From f44146cee089c0275bc6721644d7e6e179ab20e6 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 19:49:07 +0300 Subject: [PATCH 01/60] add OSRS environments: inferno, zulrah, PvP shared sim headers in src/osrs/, per-encounter bindings in ocean/osrs_*/, visual viewer + asset export pipeline in ocean/osrs/. build.sh wired up with -Isrc/osrs for osrs_* envs. --- build.sh | 6 +- config/osrs_inferno.ini | 204 + config/osrs_inferno_zuk.ini | 190 + config/osrs_pvp.ini | 39 + config/osrs_zulrah.ini | 40 + ocean/osrs/Makefile | 43 + ocean/osrs/data/.gitignore | 11 + ocean/osrs/osrs_visual.c | 651 +++ ocean/osrs/scripts/ExportItemSprites.java | 222 + ocean/osrs/scripts/export_all.sh | 197 + ocean/osrs/scripts/export_animations.py | 621 +++ .../scripts/export_collision_map_modern.py | 993 ++++ ocean/osrs/scripts/export_inferno_npcs.py | 866 ++++ ocean/osrs/scripts/export_models.py | 1850 +++++++ ocean/osrs/scripts/export_objects.py | 965 ++++ ocean/osrs/scripts/export_spotanims.py | 186 + ocean/osrs/scripts/export_sprites_modern.py | 373 ++ ocean/osrs/scripts/export_terrain.py | 935 ++++ ocean/osrs/scripts/export_textures.py | 153 + ocean/osrs/scripts/modern_cache_reader.py | 670 +++ ocean/osrs/tests/test_bolt_procs.c | 379 ++ ocean/osrs/tests/test_collision.c | 422 ++ ocean/osrs/tests/test_combat_math.c | 1112 +++++ ocean/osrs/tests/test_consumables.c | 124 + ocean/osrs/tests/test_damage.c | 389 ++ ocean/osrs/tests/test_interaction.c | 264 + ocean/osrs/tests/test_inventory.c | 417 ++ ocean/osrs/tests/test_item_effects.c | 911 ++++ ocean/osrs/tests/test_player_combat.c | 230 + ocean/osrs/tests/test_special_attacks.c | 1212 +++++ ocean/osrs/tools/README.md | 176 + ocean/osrs/tools/discover_npc_assets.py | 270 + ocean/osrs/tools/export_encounter_npcs.py | 693 +++ ocean/osrs/tools/gameval_parser.py | 82 + ocean/osrs/tools/generate_items.py | 405 ++ ocean/osrs/tools/generate_monsters.py | 315 ++ ocean/osrs/tools/items_manifest.json | 820 +++ ocean/osrs/tools/monsters_manifest.json | 224 + ocean/osrs_inferno/binding.c | 430 ++ ocean/osrs_pvp/binding.c | 247 + ocean/osrs_pvp/pfsp.py | 68 + ocean/osrs_zulrah/binding.c | 156 + src/osrs/data/item_models.h | 118 + src/osrs/data/npc_models.h | 113 + src/osrs/data/npc_models_inferno.h | 94 + src/osrs/data/npc_models_zulrah.h | 39 + src/osrs/data/player_models.h | 28 + src/osrs/encounters/encounter_inferno.h | 3348 +++++++++++++ src/osrs/encounters/encounter_nh_pvp.h | 229 + src/osrs/encounters/encounter_zulrah.h | 2274 +++++++++ src/osrs/osrs_anim.h | 681 +++ src/osrs/osrs_bolt_procs.h | 106 + src/osrs/osrs_collision.h | 586 +++ src/osrs/osrs_combat.h | 456 ++ src/osrs/osrs_consumables.h | 173 + src/osrs/osrs_damage.h | 113 + src/osrs/osrs_encounter.h | 1463 ++++++ src/osrs/osrs_env.h | 28 + src/osrs/osrs_gui.h | 1983 ++++++++ src/osrs/osrs_human_input.h | 486 ++ src/osrs/osrs_human_input_types.h | 43 + src/osrs/osrs_interaction.h | 97 + src/osrs/osrs_inventory.h | 193 + src/osrs/osrs_items.h | 316 ++ src/osrs/osrs_items_generated.h | 1350 +++++ src/osrs/osrs_models.h | 213 + src/osrs/osrs_monsters_generated.h | 255 + src/osrs/osrs_objects.h | 227 + src/osrs/osrs_pathfinding.h | 515 ++ src/osrs/osrs_pvp_actions.h | 1043 ++++ src/osrs/osrs_pvp_api.h | 787 +++ src/osrs/osrs_pvp_combat.h | 1204 +++++ src/osrs/osrs_pvp_effects.h | 366 ++ src/osrs/osrs_pvp_gear.h | 1102 +++++ src/osrs/osrs_pvp_movement.h | 507 ++ src/osrs/osrs_pvp_observations.h | 767 +++ src/osrs/osrs_pvp_opponents.h | 3666 ++++++++++++++ src/osrs/osrs_render.h | 4380 +++++++++++++++++ src/osrs/osrs_special_attacks.h | 487 ++ src/osrs/osrs_terrain.h | 186 + src/osrs/osrs_types.h | 1171 +++++ 81 files changed, 49753 insertions(+), 1 deletion(-) create mode 100644 config/osrs_inferno.ini create mode 100644 config/osrs_inferno_zuk.ini create mode 100644 config/osrs_pvp.ini create mode 100644 config/osrs_zulrah.ini create mode 100644 ocean/osrs/Makefile create mode 100644 ocean/osrs/data/.gitignore create mode 100644 ocean/osrs/osrs_visual.c create mode 100644 ocean/osrs/scripts/ExportItemSprites.java create mode 100755 ocean/osrs/scripts/export_all.sh create mode 100644 ocean/osrs/scripts/export_animations.py create mode 100644 ocean/osrs/scripts/export_collision_map_modern.py create mode 100644 ocean/osrs/scripts/export_inferno_npcs.py create mode 100644 ocean/osrs/scripts/export_models.py create mode 100644 ocean/osrs/scripts/export_objects.py create mode 100644 ocean/osrs/scripts/export_spotanims.py create mode 100644 ocean/osrs/scripts/export_sprites_modern.py create mode 100644 ocean/osrs/scripts/export_terrain.py create mode 100644 ocean/osrs/scripts/export_textures.py create mode 100644 ocean/osrs/scripts/modern_cache_reader.py create mode 100644 ocean/osrs/tests/test_bolt_procs.c create mode 100644 ocean/osrs/tests/test_collision.c create mode 100644 ocean/osrs/tests/test_combat_math.c create mode 100644 ocean/osrs/tests/test_consumables.c create mode 100644 ocean/osrs/tests/test_damage.c create mode 100644 ocean/osrs/tests/test_interaction.c create mode 100644 ocean/osrs/tests/test_inventory.c create mode 100644 ocean/osrs/tests/test_item_effects.c create mode 100644 ocean/osrs/tests/test_player_combat.c create mode 100644 ocean/osrs/tests/test_special_attacks.c create mode 100644 ocean/osrs/tools/README.md create mode 100644 ocean/osrs/tools/discover_npc_assets.py create mode 100644 ocean/osrs/tools/export_encounter_npcs.py create mode 100644 ocean/osrs/tools/gameval_parser.py create mode 100644 ocean/osrs/tools/generate_items.py create mode 100644 ocean/osrs/tools/generate_monsters.py create mode 100644 ocean/osrs/tools/items_manifest.json create mode 100644 ocean/osrs/tools/monsters_manifest.json create mode 100644 ocean/osrs_inferno/binding.c create mode 100644 ocean/osrs_pvp/binding.c create mode 100644 ocean/osrs_pvp/pfsp.py create mode 100644 ocean/osrs_zulrah/binding.c create mode 100644 src/osrs/data/item_models.h create mode 100644 src/osrs/data/npc_models.h create mode 100644 src/osrs/data/npc_models_inferno.h create mode 100644 src/osrs/data/npc_models_zulrah.h create mode 100644 src/osrs/data/player_models.h create mode 100644 src/osrs/encounters/encounter_inferno.h create mode 100644 src/osrs/encounters/encounter_nh_pvp.h create mode 100644 src/osrs/encounters/encounter_zulrah.h create mode 100644 src/osrs/osrs_anim.h create mode 100644 src/osrs/osrs_bolt_procs.h create mode 100644 src/osrs/osrs_collision.h create mode 100644 src/osrs/osrs_combat.h create mode 100644 src/osrs/osrs_consumables.h create mode 100644 src/osrs/osrs_damage.h create mode 100644 src/osrs/osrs_encounter.h create mode 100644 src/osrs/osrs_env.h create mode 100644 src/osrs/osrs_gui.h create mode 100644 src/osrs/osrs_human_input.h create mode 100644 src/osrs/osrs_human_input_types.h create mode 100644 src/osrs/osrs_interaction.h create mode 100644 src/osrs/osrs_inventory.h create mode 100644 src/osrs/osrs_items.h create mode 100644 src/osrs/osrs_items_generated.h create mode 100644 src/osrs/osrs_models.h create mode 100644 src/osrs/osrs_monsters_generated.h create mode 100644 src/osrs/osrs_objects.h create mode 100644 src/osrs/osrs_pathfinding.h create mode 100644 src/osrs/osrs_pvp_actions.h create mode 100644 src/osrs/osrs_pvp_api.h create mode 100644 src/osrs/osrs_pvp_combat.h create mode 100644 src/osrs/osrs_pvp_effects.h create mode 100644 src/osrs/osrs_pvp_gear.h create mode 100644 src/osrs/osrs_pvp_movement.h create mode 100644 src/osrs/osrs_pvp_observations.h create mode 100644 src/osrs/osrs_pvp_opponents.h create mode 100644 src/osrs/osrs_render.h create mode 100644 src/osrs/osrs_special_attacks.h create mode 100644 src/osrs/osrs_terrain.h create mode 100644 src/osrs/osrs_types.h diff --git a/build.sh b/build.sh index 0a4664756d..0f741d5263 100755 --- a/build.sh +++ b/build.sh @@ -116,6 +116,9 @@ 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./src/osrs) elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" else @@ -216,8 +219,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..fa61afb28e --- /dev/null +++ b/config/osrs_inferno.ini @@ -0,0 +1,204 @@ +# Metal config for OSRS Inferno encounter. +# 8 action heads (79 logits), 1058 obs, long episodes (300-8000+ ticks). + +[base] +package = ocean +env_name = puffer_osrs_inferno +policy_name = MinGRU +rnn_name = Recurrent +score_metric = episode_return + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 +# curriculum: fraction of agents starting at later waves (rest at start_wave) +curriculum_wave_1 = 20.0 +curriculum_frac_1 = 0.10 +curriculum_wave_2 = 40.0 +curriculum_frac_2 = 0.05 +curriculum_wave_3 = 60.0 +curriculum_frac_3 = 0.05 + +[vec] +total_agents = 256 +num_buffers = 2 + +[policy] +hidden_size = 512 +num_layers = 3 + +[train] +# anchor from CUDA sweep (100 trials, 4080S). best config for wave 28-30 range. +# real progress requires 10B+ steps on multi-GPU — short sweeps only find brute-force configs. +total_timesteps = 1600000000 +horizon = 64 +min_lr_ratio = 0.4465 +learning_rate = 0.004373 +beta1 = 0.9337 +eps = 0.000012 +ent_coef = 0.026798 +gamma = 0.9999145 +gae_lambda = 0.6379 +vtrace_rho_clip = 1.552 +vtrace_c_clip = 1.322 +prio_alpha = 0.0 +prio_beta0 = 0.6249 +clip_coef = 0.2 +vf_coef = 0.2132 +vf_clip_coef = 0.282447 +max_grad_norm = 1.799 +replay_ratio = 1.538 +minibatch_size = 4096 +ns_iters = 5 +weight_decay = 0.00227 +checkpoint_interval = 0 + +[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_inferno_zuk.ini b/config/osrs_inferno_zuk.ini new file mode 100644 index 0000000000..fb57150d78 --- /dev/null +++ b/config/osrs_inferno_zuk.ini @@ -0,0 +1,190 @@ +# Metal config for Zuk-only training (wave 69). +# short episodes (~30-300 ticks), focused on learning shield-dancing + DPS. +# default config from best sweep trial (trial 12, score 32.2). + +[base] +package = ocean +env_name = puffer_osrs_inferno +policy_name = MinGRU +rnn_name = Recurrent +score_metric = episode_return + +[env] +start_wave = 69.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 512 +num_buffers = 4 + +[policy] +hidden_size = 128 +num_layers = 3 + +[train] +total_timesteps = 100000000 +horizon = 32 +learning_rate = 0.00139 +beta1 = 0.8 +beta2 = 0.982 +ent_coef = 0.05 +gamma = 0.99988 +gae_lambda = 0.591 +vtrace_rho_clip = 1.0 +vtrace_c_clip = 2.24 +prio_alpha = 0.999 +prio_beta0 = 0.346 +clip_coef = 0.829 +vf_coef = 0.214 +vf_clip_coef = 1.856 +max_grad_norm = 2.29 +replay_ratio = 2.47 +minibatch_size = 2048 +ns_iters = 5 +weight_decay = 0.016 +min_lr_ratio = 0.077 +checkpoint_interval = 0 + +[sweep] +min_sps = 50000 +max_suggestion_cost = 300 +metric = episode_return +metric_distribution = linear + +[sweep.train.total_timesteps] +distribution = log_normal +min = 100000000 +max = 200000000 +scale = time + +[sweep.train.horizon] +distribution = uniform_pow2 +min = 8 +max = 128 +scale = auto + +[sweep.train.learning_rate] +distribution = log_normal +min = 0.0002 +max = 0.01 +scale = 0.5 + +[sweep.train.ent_coef] +distribution = log_normal +min = 0.0001 +max = 0.05 +scale = auto + +[sweep.train.gamma] +distribution = logit_normal +min = 0.999 +max = 0.999999 +scale = auto + +[sweep.train.beta1] +distribution = uniform +min = 0.8 +max = 0.95 +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.replay_ratio] +distribution = uniform +min = 0.5 +max = 3.0 +scale = auto + +[sweep.train.clip_coef] +distribution = uniform +min = 0.2 +max = 2.0 +scale = auto + +[sweep.train.vf_coef] +distribution = log_normal +min = 0.01 +max = 1.0 +scale = auto + +[sweep.train.vf_clip_coef] +distribution = uniform +min = 0.2 +max = 2.0 +scale = auto + +[sweep.train.max_grad_norm] +distribution = uniform +min = 0.5 +max = 3.0 +scale = auto + +[sweep.train.weight_decay] +distribution = log_normal +min = 0.005 +max = 0.1 +scale = auto + +[sweep.train.min_lr_ratio] +distribution = uniform +min = 0.0 +max = 0.3 +scale = auto + +[sweep.train.minibatch_size] +distribution = uniform_pow2 +min = 256 +max = 4096 +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.5 +scale = auto + +[sweep.vec.total_agents] +distribution = uniform_pow2 +min = 256 +max = 2048 +scale = auto + +[sweep.vec.num_buffers] +distribution = uniform_pow2 +min = 2 +max = 4 +scale = auto + +[sweep.policy.hidden_size] +distribution = uniform_pow2 +min = 128 +max = 512 +scale = auto + +[sweep.policy.num_layers] +distribution = uniform +min = 2 +max = 3 +scale = auto diff --git a/config/osrs_pvp.ini b/config/osrs_pvp.ini new file mode 100644 index 0000000000..8610497c5a --- /dev/null +++ b/config/osrs_pvp.ini @@ -0,0 +1,39 @@ +# Metal config for OSRS NH PvP encounter. +# 7 action heads (39 logits), 334 obs + 39 mask = 373 total, short episodes (~300 ticks). + +[base] +package = ocean +env_name = puffer_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 +checkpoint_interval = 0 diff --git a/config/osrs_zulrah.ini b/config/osrs_zulrah.ini new file mode 100644 index 0000000000..1a6c7d43e5 --- /dev/null +++ b/config/osrs_zulrah.ini @@ -0,0 +1,40 @@ +# Metal config for OSRS Zulrah encounter. +# 6 action heads (41 logits), 81 obs + 41 mask = 122 total, medium episodes (~600 ticks max). + +[base] +package = ocean +env_name = puffer_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 +checkpoint_interval = 0 diff --git a/ocean/osrs/Makefile b/ocean/osrs/Makefile new file mode 100644 index 0000000000..380b9fb2e5 --- /dev/null +++ b/ocean/osrs/Makefile @@ -0,0 +1,43 @@ +# OSRS environment Makefile +# +# standalone targets (no PufferLib dependency): +# make — headless benchmark binary +# make visual — headed raylib viewer with human input +# make debug — debug build with sanitizers +# +# PufferLib training uses setup.py build_osrs instead. + +CC = clang +CFLAGS = -Wall -Wextra -O3 -ffast-math -flto -fPIC -std=c11 +DEBUG_FLAGS = -Wall -Wextra -g -O0 -fPIC -std=c11 -DDEBUG +LDFLAGS = -lm + +TARGET = osrs_visual +DEMO_SRC = osrs_visual.c +HEADERS = osrs_env.h + +# Raylib (for visual target). download from https://github.com/raysan5/raylib/releases +RAYLIB_DIR = raylib-5.5_macos +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include $(RAYLIB_DIR)/lib/libraylib.a \ + -framework Cocoa -framework OpenGL -framework IOKit -framework CoreVideo +else +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include -L$(RAYLIB_DIR)/lib -lraylib -lGL -lpthread -ldl -lrt +endif + +.PHONY: all clean debug visual + +all: $(TARGET) + +$(TARGET): $(DEMO_SRC) $(HEADERS) + $(CC) $(CFLAGS) -o $@ $(DEMO_SRC) $(LDFLAGS) + +visual: $(DEMO_SRC) $(HEADERS) osrs_render.h osrs_gui.h + $(CC) $(CFLAGS) -DOSRS_VISUAL $(RAYLIB_FLAGS) -o $(TARGET) $(DEMO_SRC) $(LDFLAGS) + +debug: $(DEMO_SRC) $(HEADERS) + $(CC) $(DEBUG_FLAGS) -o $(TARGET)_debug $(DEMO_SRC) $(LDFLAGS) + +clean: + rm -f $(TARGET) $(TARGET)_debug *.o 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/osrs_visual.c b/ocean/osrs/osrs_visual.c new file mode 100644 index 0000000000..9ceb48c9d2 --- /dev/null +++ b/ocean/osrs/osrs_visual.c @@ -0,0 +1,651 @@ +/** + * @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); +} + +#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); + rc->gui.inv_grid_dirty = 1; + if (env->encounter_def) { + ((const EncounterDef*)env->encounter_def)->reset( + env->encounter_state, (uint32_t)rand()); + } else { + pvp_reset(env); + } + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + rc->sub_x[i] = rc->entities[i].x * 128 + 64; + rc->sub_y[i] = rc->entities[i].y * 128 + 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + render_save_snapshot(rc, env); + } + 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}; + + 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]; + } + } + 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 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], "--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_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"); + printf("===========================\n\n"); + + printf("Running single verbose episode...\n"); + run_random_episode(&env, 1); + + printf("\n"); + benchmark(&env, 100000); + + printf("\nVerifying observations...\n"); + pvp_reset(&env); + printf("Observation count per agent: %d\n", SLOT_NUM_OBSERVATIONS); + printf("First 10 observations (agent 0): "); + for (int i = 0; i < 10; i++) { + printf("%.2f ", env.observations[i]); + } + printf("\n"); + + printf("\nAction heads: %d\n", NUM_ACTION_HEADS); + printf("Action dims: ["); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + printf("%d", ACTION_HEAD_DIMS[i]); + if (i < NUM_ACTION_HEADS - 1) { + printf(", "); + } + } + printf("]\n"); + + printf("\nDemo complete.\n"); + + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + free(env.action_masks); + free(env.ocean_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..6fe57424e2 --- /dev/null +++ b/ocean/osrs/scripts/ExportItemSprites.java @@ -0,0 +1,222 @@ +/** + * Export item inventory sprites from OpenRS2 flat cache using RuneLite's + * ItemSpriteFactory (3D model → 2D sprite rendering). + * + * Reads the modern cache at reference/osrs-cache-modern/ (OpenRS2 flat format: + * {index}/{group}.dat files). Renders each requested item ID to a 36x32 PNG + * matching the real OSRS inventory icon exactly. + * + * Usage: + * javac -cp : scripts/ExportItemSprites.java + * java -cp ::scripts ExportItemSprites \ + * --cache ../reference/osrs-cache-modern \ + * --output data/sprites/items \ + * --ids 4151,10828,21795,... + */ + +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import javax.imageio.ImageIO; + +import net.runelite.cache.*; +import net.runelite.cache.definitions.*; +import net.runelite.cache.definitions.loaders.*; +import net.runelite.cache.definitions.providers.*; +import net.runelite.cache.fs.*; +import net.runelite.cache.index.*; +import net.runelite.cache.item.ItemSpriteFactory; + +/** + * Custom Storage that reads OpenRS2 flat cache format ({index}/{group}.dat). + * Reference tables are at 255/{index}.dat. + */ +class OpenRS2Storage implements Storage { + private final File cacheDir; + + OpenRS2Storage(File cacheDir) { + this.cacheDir = cacheDir; + } + + @Override + public void init(Store store) throws IOException { + // discover index IDs from directories under cache root + // (exclude 255 which is the meta-index) + File[] dirs = cacheDir.listFiles(File::isDirectory); + if (dirs == null) throw new IOException("cache dir not readable: " + cacheDir); + + List indexIds = new ArrayList<>(); + for (File d : dirs) { + try { + int id = Integer.parseInt(d.getName()); + if (id != 255) indexIds.add(id); + } catch (NumberFormatException ignored) {} + } + Collections.sort(indexIds); + for (int id : indexIds) { + store.addIndex(id); + } + } + + @Override + public void load(Store store) throws IOException { + for (Index index : store.getIndexes()) { + loadIndex(index); + } + } + + private void loadIndex(Index index) throws IOException { + // reference table for this index is at 255/{indexId}.dat + File refFile = new File(cacheDir, "255/" + index.getId() + ".dat"); + if (!refFile.exists()) { + System.err.println("warning: no reference table for index " + index.getId()); + return; + } + + byte[] refData = Files.readAllBytes(refFile.toPath()); + Container container = Container.decompress(refData, null); + byte[] data = container.data; + + IndexData indexData = new IndexData(); + indexData.load(data); + + index.setProtocol(indexData.getProtocol()); + index.setRevision(indexData.getRevision()); + index.setNamed(indexData.isNamed()); + + for (ArchiveData ad : indexData.getArchives()) { + Archive archive = index.addArchive(ad.getId()); + archive.setNameHash(ad.getNameHash()); + archive.setCrc(ad.getCrc()); + archive.setRevision(ad.getRevision()); + archive.setFileData(ad.getFiles()); + } + } + + @Override + public byte[] load(int index, int archive) throws IOException { + File f = new File(cacheDir, index + "/" + archive + ".dat"); + if (!f.exists()) return null; + return Files.readAllBytes(f.toPath()); + } + + @Override + public void save(Store store) throws IOException { + throw new UnsupportedOperationException("read-only"); + } + + @Override + public void store(int index, int archive, byte[] data) throws IOException { + throw new UnsupportedOperationException("read-only"); + } + + @Override + public void close() throws IOException {} +} + +public class ExportItemSprites { + public static void main(String[] args) throws Exception { + String cachePath = "../reference/osrs-cache-modern"; + String outputPath = "data/sprites/items"; + String idsArg = null; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--cache": cachePath = args[++i]; break; + case "--output": outputPath = args[++i]; break; + case "--ids": idsArg = args[++i]; break; + } + } + + File cacheDir = new File(cachePath); + File outDir = new File(outputPath); + outDir.mkdirs(); + + System.out.println("opening cache: " + cacheDir.getAbsolutePath()); + + try (Store store = new Store(new OpenRS2Storage(cacheDir))) { + store.load(); + + // load items + ItemManager itemManager = new ItemManager(store); + itemManager.load(); + itemManager.link(); + + // model provider: reads from index 7 + ModelProvider modelProvider = modelId -> { + Index models = store.getIndex(IndexType.MODELS); + Archive archive = models.getArchive(modelId); + if (archive == null) return null; + byte[] data = archive.decompress(store.getStorage().loadArchive(archive)); + return new ModelLoader().load(modelId, data); + }; + + // sprite manager + SpriteManager spriteManager = new SpriteManager(store); + spriteManager.load(); + + // texture manager — may fail on modern cache format, potions don't need textures + TextureManager textureManager = new TextureManager(store); + try { + textureManager.load(); + } catch (Exception e) { + System.err.println("warning: TextureManager.load() failed: " + e.getMessage()); + System.err.println(" continuing without textures (potions render fine without them)"); + } + + // parse item IDs to export + Set targetIds = new HashSet<>(); + if (idsArg != null) { + for (String s : idsArg.split(",")) { + targetIds.add(Integer.parseInt(s.trim())); + } + } + + int exported = 0, failed = 0; + + Collection items; + if (targetIds.isEmpty()) { + // export all items with valid models + items = itemManager.getItems(); + } else { + items = new ArrayList<>(); + for (int id : targetIds) { + ItemDefinition def = itemManager.getItem(id); + if (def != null) ((ArrayList) items).add(def); + else System.err.println(" item " + id + ": not found"); + } + } + + for (ItemDefinition itemDef : items) { + if (itemDef.name == null || itemDef.name.equalsIgnoreCase("null")) continue; + if (targetIds.isEmpty() && itemDef.inventoryModel <= 0) continue; + + try { + BufferedImage sprite = ItemSpriteFactory.createSprite( + itemManager, modelProvider, spriteManager, textureManager, + itemDef.id, 1, 1, 0x303030, false); + + if (sprite == null) { + System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): null sprite"); + failed++; + continue; + } + + File out = new File(outDir, itemDef.id + ".png"); + ImageIO.write(sprite, "PNG", out); + exported++; + + System.out.println(" " + itemDef.id + " (" + itemDef.name + "): " + + sprite.getWidth() + "x" + sprite.getHeight()); + } catch (Exception ex) { + System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): " + ex.getMessage()); + failed++; + } + } + + System.out.println("\nexported " + exported + " item sprites, " + failed + " failed"); + System.out.println("output: " + outDir.getAbsolutePath()); + } + } +} diff --git a/ocean/osrs/scripts/export_all.sh b/ocean/osrs/scripts/export_all.sh new file mode 100755 index 0000000000..089cff932f --- /dev/null +++ b/ocean/osrs/scripts/export_all.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# export all visual assets from an OSRS modern cache (OpenRS2 flat file format). +# +# usage: +# cd ocean/osrs +# ./scripts/export_all.sh [keys.json] +# +# 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. +# +# XTEA keys (keys.json) are needed for terrain/objects in encrypted regions. +# if not provided, the script looks for keys.json inside the cache dir. +# +# idempotent: skips any asset that already exists. delete a file to re-export it. +# all output goes to data/. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOOLS_DIR="$SCRIPT_DIR/../tools" +DATA_DIR="$SCRIPT_DIR/../data" +mkdir -p "$DATA_DIR" "$DATA_DIR/sprites/gui" "$DATA_DIR/sprites/items" + +if [ $# -lt 1 ]; then + echo "usage: $0 [keys.json]" + 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="${2:-$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 — encrypted regions (terrain/objects) will fail" + KEYS="" +fi + +KEYS_ARG="" +[ -n "$KEYS" ] && KEYS_ARG="--keys $KEYS" + +skip_if_exists() { + if [ -f "$1" ]; then + echo " skip: $1 (exists)" + return 0 + fi + return 1 +} + +# ============================================================================ +# shared assets (all encounters) +# ============================================================================ + +echo "=== equipment models (player body + worn gear) ===" +if ! skip_if_exists "$DATA_DIR/equipment.models"; then + python "$SCRIPT_DIR/export_models.py" \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/equipment.models" \ + --extra-models 14407,14408,14409,10415,20390,11221,26593,4086 +fi + +echo "=== equipment animations ===" +if ! skip_if_exists "$DATA_DIR/equipment.anims"; then + python "$SCRIPT_DIR/export_animations.py" \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/equipment.anims" +fi + +echo "=== GUI sprites (prayer icons, hitsplats, UI chrome) ===" +# sprites are many small files; check for a sentinel +if ! skip_if_exists "$DATA_DIR/sprites/gui/pray_melee.png"; then + python "$SCRIPT_DIR/export_sprites_modern.py" \ + --cache "$CACHE" \ + --output "$DATA_DIR/sprites/gui" +fi + +# ============================================================================ +# zulrah +# ============================================================================ + +echo "=== zulrah NPC models + animations ===" +if ! skip_if_exists "$DATA_DIR/zulrah.models"; then + python "$TOOLS_DIR/export_encounter_npcs.py" \ + --group zulrah \ + --modern-cache "$CACHE" \ + --output-dir "$DATA_DIR" +fi + +echo "=== zulrah collision map ===" +if ! skip_if_exists "$DATA_DIR/zulrah.cmap"; then + python "$SCRIPT_DIR/export_collision_map_modern.py" \ + --cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/zulrah.cmap" \ + --regions 35,47 35,48 +fi + +echo "=== zulrah terrain ===" +if ! skip_if_exists "$DATA_DIR/zulrah.terrain"; then + python "$SCRIPT_DIR/export_terrain.py" \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/zulrah.terrain" \ + --regions 35,47 35,48 +fi + +echo "=== zulrah objects ===" +if ! skip_if_exists "$DATA_DIR/zulrah.objects"; then + python "$SCRIPT_DIR/export_objects.py" \ + --modern-cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/zulrah.objects" \ + --regions 35,47 35,48 +fi + +# ============================================================================ +# inferno +# ============================================================================ + +echo "=== inferno NPC models + animations ===" +if ! skip_if_exists "$DATA_DIR/inferno.models"; then + python "$TOOLS_DIR/export_encounter_npcs.py" \ + --group inferno \ + --modern-cache "$CACHE" \ + --output-dir "$DATA_DIR" +fi + +echo "=== inferno collision map ===" +if ! skip_if_exists "$DATA_DIR/inferno.cmap"; then + python "$SCRIPT_DIR/export_collision_map_modern.py" \ + --cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/inferno.cmap" \ + --regions 35,83 +fi + +echo "=== inferno terrain ===" +if ! skip_if_exists "$DATA_DIR/inferno.terrain"; then + python "$SCRIPT_DIR/export_terrain.py" \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/inferno.terrain" \ + --regions 35,83 +fi + +echo "=== inferno objects (full arena) ===" +if ! skip_if_exists "$DATA_DIR/inferno.objects"; then + python "$SCRIPT_DIR/export_objects.py" \ + --modern-cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/inferno.objects" \ + --regions 35,83 +fi + +echo "=== inferno objects (zuk arena only, pillars removed) ===" +if ! skip_if_exists "$DATA_DIR/inferno_zuk.objects"; then + python "$SCRIPT_DIR/export_objects.py" \ + --modern-cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/inferno_zuk.objects" \ + --regions 35,83 \ + --exclude-ids "30327,30328,30329,30330,30331,30332,30333,30334,30335,30336,30337,30338,30356" +fi + +# ============================================================================ +# PvP (wilderness) +# ============================================================================ + +echo "=== wilderness collision map ===" +if ! skip_if_exists "$DATA_DIR/wilderness.cmap"; then + python "$SCRIPT_DIR/export_collision_map_modern.py" \ + --cache "$CACHE" $KEYS_ARG \ + --output "$DATA_DIR/wilderness.cmap" \ + --wilderness +fi + +echo "=== wilderness terrain ===" +if ! skip_if_exists "$DATA_DIR/wilderness.terrain"; then + python "$SCRIPT_DIR/export_terrain.py" \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/wilderness.terrain" \ + --wilderness +fi + +# wilderness.objects is 685MB+ — skip by default +echo "=== wilderness objects (skipped, 685MB+) ===" +echo " run manually: python scripts/export_objects.py --modern-cache \$CACHE --keys \$KEYS --output data/wilderness.objects --wilderness" + +# ============================================================================ +# done +# ============================================================================ + +echo "" +echo "done. assets exported to $DATA_DIR/" +echo "" +echo "note: 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..a38fa6a9c6 --- /dev/null +++ b/ocean/osrs/scripts/export_collision_map_modern.py @@ -0,0 +1,993 @@ +"""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 ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/zulrah.cmap \ + --regions 35,48 + + # export multiple regions + uv run python scripts/export_collision_map_modern.py \ + --cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/world.cmap \ + --regions 35,48 34,48 36,48 + + # export wilderness regions + uv run python scripts/export_collision_map_modern.py \ + --cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/wilderness.cmap \ + --wilderness +""" + +import argparse +import bz2 +import io +import json +import struct +import sys +import zlib +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_smart, + read_string, + read_u16, + read_u8, +) + +# --- 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=Path("../reference/osrs-cache-modern"), + help="path to modern cache directory", + ) + parser.add_argument( + "--keys", + type=Path, + default=Path("../reference/osrs-cache-modern/keys.json"), + help="path to XTEA keys JSON from OpenRS2", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/zulrah.cmap"), + help="output .cmap binary file", + ) + parser.add_argument( + "--regions", + nargs="+", + type=str, + help="region coordinates as rx,ry pairs (e.g. 35,48 34,48)", + ) + parser.add_argument( + "--wilderness", + action="store_true", + help="export wilderness regions (rx=44-56, ry=48-62)", + ) + parser.add_argument( + "--all-regions", + action="store_true", + help="export all available regions", + ) + parser.add_argument( + "--ascii", + action="store_true", + help="print ASCII visualization of collision map", + ) + args = parser.parse_args() + + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + if not args.keys.exists(): + sys.exit(f"XTEA keys file not found: {args.keys}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + + print(f"reading modern cache from {args.cache}") + reader = ModernCacheReader(args.cache) + + print("loading XTEA keys...") + xtea_keys = load_xtea_keys(args.keys) + print(f" {len(xtea_keys)} region keys loaded") + + print("loading modern object definitions...") + obj_defs = decode_modern_obj_defs(reader) + print(f" {len(obj_defs)} object definitions parsed") + + print("scanning index 5 for map groups...") + map_groups = find_map_groups(reader) + print(f" {len(map_groups)} regions found in index 5") + + # determine which regions to export + target_mapsquares: set[int] = set() + + if args.regions: + for coord in args.regions: + parts = coord.split(",") + rx, ry = int(parts[0]), int(parts[1]) + ms = (rx << 8) | ry + target_mapsquares.add(ms) + elif args.wilderness: + for rx in range(44, 57): + for ry in range(48, 63): + ms = (rx << 8) | ry + if ms in map_groups: + target_mapsquares.add(ms) + elif args.all_regions: + target_mapsquares = set(map_groups.keys()) + else: + # default: Zulrah region + target_mapsquares.add((35 << 8) | 48) + + print(f"\nexporting {len(target_mapsquares)} regions...") + + output_regions: dict[int, CollisionFlags] = {} + decoded = 0 + errors = 0 + total_obj_marked = 0 + + for ms in sorted(target_mapsquares): + rx = (ms >> 8) & 0xFF + ry = ms & 0xFF + + if ms not in map_groups: + print(f" region ({rx},{ry}): not found in index 5") + errors += 1 + continue + + terrain_gid, obj_gid = map_groups[ms] + + # parse terrain + if terrain_gid is None: + print(f" region ({rx},{ry}): no terrain group") + errors += 1 + continue + + terrain_data = reader.read_container(5, terrain_gid) + if terrain_data is None: + print(f" region ({rx},{ry}): failed to read terrain") + errors += 1 + continue + + flags, down_heights = parse_terrain(terrain_data) + + # parse objects (XTEA encrypted) + obj_marked = 0 + if obj_gid is not None: + key = xtea_keys.get(ms) + if key is None: + print(f" region ({rx},{ry}): no XTEA key, skipping objects") + else: + raw_obj = reader._read_raw(5, obj_gid) + if raw_obj is not None and len(raw_obj) >= 5: + # container: compression(1) + compressed_len(4) + encrypted_payload + # XTEA starts at byte 5 (decompressed_len is also encrypted) + compression = raw_obj[0] + compressed_len = struct.unpack(">I", raw_obj[1:5])[0] + decrypted = xtea_decrypt(raw_obj[5:], key) + + if compression == 0: + obj_data = decrypted[:compressed_len] + else: + # decrypted[0:4] = decompressed_len, decrypted[4:] = compressed data + gzip_data = decrypted[4 : 4 + compressed_len] + if compression == 2: + # gzip — use raw inflate to avoid CRC issues from XTEA padding + obj_data = zlib.decompress(gzip_data[10:], -zlib.MAX_WBITS) + elif compression == 1: + # bzip2 — strip 'BZ' header + obj_data = bz2.decompress(b"BZh1" + gzip_data) + + obj_marked = parse_objects_modern( + obj_data, flags, down_heights, obj_defs + ) + total_obj_marked += obj_marked + + output_regions[ms] = flags + decoded += 1 + + # per-region stats + blocked = sum( + 1 + for x in range(64) + for y in range(64) + if flags[0][x][y] & BLOCKED + ) + walled = sum( + 1 + for x in range(64) + for y in range(64) + if flags[0][x][y] & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST) + ) + print( + f" region ({rx},{ry}): " + f"{blocked} blocked, {walled} walled, {obj_marked} objects marked" + ) + + print(f"\ndecoded {decoded} regions, {errors} skipped") + print(f"total objects marked for collision: {total_obj_marked}") + + # overall stats + total_blocked = 0 + total_walled = 0 + for region_flags in output_regions.values(): + for x in range(64): + for y in range(64): + f = region_flags[0][x][y] + if f & BLOCKED: + total_blocked += 1 + if f & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST): + total_walled += 1 + + print(f"height-0 totals: {total_blocked} blocked, {total_walled} walled") + + # ASCII visualization + if args.ascii and len(output_regions) == 1: + ms = next(iter(output_regions)) + rx = (ms >> 8) & 0xFF + ry = ms & 0xFF + region_flags = output_regions[ms] + + print(f"\n--- collision map for region ({rx},{ry}) height 0 ---") + print(" legend: . = walkable, # = blocked (terrain), W = walled (object)") + print(" B = blocked (object), X = blocked + walled") + print() + + for local_y in range(63, -1, -1): + row = [] + for local_x in range(64): + f = region_flags[0][local_x][local_y] + has_block = bool(f & BLOCKED) + has_wall = bool( + f & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST) + ) + has_imp_block = bool(f & IMPENETRABLE_BLOCKED) + + if has_block and has_wall: + row.append("X") + elif has_wall: + row.append("W") + elif has_imp_block: + row.append("B") + elif has_block: + row.append("#") + else: + row.append(".") + print("".join(row)) + + write_cmap(args.output, output_regions) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py new file mode 100644 index 0000000000..78e8766339 --- /dev/null +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -0,0 +1,866 @@ +"""Export inferno NPC models, animations, and spotanim GFX from modern OSRS cache. + +Reads NPC definitions for all inferno monsters (nibblers through Zuk), extracts +their model IDs and animation sequence IDs, exports 3D meshes to .models binary, +exports animations to .anims binary, and updates npc_models.h with mappings. + +Also reads SpotAnim (GFX) configs for inferno projectiles. + +Usage: + uv run python scripts/export_inferno_npcs.py \ + --modern-cache /path/to/osrs-cache-modern \ + --output-dir data +""" + +import argparse +import copy +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_i32, + read_string, + read_u8, + read_u16, + read_u24, + read_u32, +) +from export_models import ( + MDL2_MAGIC, + ModelData, + _merge_models, + decode_model, + expand_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + ANIM_MAGIC, + FrameBaseDef, + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + parse_modern_framebase, + write_animations_binary, +) +from modern_cache_reader import parse_sequence as parse_modern_sequence + +# modern cache layout +MODERN_NPC_CONFIG_GROUP = 9 # config index 2, group 9 = NPC definitions +MODERN_SPOTANIM_CONFIG_GROUP = 13 # config index 2, group 13 = SpotAnim/GFX +MODERN_SEQ_CONFIG_GROUP = 12 # config index 2, group 12 = sequences +MODERN_FRAME_INDEX = 0 # frame archives +MODERN_FRAMEBASE_INDEX = 1 # frame bases + +# inferno NPC IDs from the OSRS wiki +INFERNO_NPC_IDS = { + 7691: "Jal-Nib (nibbler)", + 7692: "Jal-MejRah (bat)", + 7693: "Jal-Ak (blob)", + 7694: "Jal-Ak-Rek-Ket (blob melee split)", + 7695: "Jal-Ak-Rek-Xil (blob range split)", + 7696: "Jal-Ak-Rek-Mej (blob mage split)", + 7697: "Jal-ImKot (meleer)", + 7698: "Jal-Xil (ranger)", + 7699: "Jal-Zek (mager)", + 7700: "JalTok-Jad", + 7701: "Yt-HurKot (jad healer)", + 7706: "TzKal-Zuk", + 7707: "Zuk shield", + 7708: "Jal-MejJak (zuk healer)", +} + +# attack anims are NOT in cache NPC config — they come from CombatAnimationData +# which is a separate client table. hardcoded from wiki/runelite/deob client. +INFERNO_ATTACK_ANIMS: dict[int, int] = { + 7691: 7574, # nibbler + 7692: 7578, # bat + 7693: 7581, # blob + 7694: 65535, # blob melee split (no attack anim) + 7695: 65535, # blob range split (no attack anim) + 7696: 65535, # blob mage split (no attack anim) + 7697: 7597, # meleer + 7698: 7605, # ranger + 7699: 7610, # mager + 7700: 7593, # jad + 7701: 65535, # healer jad (no attack anim) + 7706: 7566, # zuk + 7707: 65535, # zuk shield (no attack anim) + 7708: 65535, # zuk healer (no attack anim) +} + +# known inferno projectile/effect GFX IDs to check +# from OSRS wiki inferno page and runelite inferno plugin +INFERNO_SPOTANIM_IDS = { + # jad attacks + 447: "Jad ranged projectile (fireball)", + 448: "Jad magic projectile", + 451: "Jad ranged hit", + 157: "Jad magic hit", + # mager + 1379: "Mager magic projectile", + 1380: "Mager magic hit", + # ranger + 1377: "Ranger ranged projectile", + 1378: "Ranger ranged hit", + # zuk + 1375: "Zuk magic projectile", + 1376: "Zuk ranged projectile", + 1381: "Zuk typeless hit (falling rocks?)", + # bat + 1374: "Bat ranged projectile", + # blob + 1382: "Blob melee", + 1383: "Blob ranged", + 1384: "Blob magic", + # healer + 1385: "Healer magic attack", + # player projectiles (needed for tbow in inferno) + 942: "Dragon arrow projectile (twisted bow)", +} + + +@dataclass +class NpcDef: + """NPC definition from modern OSRS cache.""" + + npc_id: int = 0 + name: str = "" + model_ids: list[int] = field(default_factory=list) + chathead_model_ids: list[int] = field(default_factory=list) + size: int = 1 + idle_anim: int = -1 + walk_anim: int = -1 + run_anim: int = -1 + turn_180_anim: int = -1 + turn_cw_anim: int = -1 + turn_ccw_anim: int = -1 + attack_anim: int = -1 # from wiki/runelite, not in def directly + death_anim: int = -1 # from wiki/runelite, not in def directly + combat_level: int = 0 + width_scale: int = 128 + height_scale: int = 128 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + retexture_src: list[int] = field(default_factory=list) + retexture_dst: list[int] = field(default_factory=list) + + +@dataclass +class SpotAnimDef: + """SpotAnim (GFX) definition from modern OSRS cache.""" + + id: int = 0 + model_id: int = -1 + seq_id: int = -1 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + width_scale: int = 128 + height_scale: int = 128 + rotation: int = 0 + ambient: int = 0 + contrast: int = 0 + + +def parse_modern_npc_def(npc_id: int, data: bytes) -> NpcDef: + """Parse modern OSRS NPC definition from opcode stream. + + Opcode reference from RuneLite NpcLoader (modern revisions): + 1: model IDs (u8 count, u16[count]) + 2: name (string) + 12: size (u8) + 13: idle animation (u16) + 14: walk animation (u16) + 15: turn 180 animation (u16) + 16: turn CW animation (u16, modern split from old 17) + 17: turn CCW animation (u16) + 18: unused / walk backward (u16) + 19: unused (u8 from modern, or actions in old) + 30-34: actions (string each) + 40: recolor pairs (u8 count, u16+u16 per pair) + 41: retexture pairs (u8 count, u16+u16 per pair) + 60: chathead model IDs (u8 count, u16[count]) + 93: drawMapDot = false (flag) + 95: combat level (u16) + 97: width scale (u16) + 98: height scale (u16) + 99: hasRenderPriority (flag) + 100: ambient (u8) + 101: contrast (u8) + 102: head icon (bitfield + smart pairs) + 103: rotation (u16) + 106: morph (varbit+varp+count+children) + 107: isInteractable = false (flag) + 108: isPet = false (modern) + 109: isClickable = false (flag) + 111: isFollower (flag) + 114-118: various transform/morph opcodes + 249: params map + """ + d = NpcDef(npc_id=npc_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + count = read_u8(buf) + d.model_ids = [read_u16(buf) for _ in range(count)] + elif opcode == 2: + d.name = read_string(buf) + elif opcode == 3: + read_string(buf) # description (removed in modern, but handle gracefully) + elif opcode == 5: + # pre-modern: another model list? skip + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + elif opcode == 12: + d.size = read_u8(buf) + elif opcode == 13: + d.idle_anim = read_u16(buf) + elif opcode == 14: + d.walk_anim = read_u16(buf) + elif opcode == 15: + d.turn_180_anim = read_u16(buf) # idleRotateLeftAnimation + elif opcode == 16: + d.turn_cw_anim = read_u16(buf) # idleRotateRightAnimation + elif opcode == 17: + # walk + rotate180 + rotateLeft + rotateRight (4 x u16) + d.walk_anim = read_u16(buf) + d.turn_180_anim = read_u16(buf) + d.turn_cw_anim = read_u16(buf) + d.turn_ccw_anim = read_u16(buf) + elif opcode == 18: + read_u16(buf) # category + elif 30 <= opcode <= 34: + read_string(buf) # actions[0..4] + elif opcode == 40: + count = read_u8(buf) + for _ in range(count): + d.recolor_src.append(read_u16(buf)) + d.recolor_dst.append(read_u16(buf)) + elif opcode == 41: + count = read_u8(buf) + for _ in range(count): + d.retexture_src.append(read_u16(buf)) + d.retexture_dst.append(read_u16(buf)) + elif opcode == 60: + count = read_u8(buf) + d.chathead_model_ids = [read_u16(buf) for _ in range(count)] + elif 74 <= opcode <= 79: + read_u16(buf) # stats[opcode - 74] (attack/def/str/range/magic/hp) + elif opcode == 93: + pass # drawMapDot = false + elif opcode == 95: + d.combat_level = read_u16(buf) + elif opcode == 97: + d.width_scale = read_u16(buf) + elif opcode == 98: + d.height_scale = read_u16(buf) + elif opcode == 99: + pass # hasRenderPriority + elif opcode == 100: + read_u8(buf) # ambient + elif opcode == 101: + read_u8(buf) # contrast + elif opcode == 102: + # head icon sprite — u8 bitfield, per set bit: BigSmart2 + UnsignedShortSmartMinusOne + bitfield = read_u8(buf) + bit_count = 0 + tmp = bitfield + while tmp != 0: + bit_count += 1 + tmp >>= 1 + for i in range(bit_count): + if bitfield & (1 << i): + # BigSmart2: if first byte < 128, read u16; else read i32 & 0x7FFFFFFF + pos = buf.tell() + peek = buf.read(1) + if peek and peek[0] < 128: + buf.seek(pos) + read_u16(buf) + else: + buf.seek(pos) + read_i32(buf) + # UnsignedShortSmartMinusOne: same as big_smart but -1 + pos2 = buf.tell() + peek2 = buf.read(1) + if peek2 and peek2[0] < 128: + buf.seek(pos2) + read_u16(buf) + else: + buf.seek(pos2) + read_i32(buf) + elif opcode == 103: + read_u16(buf) # rotation + elif opcode == 106: + # morph: u16 varbit, u16 varp, u8 length, (length+1) u16 configs + read_u16(buf) # varbitId + read_u16(buf) # varpIndex + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) # configs + elif opcode == 107: + pass # isInteractable = false + elif opcode == 108: + pass # isPet (modern) + elif opcode == 109: + pass # isClickable = false + elif opcode == 111: + pass # isFollower + elif opcode == 114: + read_u16(buf) # runSequence + elif opcode == 115: + read_u16(buf) # runSequence + read_u16(buf) # runBackSequence + read_u16(buf) # runRightSequence + read_u16(buf) # runLeftSequence + elif opcode == 116: + read_u16(buf) # crawlSequence + elif opcode == 117: + read_u16(buf) # crawlBackSequence + read_u16(buf) # crawlRightSequence + read_u16(buf) # crawlLeftSequence + elif opcode == 118: + # morph2: u16 varbit, u16 varp, u16 default, u8 length, (length+1) u16 configs + read_u16(buf) # varbitId + read_u16(buf) # varpIndex + read_u16(buf) # default child (var) + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) # configs + elif opcode == 122: + pass # isFollower + elif opcode == 123: + pass # lowPriorityFollowerOps + elif opcode == 124: + read_u16(buf) # height + elif opcode == 125: + read_u8(buf) # unknown + elif opcode == 126: + read_u16(buf) # footprintSize + elif opcode == 128: + read_u8(buf) # unknown + elif opcode == 129: + pass # unknown flag + elif opcode == 130: + pass # idleAnimRestart + elif opcode == 145: + pass # canHideForOverlap + elif opcode == 146: + read_u16(buf) # overlapTintHSL + elif opcode == 147: + pass # zbuf = false + elif opcode == 249: + count_val = read_u8(buf) + for _ in range(count_val): + is_string = read_u8(buf) + read_u24(buf) # key (medium) + if is_string: + read_string(buf) + else: + read_u32(buf) + else: + print(f" warning: unknown npc opcode {opcode} at npc {npc_id}, pos {buf.tell()}", file=sys.stderr) + break + + return d + + +def parse_modern_spotanim(spotanim_id: int, data: bytes) -> SpotAnimDef: + """Parse modern SpotAnim/GFX definition from opcode stream. + + Opcode reference from RuneLite SpotAnimLoader: + 1: model ID (u16) + 2: sequence ID (u16) + 4: width scale (u16) + 5: height scale (u16) + 6: rotation (u16) + 7: ambient (u8) + 8: contrast (u8) + 40: recolor pairs (u8 count, u16+u16) + 41: retexture pairs (u8 count, u16+u16) + """ + d = SpotAnimDef(id=spotanim_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + d.model_id = read_u16(buf) + elif opcode == 2: + d.seq_id = read_u16(buf) + elif opcode == 4: + d.width_scale = read_u16(buf) + elif opcode == 5: + d.height_scale = read_u16(buf) + elif opcode == 6: + d.rotation = read_u16(buf) + elif opcode == 7: + d.ambient = read_u8(buf) + elif opcode == 8: + d.contrast = read_u8(buf) + elif opcode == 40: + count = read_u8(buf) + for _ in range(count): + d.recolor_src.append(read_u16(buf)) + d.recolor_dst.append(read_u16(buf)) + elif opcode == 41: + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + read_u16(buf) + else: + print(f" warning: unknown spotanim opcode {opcode} at gfx {spotanim_id}", file=sys.stderr) + break + + return d + + +def apply_recolors(md: ModelData, src: list[int], dst: list[int]) -> None: + """Apply recolor pairs to model face colors in-place.""" + for i, color in enumerate(md.face_colors): + for s, d in zip(src, dst): + if color == s: + md.face_colors[i] = d + break + + +def apply_scale(md: ModelData, width_scale: int, height_scale: int) -> None: + """Apply NPC width/height scale to vertex positions in-place.""" + if width_scale == 128 and height_scale == 128: + return + ws = width_scale / 128.0 + hs = height_scale / 128.0 + for i in range(md.vertex_count): + md.vertices_x[i] = int(md.vertices_x[i] * ws) + md.vertices_y[i] = int(md.vertices_y[i] * hs) + md.vertices_z[i] = int(md.vertices_z[i] * ws) + + +def main() -> None: + """Export inferno NPC data from modern OSRS cache.""" + parser = argparse.ArgumentParser(description="export inferno NPC models + animations from modern cache") + parser.add_argument( + "--modern-cache", type=Path, required=True, + help="path to modern OpenRS2 flat-file cache", + ) + parser.add_argument( + "--output-dir", type=Path, default=Path("data"), + help="output directory for generated files", + ) + args = parser.parse_args() + + reader = ModernCacheReader(args.modern_cache) + output_dir = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + + # ================================================================ + # step 1: read NPC definitions from config index 2, group 9 + # ================================================================ + print("reading NPC definitions from modern cache (index 2, group 9)...") + npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) + print(f" {len(npc_files)} total NPC entries in group 9") + + npc_defs: dict[int, NpcDef] = {} + all_model_ids: set[int] = set() + all_anim_ids: set[int] = set() + + for npc_id, label in sorted(INFERNO_NPC_IDS.items()): + if npc_id not in npc_files: + print(f" NPC {npc_id} ({label}): NOT FOUND in cache") + continue + + npc = parse_modern_npc_def(npc_id, npc_files[npc_id]) + npc_defs[npc_id] = npc + + print(f"\n NPC {npc_id} ({label}):") + print(f" name: {npc.name}") + print(f" models: {npc.model_ids}") + print(f" size: {npc.size}") + print(f" idle_anim: {npc.idle_anim}") + print(f" walk_anim: {npc.walk_anim}") + print(f" scale: {npc.width_scale}x{npc.height_scale}") + if npc.recolor_src: + print(f" recolors: {list(zip(npc.recolor_src, npc.recolor_dst))}") + if npc.retexture_src: + print(f" retextures: {list(zip(npc.retexture_src, npc.retexture_dst))}") + + all_model_ids.update(npc.model_ids) + for anim_id in [npc.idle_anim, npc.walk_anim, npc.turn_180_anim, npc.turn_cw_anim, npc.turn_ccw_anim]: + if anim_id >= 0: + all_anim_ids.add(anim_id) + # 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) + + # ================================================================ + # step 2: read SpotAnim/GFX definitions + # ================================================================ + print("\n\nreading SpotAnim/GFX definitions (index 2, group 13)...") + spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) + print(f" {len(spotanim_files)} total spotanim entries") + + spotanim_defs: dict[int, SpotAnimDef] = {} + for gfx_id, label in sorted(INFERNO_SPOTANIM_IDS.items()): + if gfx_id not in spotanim_files: + print(f" GFX {gfx_id} ({label}): NOT FOUND in cache") + continue + + sa = parse_modern_spotanim(gfx_id, spotanim_files[gfx_id]) + spotanim_defs[gfx_id] = sa + + print(f" GFX {gfx_id} ({label}): model={sa.model_id}, seq={sa.seq_id}, " + f"scale={sa.width_scale}x{sa.height_scale}") + + if sa.model_id >= 0: + all_model_ids.add(sa.model_id) + if sa.seq_id >= 0: + all_anim_ids.add(sa.seq_id) + + print(f"\ntotal unique model IDs to export: {len(all_model_ids)}") + print(f" {sorted(all_model_ids)}") + print(f"total unique animation IDs to export: {len(all_anim_ids)}") + print(f" {sorted(all_anim_ids)}") + + # ================================================================ + # step 3: export NPC models + # ================================================================ + print("\n\nexporting NPC + GFX models...") + all_models: list[ModelData] = [] + + # for each NPC, merge sub-models, apply recolors/scale + for npc_id, npc in sorted(npc_defs.items()): + sub_models: list[ModelData] = [] + for mid in npc.model_ids: + raw = load_model_modern(reader, mid) + if raw is None: + print(f" warning: model {mid} not found for NPC {npc_id}") + continue + md = decode_model(mid, raw) + if md is None: + print(f" warning: failed to decode model {mid} for NPC {npc_id}") + continue + sub_models.append(md) + + if not sub_models: + print(f" NPC {npc_id}: no models decoded") + continue + + if len(sub_models) == 1: + merged = sub_models[0] + else: + merged = _merge_models(sub_models) + + # apply recolors + if npc.recolor_src: + apply_recolors(merged, npc.recolor_src, npc.recolor_dst) + + # apply scale + apply_scale(merged, npc.width_scale, npc.height_scale) + + # use NPC ID as model ID for lookup (synthetic: 0xC0000 + npc_id) + merged.model_id = 0xC0000 + npc_id + all_models.append(merged) + print(f" NPC {npc_id} ({npc.name}): {merged.vertex_count} verts, {merged.face_count} faces") + + # export GFX projectile models, applying spotanim recolors where needed. + # recolored models get synthetic IDs (0xD0000 | gfx_id) so the cache binary + # can hold both the raw and recolored variants of the same base model. + exported_gfx_models: set[int] = set() + for gfx_id, sa in sorted(spotanim_defs.items()): + if sa.model_id < 0: + continue + raw = load_model_modern(reader, sa.model_id) + if raw is None: + print(f" warning: GFX {gfx_id} model {sa.model_id} not found") + continue + md = decode_model(sa.model_id, raw) + if md is None: + print(f" warning: failed to decode GFX {gfx_id} model {sa.model_id}") + continue + if sa.recolor_src: + apply_recolors(md, sa.recolor_src, sa.recolor_dst) + md.model_id = 0xD0000 | gfx_id + print(f" GFX {gfx_id} model {sa.model_id} -> 0x{md.model_id:X} (recolored): {md.vertex_count} verts") + else: + if sa.model_id in exported_gfx_models: + continue # already exported this raw model + print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") + exported_gfx_models.add(md.model_id) + all_models.append(md) + + # write models binary + models_path = output_dir / "inferno_npcs.models" + write_models_binary(models_path, all_models) + file_size = models_path.stat().st_size + print(f"\nwrote {len(all_models)} models ({file_size:,} bytes) to {models_path}") + + # ================================================================ + # step 4: export animations + # ================================================================ + print("\n\nexporting animations...") + seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) + + sequences: dict[int, SequenceDef] = {} + for seq_id in sorted(all_anim_ids): + if seq_id not in seq_files: + print(f" warning: sequence {seq_id} not found in cache") + continue + modern_seq = parse_modern_sequence(seq_id, seq_files[seq_id]) + seq = SequenceDef( + seq_id=modern_seq.seq_id, + frame_count=modern_seq.frame_count, + frame_delays=modern_seq.frame_delays, + primary_frame_ids=modern_seq.primary_frame_ids, + frame_step=modern_seq.frame_step, + interleave_order=modern_seq.interleave_order, + priority=modern_seq.forced_priority, + loop_count=modern_seq.max_loops, + walk_flag=modern_seq.priority, + run_flag=modern_seq.precedence_animating, + ) + sequences[seq_id] = seq + print(f" seq {seq_id}: {seq.frame_count} frames, delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}") + + # collect needed frame groups + needed_groups: set[int] = set() + for seq_id in all_anim_ids & set(sequences.keys()): + seq = sequences[seq_id] + for fid in seq.primary_frame_ids: + if fid != -1: + needed_groups.add(fid >> 16) + + print(f" loading {len(needed_groups)} frame archives...") + + # first pass: discover framebase IDs from frame data headers + needed_base_ids: set[int] = set() + raw_frame_data: dict[int, dict[int, bytes]] = {} + for group_id in sorted(needed_groups): + try: + files = reader.read_group(MODERN_FRAME_INDEX, group_id) + except (KeyError, FileNotFoundError): + print(f" warning: frame archive {group_id} not found") + continue + raw_frame_data[group_id] = files + for file_data in files.values(): + if len(file_data) >= 2: + fb_id = (file_data[0] << 8) | file_data[1] + needed_base_ids.add(fb_id) + + print(f" loading {len(needed_base_ids)} framebases...") + framebases = load_modern_framebases(reader, needed_base_ids) + print(f" loaded {len(framebases)} framebases") + + # second pass: parse frames + all_frames: dict[int, dict[int, FrameDef]] = {} + for group_id, files in raw_frame_data.items(): + frames: dict[int, FrameDef] = {} + for file_id, file_data in files.items(): + if len(file_data) < 3: + continue + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + if frames: + all_frames[group_id] = frames + + total_frames = sum(len(v) for v in all_frames.values()) + print(f" {len(all_frames)} frame archives, {total_frames} total frames") + + # write animations binary + anims_path = output_dir / "inferno_npcs.anims" + available_seqs = all_anim_ids & set(sequences.keys()) + write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) + + # ================================================================ + # step 5: update npc_models.h + # ================================================================ + print("\n\nupdating npc_models.h...") + header_path = output_dir / "npc_models.h" + + # build NPC model mapping entries + npc_entries = [] + for npc_id, npc in sorted(npc_defs.items()): + synth_model = 0xC0000 + npc_id + idle = npc.idle_anim if npc.idle_anim >= 0 else 0xFFFF + attack = INFERNO_ATTACK_ANIMS.get(npc_id, 0xFFFF) + walk = npc.walk_anim if npc.walk_anim >= 0 else 0xFFFF + label = INFERNO_NPC_IDS.get(npc_id, npc.name) + npc_entries.append((npc_id, synth_model, idle, attack, walk, label)) + + # build spotanim entries for C header. + # recolored spotanims get synthetic model IDs (0xD0000 | gfx_id) so the + # recolored variant is distinct from the raw model in the binary cache. + spotanim_entries = [] + for gfx_id, sa in sorted(spotanim_defs.items()): + if sa.model_id >= 0: + label = INFERNO_SPOTANIM_IDS.get(gfx_id, "unknown") + if sa.recolor_src: + emit_model_id = 0xD0000 | gfx_id + else: + emit_model_id = sa.model_id + spotanim_entries.append((gfx_id, emit_model_id, sa.seq_id, label)) + + # write C header + with open(header_path, "w") as f: + f.write("/**\n") + f.write(" * @fileoverview NPC model/animation mappings for encounter rendering.\n") + f.write(" *\n") + f.write(" * Maps NPC definition IDs to cache model IDs and animation sequence IDs.\n") + f.write(" * Generated by scripts/export_inferno_npcs.py — do not edit.\n") + f.write(" */\n\n") + f.write("#ifndef NPC_MODELS_H\n") + f.write("#define NPC_MODELS_H\n\n") + f.write("#include \n\n") + + f.write("typedef struct {\n") + f.write(" uint16_t npc_id;\n") + f.write(" uint32_t model_id;\n") + f.write(" uint32_t idle_anim;\n") + f.write(" uint32_t attack_anim;\n") + f.write(" uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */\n") + f.write("} NpcModelMapping;\n\n") + + # zulrah entries + f.write("/* zulrah forms + snakeling */\n") + f.write("static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = {\n") + f.write(" {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */\n") + f.write(" {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */\n") + f.write(" {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */\n") + f.write("};\n\n") + + # snakeling defines (keep existing) + f.write("/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */\n") + f.write("#define SNAKELING_MODEL_ID 10415\n") + f.write("#define SNAKELING_ANIM_IDLE 1721\n") + f.write("#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */\n") + f.write("#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */\n") + f.write("#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */\n") + f.write("#define SNAKELING_ANIM_WALK 2405 /* walk cycle */\n\n") + + # zulrah spotanim defines (keep existing) + f.write("/* zulrah spotanim (projectile/cloud) model IDs */\n") + f.write("#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */\n") + f.write("#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */\n") + f.write("#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */\n") + f.write("#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */\n") + f.write("#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */\n\n") + + # zulrah animation defines (keep existing) + f.write("/* zulrah animation sequence IDs */\n") + f.write("#define ZULRAH_ANIM_ATTACK 5068\n") + f.write("#define ZULRAH_ANIM_IDLE 5069\n") + f.write("#define ZULRAH_ANIM_DIVE 5072\n") + f.write("#define ZULRAH_ANIM_SURFACE 5071\n") + f.write("#define ZULRAH_ANIM_RISE 5073\n") + f.write("#define ZULRAH_ANIM_5070 5070\n") + f.write("#define ZULRAH_ANIM_5806 5806\n") + f.write("#define ZULRAH_ANIM_5807 5807\n") + f.write("#define GFX_SNAKELING_SPAWN_ANIM 5358\n\n") + + # inferno NPC model mappings + f.write("/* ================================================================ */\n") + f.write("/* inferno NPC model/animation mappings */\n") + f.write("/* ================================================================ */\n\n") + + f.write("static const NpcModelMapping NPC_MODEL_MAP_INFERNO[] = {\n") + for npc_id, synth_model, idle, attack, walk, label in npc_entries: + f.write(f" {{{npc_id}, 0x{synth_model:X}, {idle}, {attack}, {walk}}}, /* {label} */\n") + f.write("};\n\n") + + # inferno NPC defines for walk anims and other useful data + f.write("/* inferno NPC walk animation IDs */\n") + for npc_id, npc in sorted(npc_defs.items()): + safe_name = INFERNO_NPC_IDS[npc_id].split("(")[1].rstrip(")") if "(" in INFERNO_NPC_IDS[npc_id] else INFERNO_NPC_IDS[npc_id] + safe_name = safe_name.replace(" ", "_").replace("-", "_").upper() + if npc.walk_anim >= 0: + f.write(f"#define INF_WALK_ANIM_{safe_name} {npc.walk_anim}\n") + f.write("\n") + + # inferno spotanim/GFX defines + f.write("/* inferno spotanim (projectile/effect) model + animation IDs */\n") + for gfx_id, model_id, seq_id, label in spotanim_entries: + safe_label = label.replace(" ", "_").replace("(", "").replace(")", "").replace("?", "").upper() + f.write(f"#define INF_GFX_{gfx_id}_MODEL {model_id} /* {label} */\n") + if seq_id >= 0: + f.write(f"#define INF_GFX_{gfx_id}_ANIM {seq_id}\n") + f.write("\n") + + # inferno pillar models — "Rocky support" objects 30284-30287, 4 HP levels + f.write("/* inferno pillar models — Rocky support objects 30284-30287 */\n") + f.write("#define INF_PILLAR_MODEL_100 33044 /* object 30284 — full health */\n") + f.write("#define INF_PILLAR_MODEL_75 33043 /* object 30285 — 75% HP */\n") + f.write("#define INF_PILLAR_MODEL_50 33042 /* object 30286 — 50% HP */\n") + f.write("#define INF_PILLAR_MODEL_25 33045 /* object 30287 — 25% HP */\n\n") + + # combined lookup function that searches both zulrah and inferno tables + f.write("static const NpcModelMapping* npc_model_lookup(uint16_t npc_id) {\n") + f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_ZULRAH) / sizeof(NPC_MODEL_MAP_ZULRAH[0])); i++) {\n") + f.write(" if (NPC_MODEL_MAP_ZULRAH[i].npc_id == npc_id) return &NPC_MODEL_MAP_ZULRAH[i];\n") + f.write(" }\n") + f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_INFERNO) / sizeof(NPC_MODEL_MAP_INFERNO[0])); i++) {\n") + f.write(" if (NPC_MODEL_MAP_INFERNO[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO[i];\n") + f.write(" }\n") + f.write(" return NULL;\n") + f.write("}\n\n") + + f.write("#endif /* NPC_MODELS_H */\n") + + print(f"wrote {header_path}") + + # ================================================================ + # step 6: print encounter_inferno.h mapping table + # ================================================================ + print("\n\n========================================") + print("INF_NPC_DEF_IDS mapping table for encounter_inferno.h:") + print("========================================") + print("static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = {") + + inf_type_to_npc = { + "INF_NPC_NIBBLER": 7691, + "INF_NPC_BAT": 7692, + "INF_NPC_BLOB": 7693, + "INF_NPC_BLOB_MELEE": 7694, + "INF_NPC_BLOB_RANGE": 7695, + "INF_NPC_BLOB_MAGE": 7696, + "INF_NPC_MELEER": 7697, + "INF_NPC_RANGER": 7698, + "INF_NPC_MAGER": 7699, + "INF_NPC_JAD": 7700, + "INF_NPC_ZUK": 7706, + "INF_NPC_HEALER_JAD": 7701, + "INF_NPC_HEALER_ZUK": 7708, + "INF_NPC_ZUK_SHIELD": 7707, + } + for enum_name, npc_id in inf_type_to_npc.items(): + npc = npc_defs.get(npc_id) + name = npc.name if npc else "UNKNOWN" + print(f" [{enum_name}] = {npc_id}, /* {name} */") + print("};") + + print("\ndone.") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py new file mode 100644 index 0000000000..4e2669f549 --- /dev/null +++ b/ocean/osrs/scripts/export_models.py @@ -0,0 +1,1850 @@ +"""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 ../reference/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 + +_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 Exception: + continue + + kit = IdentityKitDef(kit_id=kit_id) + buf = io.BytesIO(data) + + while True: + opcode = buf.read(1) + if not opcode: + break + op = opcode[0] + if op == 0: + break + elif op == 1: + kit.body_part_id = buf.read(1)[0] + elif op == 2: + n = buf.read(1)[0] + kit.body_models = [ + struct.unpack(">H", buf.read(2))[0] for _ in range(n) + ] + elif op == 3: + kit.valid_style = True + elif 40 <= op < 50: + kit.original_colors[op - 40] = struct.unpack(">H", buf.read(2))[0] + elif 50 <= op < 60: + kit.replacement_colors[op - 50] = struct.unpack(">H", buf.read(2))[0] + elif 60 <= op < 70: + buf.read(2) # head model (not needed for body rendering) + + kits[kit_id] = kit + + return kits + + +def _parse_modern_item_entry(item_id: int, data: bytes) -> ItemDef: + """Parse a single modern item definition from opcode stream. + + Modern OSRS cache (rev226+) has many additional opcodes beyond the 317 set. + We handle all known opcodes from RuneLite's ItemLoader plus modern additions. + Unknown opcodes cause a break since we can't determine their byte length. + + Opcode reference (modern OSRS, compiled from RuneLite + rev226 analysis): + 0: terminator + 1: inv_model (u16) + 2: name (string) + 4-6: zoom/rotation (u16 each) + 7-8: model offsets (u16 each) + 9: unknown string (removed in modern, but some revs have it) + 10: unknown u16 + 11: stackable (flag) + 12: value (i32) + 13-14: wearPos1/2 (u8 each) + 15: isTradeable (flag, modern addition) + 16: membersObject (flag) + 17: unknown u8 (modern) + 18: unknown u8 (modern) + 19: unknown u8 (modern, very common) + 20: unknown u8 (modern) + 21: groundScaleX (u16, modern) + 22: groundScaleY (u16, modern — very common placeholder item flag?) + 23: maleWield + offset (u16 + i8) + 24: maleWield2 (u16) + 25: femaleWield + offset (u16 + i8) + 26: femaleWield2 (u16) + 27: wearPos3 (u8) + 28: unknown u8 + 29: unknown u8 (very common, modern) + 30-34: ground options (strings) + 35-39: interface options (strings) + 40: recolor pairs (u8 count, then u16+u16 per pair) + 41: retexture pairs (u8 count, then u16+u16 per pair) + 42: shiftClickDropIndex (u8) + 43: sub-operations (u8 count, then u16+u16 per entry — modern) + 62: unknown u8 (modern) + 64: unknown flag (modern) + 65: isTradeable (flag) + 69: unknown u8 (modern) + 71: unknown u16 (modern) + 75: weight (u16) + 78-79: maleModel2/femaleModel2 (u16 each) + 90-93: head models (u16 each) + 94: category (u16) + 95: zan2d (u16) + 97-98: cert references (u16 each) + 100-109: count objects (u16+u16 each) + 110-112: resize (u16 each) + 113-114: ambient/contrast (u8 each) + 115: team (u8) + 139-140: unnoted/noted references (u16 each) + 148-149: placeholder references (u16 each) + 155: unknown u8 (modern) + 156: unknown u16 (modern) + 157: unknown u8 (modern) + 158: unknown u8 (modern) + 159: unknown u8 (modern) + 160: unknown u16 (modern, u8 count then u16s — like wearPos list) + 161: unknown u16 (modern) + 162: unknown u8 (modern) + 163: unknown u8 (modern) + 164: unknown string (modern) + 165: unknown u8 (modern) + 202: unknown u16 (modern, seen on some items) + 211: unknown u8 count then u16s (modern) + 249: params map (u8 count, then key-value pairs) + """ + d = ItemDef(item_id=item_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + d.inv_model = struct.unpack(">H", buf.read(2))[0] + elif opcode == 2: + d.name = _read_string(buf) + elif opcode == 3: + _read_string(buf) # description + elif opcode == 4: + buf.read(2) # modelZoom + elif opcode == 5: + buf.read(2) # modelRotationY + elif opcode == 6: + buf.read(2) # modelRotationX + elif opcode == 7: + buf.read(2) # modelOffset1 + elif opcode == 8: + buf.read(2) # modelOffset2 + elif opcode == 9: + _read_string(buf) # unknown string + elif opcode == 10: + buf.read(2) # unknown u16 + elif opcode == 11: + pass # stackable + elif opcode == 12: + buf.read(4) # value + elif opcode == 13: + buf.read(1) # wearPos1 + elif opcode == 14: + buf.read(1) # wearPos2 + elif opcode == 15: + pass # isTradeable (modern flag, 0 bytes) + elif opcode == 16: + pass # membersObject + elif opcode == 17: + buf.read(1) # unknown u8 + elif opcode == 18: + buf.read(1) # unknown u8 + elif opcode == 19: + buf.read(1) # unknown u8 (very common in modern) + elif opcode == 20: + buf.read(1) # unknown u8 + elif opcode == 21: + buf.read(2) # groundScaleX + elif opcode == 22: + buf.read(2) # groundScaleY + elif opcode == 23: + d.male_wield = struct.unpack(">H", buf.read(2))[0] + d.male_offset = struct.unpack(">b", buf.read(1))[0] + elif opcode == 24: + d.male_wield2 = struct.unpack(">H", buf.read(2))[0] + elif opcode == 25: + buf.read(2) # femaleWield + buf.read(1) # femaleOffset + elif opcode == 26: + buf.read(2) # femaleWield2 + elif opcode == 27: + buf.read(1) # wearPos3 + elif opcode == 28: + buf.read(1) # unknown u8 + elif opcode == 29: + buf.read(1) # unknown u8 (very common in modern) + elif 30 <= opcode < 35: + _read_string(buf) # groundActions + elif 35 <= opcode < 40: + _read_string(buf) # itemActions + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + src = struct.unpack(">H", buf.read(2))[0] + dst = struct.unpack(">H", buf.read(2))[0] + d.recolor_src.append(src) + d.recolor_dst.append(dst) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # retextureFrom + retextureTo + elif opcode == 42: + buf.read(1) # shiftClickDropIndex + elif opcode == 43: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # sub-operation pairs (u16+u16) + elif opcode == 62: + buf.read(1) # unknown u8 + elif opcode == 64: + pass # unknown flag (0 bytes) + elif opcode == 65: + pass # isTradeable + elif opcode == 66: + buf.read(2) # unknown u16 + elif opcode == 67: + buf.read(2) # unknown u16 + elif opcode == 68: + buf.read(2) # unknown u16 + elif opcode == 69: + buf.read(1) # unknown u8 + elif opcode == 71: + buf.read(2) # unknown u16 + elif opcode == 73: + buf.read(2) # unknown u16 (modern) + elif opcode == 74: + buf.read(2) # unknown u16 (modern) + elif opcode == 75: + buf.read(2) # weight + elif opcode == 76: + buf.read(2) # unknown u16 (modern) + elif opcode == 77: + buf.read(2) # unknown u16 (modern) + elif opcode == 78: + buf.read(2) # maleModel2 + elif opcode == 79: + buf.read(2) # femaleModel2 + elif opcode == 80: + buf.read(2) # unknown u16 (modern) + elif opcode == 81: + buf.read(2) # unknown u16 (modern) + elif opcode == 82: + buf.read(2) # unknown u16 (modern) + elif opcode == 83: + buf.read(2) # unknown u16 (modern) + elif opcode == 84: + buf.read(2) # unknown u16 (modern) + elif opcode == 85: + buf.read(2) # unknown u16 (modern) + elif opcode == 86: + buf.read(2) # unknown u16 (modern) + elif opcode == 87: + buf.read(2) # unknown u16 (modern) + elif opcode == 90: + buf.read(2) # maleHeadModel + elif opcode == 91: + buf.read(2) # femaleHeadModel + elif opcode == 92: + buf.read(2) # maleHeadModel2 + elif opcode == 93: + buf.read(2) # femaleHeadModel2 + elif opcode == 94: + buf.read(2) # category + elif opcode == 95: + buf.read(2) # zan2d + elif opcode == 97: + buf.read(2) # certID + elif opcode == 98: + buf.read(2) # certTemplateID + elif 100 <= opcode < 110: + buf.read(4) # stackIDs + stackAmounts + elif opcode == 110: + buf.read(2) # resizeX + elif opcode == 111: + buf.read(2) # resizeY + elif opcode == 112: + buf.read(2) # resizeZ + elif opcode == 113: + buf.read(1) # brightness + elif opcode == 114: + buf.read(1) # contrast + elif opcode == 115: + buf.read(1) # team + elif opcode == 116: + buf.read(2) # unknown u16 (modern) + elif opcode == 117: + buf.read(2) # unknown u16 (modern) + elif opcode == 118: + buf.read(2) # unknown u16 (modern) + elif opcode == 119: + buf.read(1) # unknown u8 (modern) + elif opcode == 120: + buf.read(1) # unknown u8 (modern) + elif opcode == 121: + buf.read(1) # unknown u8 (modern) + elif opcode == 122: + buf.read(1) # unknown u8 (modern) + elif opcode == 139: + buf.read(2) # unnotedId + elif opcode == 140: + buf.read(2) # notedId + elif opcode == 148: + buf.read(2) # placeholderId + elif opcode == 149: + buf.read(2) # placeholderTemplateId + elif opcode == 155: + buf.read(1) # unknown u8 + elif opcode == 156: + buf.read(2) # unknown u16 + elif opcode == 157: + buf.read(1) # unknown u8 + elif opcode == 158: + buf.read(1) # unknown u8 + elif opcode == 159: + buf.read(1) # unknown u8 + elif opcode == 160: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # u16 per entry + elif opcode == 161: + buf.read(2) # unknown u16 + elif opcode == 162: + buf.read(1) # unknown u8 + elif opcode == 163: + buf.read(1) # unknown u8 + elif opcode == 164: + _read_string(buf) # unknown string + elif opcode == 165: + buf.read(1) # unknown u8 + elif opcode == 202: + buf.read(2) # unknown u16 + elif opcode == 211: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # u16 per entry + elif opcode == 249: + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] + buf.read(3) # key (medium) + if is_string: + _read_string(buf) + else: + buf.read(4) # int value + else: + print( + f" warning: unknown modern item opcode {opcode} at item {item_id}, " + f"pos {buf.tell()}", + file=sys.stderr, + ) + break + + return d + + +def decode_item_definitions_modern(reader: ModernCacheReader) -> dict[int, ItemDef]: + """Decode item definitions from modern cache (config index 2, group 6). + + Only parses items we actually need (SIM_ITEM_IDS) to avoid issues with + unknown opcodes in items we don't care about. + """ + files = reader.read_group(2, MODERN_CONFIG_OBJ_GROUP) + defs: dict[int, ItemDef] = {} + + for item_id in SIM_ITEM_IDS: + if item_id not in files: + print(f" warning: item {item_id} not in modern cache config group {MODERN_CONFIG_OBJ_GROUP}") + continue + d = _parse_modern_item_entry(item_id, files[item_id]) + if d.inv_model >= 0 or d.name: + defs[item_id] = d + + return defs + + +def load_model_modern(reader: ModernCacheReader, model_id: int) -> bytes | None: + """Load raw model bytes from modern cache (index 7, container-compressed).""" + raw = reader._read_raw(MODERN_MODEL_INDEX, model_id) + if raw is None: + return None + return decompress_container(raw) + + +# --- model geometry decoder --- + + +def read_smart_signed(buf: io.BytesIO) -> int: + """Read a signed smart (same as Java Buffer.readSmart in tarnish). + + Single byte: value - 64 (range -64 to 63) + Two bytes: value - 49152 (range -16384 to 16383) + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + val = peek[0] + if val < 128: + return val - 64 + buf.seek(pos) + raw = struct.unpack(">H", buf.read(2))[0] + return raw - 49152 + + +@dataclass +class ModelData: + """Decoded model geometry.""" + + model_id: int = 0 + vertex_count: int = 0 + face_count: int = 0 + vertices_x: list[int] = field(default_factory=list) + vertices_y: list[int] = field(default_factory=list) + vertices_z: list[int] = field(default_factory=list) + face_a: list[int] = field(default_factory=list) + face_b: list[int] = field(default_factory=list) + face_c: list[int] = field(default_factory=list) + face_colors: list[int] = field(default_factory=list) # 15-bit HSL per face + face_textures: list[int] = field(default_factory=list) # texture ID per face (-1 = none) + vertex_skins: list[int] = field(default_factory=list) # label group per vertex (for animation) + face_priorities: list[int] = field(default_factory=list) # per-face render priority (0-11) + face_alphas: list[int] = field(default_factory=list) # per-face alpha (0=opaque) + face_tex_coords: list[int] = field(default_factory=list) + tex_u: list[int] = field(default_factory=list) + tex_v: list[int] = field(default_factory=list) + tex_w: list[int] = field(default_factory=list) + tex_face_count: int = 0 + + +def _read_ubyte(data: bytes, offset: int) -> int: + return data[offset] + + +def _read_ushort(data: bytes, offset: int) -> int: + return (data[offset] << 8) | data[offset + 1] + + +def decode_model(model_id: int, data: bytes) -> ModelData | None: + """Decode a model from raw cache data. Handles all 3 format variants.""" + if len(data) < 18: + return None + + # detect format from last 2 bytes (Java signed: -1=0xFF, -2=0xFE, -3=0xFD) + last2 = data[-2] + last1 = data[-1] + + if last2 == 0xFF and last1 == 0xFD: + return _decode_type3(model_id, data) + if last2 == 0xFF and last1 == 0xFE: + return _decode_type2(model_id, data) + if last2 == 0xFF and last1 == 0xFF: + return _decode_type1(model_id, data) + return _decode_old_format(model_id, data) + + +def _decode_vertices( + data: bytes, + flags_offset: int, + x_offset: int, + y_offset: int, + z_offset: int, + count: int, +) -> tuple[list[int], list[int], list[int]]: + """Decode vertex positions from delta-encoded streams.""" + vx, vy, vz = [], [], [] + fbuf = io.BytesIO(data) + fbuf.seek(flags_offset) + xbuf = io.BytesIO(data) + xbuf.seek(x_offset) + ybuf = io.BytesIO(data) + ybuf.seek(y_offset) + zbuf = io.BytesIO(data) + zbuf.seek(z_offset) + + cx, cy, cz = 0, 0, 0 + for _ in range(count): + flags = fbuf.read(1)[0] + dx = read_smart_signed(xbuf) if (flags & 1) else 0 + dy = read_smart_signed(ybuf) if (flags & 2) else 0 + dz = read_smart_signed(zbuf) if (flags & 4) else 0 + cx += dx + cy += dy + cz += dz + vx.append(cx) + vy.append(cy) + vz.append(cz) + + return vx, vy, vz + + +def _decode_faces( + data: bytes, + index_offset: int, + type_offset: int, + count: int, +) -> tuple[list[int], list[int], list[int]]: + """Decode triangle indices using strip encoding (4 face types).""" + fa, fb, fc = [], [], [] + ibuf = io.BytesIO(data) + ibuf.seek(index_offset) + tbuf = io.BytesIO(data) + tbuf.seek(type_offset) + + a, b, c, last = 0, 0, 0, 0 + for _ in range(count): + ftype = tbuf.read(1)[0] + if ftype == 1: + a = read_smart_signed(ibuf) + last + b = read_smart_signed(ibuf) + a + c = read_smart_signed(ibuf) + b + last = c + elif ftype == 2: + b = c + c = read_smart_signed(ibuf) + last + last = c + elif ftype == 3: + a = c + c = read_smart_signed(ibuf) + last + last = c + elif ftype == 4: + tmp = a + a = b + b = tmp + c = read_smart_signed(ibuf) + last + last = c + else: + # type 0 or unknown: skip + fa.append(0) + fb.append(0) + fc.append(0) + continue + fa.append(a) + fb.append(b) + fc.append(c) + + return fa, fb, fc + + +def _decode_vertex_skins( + data: bytes, offset: int, count: int, has_skins: int, +) -> list[int]: + """Read per-vertex skin labels (label group assignments for animation). + + If has_skins == 1, reads one byte per vertex at offset. + Otherwise all vertices get label 0 (single group). + """ + if has_skins == 1: + return [_read_ubyte(data, offset + i) for i in range(count)] + return [0] * count + + +def _decode_face_priorities( + data: bytes, offset: int, count: int, model_priority: int, +) -> list[int]: + """Read per-face render priorities. + + If model_priority == 255, priorities are stored per-face as bytes at offset. + Otherwise all faces share the model-level priority value. + """ + if model_priority == 255: + return [_read_ubyte(data, offset + i) for i in range(count)] + return [model_priority] * count + + +def _decode_face_colors(data: bytes, offset: int, count: int) -> list[int]: + """Read face colors as unsigned shorts.""" + colors = [] + for i in range(count): + colors.append(_read_ushort(data, offset + i * 2)) + return colors + + +def _decode_face_textures_from_stream( + data: bytes, offset: int, count: int, +) -> list[int]: + """Read face texture IDs as signed shorts (readUShort() - 1 in Java). + + Mirrors type1/type3 decoder: faceTextures[i] = readUShort() - 1. + Result: -1 means no texture, >= 0 is a valid texture ID. + """ + textures = [] + for i in range(count): + val = _read_ushort(data, offset + i * 2) - 1 + # unsigned 0 → -1 (no texture), unsigned N → N-1 (texture ID) + textures.append(val) + return textures + + +def _apply_render_type_textures( + data: bytes, + render_type_offset: int, + face_colors: list[int], + face_count: int, +) -> list[int]: + """Handle faceRenderType & 2 texture assignment (type2/oldFormat path). + + Mirrors Java: when renderType & 2, faceTextures[i] = faceColors[i], + faceColors[i] = 127. Returns the face_textures array. + """ + face_textures = [-1] * face_count + for i in range(face_count): + render_type = _read_ubyte(data, render_type_offset + i) + if render_type & 2: + face_textures[i] = face_colors[i] + face_colors[i] = 127 + return face_textures + + +def _decode_type2(model_id: int, data: bytes) -> ModelData | None: + """23-byte footer without textureRenderTypes (type2, magic 0xFF 0xFE). + + Mirrors Java decodeType2. Vertex flags start at offset 0 (no tex render types). + """ + n = len(data) + if n < 23: + return None + + off = n - 23 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has vertex skins + var17 = _read_ubyte(data, off + 10) # has animaya + var18 = _read_ushort(data, off + 11) # vertex X len + var19 = _read_ushort(data, off + 13) # vertex Y len + var20 = _read_ushort(data, off + 15) # vertex Z len + var21 = _read_ushort(data, off + 17) # face index len + # off+19..20 unused? Actually var22 = readUShort at off+19 + # Wait — the footer is 23 bytes. 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. Plus magic 2 = 21. + # Actually decodeType2 footer reads: 2+2+1+1+1+1+1+1+1+2+2+2+2+2 = 21 data + 2 magic = 23 + # Let me re-check: var9(2)+var10(2)+var11(1)+var12(1)+var13(1)+var14(1)+var15(1)+var16(1)+var17(1) + # +var18(2)+var19(2)+var20(2)+var21(2)+magic(2) = 4+7+8+2 = 21. Hmm that's 21 not 23. + # Actually: 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. So there must be one more ushort. + # Looking at Java: var22 = var4.readUShort() at the end of the footer. That's face tex len. + var22 = _read_ushort(data, off + 19) # tex len (= face_tex_len not used by us, but 0xFF-2) + # Actually off + 19 + 2 = off + 21, then magic at off+21..22. Total = 23. Correct. + + # section offsets (mirrors Java decodeType2) + # type2: vertex flags start at offset 0 (var23=0 in Java) + var23 = 0 + var24 = var23 + var9 # end of vertex flags + var25 = var24 # face strip type offset + var24 += var10 + + var26 = var24 # face priority offset + if var13 == 255: + var24 += var10 + + var27 = var24 # face skin offset + if var15 == 1: + var24 += var10 + + var28 = var24 # face render type offset + if var12 == 1: + var24 += var10 + + var29 = var24 # vertex skin offset + var24 += var22 # tex len + + var30 = var24 # face transparency offset + if var14 == 1: + var24 += var10 + + var31 = var24 # face index data offset + var24 += var21 + + var32 = var24 # face color offset + var24 += var10 * 2 + + var33 = var24 # texture coords offset + var24 += var11 * 6 + + var34 = var24 # vertex X offset + var24 += var18 + var35 = var24 # vertex Y offset + var24 += var19 + # vertex Z starts at var24 + + vx, vy, vz = _decode_vertices(data, var23, var34, var35, var24, var9) + fa, fb, fc = _decode_faces(data, var31, var25, var10) + colors = _decode_face_colors(data, var32, var10) + + # handle faceRenderType & 2 → face is textured (type2/oldFormat path) + face_textures: list[int] = [] + if var12 == 1: + face_textures = _apply_render_type_textures(data, var28, colors, var10) + + priorities = _decode_face_priorities(data, var26, var10, var13) + skins = _decode_vertex_skins(data, var29, var9, var16) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: + """18-byte footer format (original 317). Mirrors Java decodeOldFormat exactly.""" + n = len(data) + if n < 18: + return None + + off = n - 18 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has vertex skins + var17 = _read_ushort(data, off + 10) # vertex X len + var18 = _read_ushort(data, off + 12) # vertex Y len + var19 = _read_ushort(data, off + 14) # vertex Z len + var20 = _read_ushort(data, off + 16) # face index len + + # section offset calculation (exact mirror of Java) + var21 = 0 + var22 = var21 + var9 # vertex flags end + var23 = var22 # face type offset + var22 += var10 # face types + + var24 = var22 # face priority offset + if var13 == 255: + var22 += var10 + + var25 = var22 # face skin offset + if var15 == 1: + var22 += var10 + + var26 = var22 # face render type offset + if var12 == 1: + var22 += var10 + + var27 = var22 # vertex skin offset + if var16 == 1: + var22 += var9 + + var28 = var22 # face transparency offset + if var14 == 1: + var22 += var10 + + var29 = var22 # face index offset + var22 += var20 + + var30 = var22 # face color offset + var22 += var10 * 2 + + var31 = var22 # texture coords offset + var22 += var11 * 6 + + var32 = var22 # vertex X offset + var22 += var17 + var33 = var22 # vertex Y offset + var22 += var18 + # vertex Z starts here + + vx, vy, vz = _decode_vertices(data, var21, var32, var33, var22, var9) + fa, fb, fc = _decode_faces(data, var29, var23, var10) + colors = _decode_face_colors(data, var30, var10) + + # handle faceRenderType & 2 → face is textured (type2/oldFormat path) + face_textures: list[int] = [] + if var12 == 1: + face_textures = _apply_render_type_textures(data, var26, colors, var10) + + priorities = _decode_face_priorities(data, var24, var10, var13) + skins = _decode_vertex_skins(data, var27, var9, var16) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_type1(model_id: int, data: bytes) -> ModelData | None: + """23-byte footer with textureRenderTypes (type1). Mirrors Java decodeType1.""" + n = len(data) + if n < 23: + return None + + off = n - 23 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has face textures + var17 = _read_ubyte(data, off + 10) # has vertex skins + # note: no animaya byte in type1 footer (that's in type3) + var18 = _read_ushort(data, off + 11) # vertex X len + var19 = _read_ushort(data, off + 13) # vertex Y len + var20 = _read_ushort(data, off + 15) # vertex Z len + var21 = _read_ushort(data, off + 17) # face index len + var22 = _read_ushort(data, off + 19) # tex index len + + # count texture render types + tex_type0 = 0 + tex_type13 = 0 + tex_type2 = 0 + if var11 > 0: + for i in range(var11): + t = data[i] + if t == 0: + tex_type0 += 1 + if 1 <= t <= 3: + tex_type13 += 1 + if t == 2: + tex_type2 += 1 + + # section offsets (exact mirror of Java decodeType1) + var26 = var11 + var9 + var56 = var26 # face render type offset + if var12 == 1: + var26 += var10 + + var28 = var26 # face strip type offset + var26 += var10 + + var29 = var26 # face priority offset + if var13 == 255: + var26 += var10 + + var30 = var26 # face skin offset + if var15 == 1: + var26 += var10 + + var31 = var26 # vertex skin offset + if var17 == 1: + var26 += var9 + + var32 = var26 # face transparency offset + if var14 == 1: + var26 += var10 + + var33 = var26 # face index data offset + var26 += var21 + + var34 = var26 # face texture offset + if var16 == 1: + var26 += var10 * 2 + + var35 = var26 # tex map offset + var26 += var22 + + var36 = var26 # face color offset + var26 += var10 * 2 + + var37 = var26 # vertex X offset + var26 += var18 + var38 = var26 # vertex Y offset + var26 += var19 + var39 = var26 # vertex Z offset + var26 += var20 + + # texture coordinates + var40 = var26 # tex type 0 coords + var26 += tex_type0 * 6 + var41 = var26 # tex type 1-3 coords + var26 += tex_type13 * 6 + # ... (more tex data, but we don't need it) + + # vertex flags start at offset var11 (after textureRenderTypes) + vx, vy, vz = _decode_vertices(data, var11, var37, var38, var39, var9) + fa, fb, fc = _decode_faces(data, var33, var28, var10) + colors = _decode_face_colors(data, var36, var10) + + # type1: faceTextures read from dedicated stream (readUShort() - 1) + face_textures: list[int] = [] + if var16 == 1: + face_textures = _decode_face_textures_from_stream(data, var34, var10) + + priorities = _decode_face_priorities(data, var29, var10, var13) + skins = _decode_vertex_skins(data, var31, var9, var17) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_type3(model_id: int, data: bytes) -> ModelData | None: + """26-byte footer (type3, magic 0xFF 0xFD). Mirrors Java decodeType3.""" + n = len(data) + if n < 26: + return None + + off = n - 26 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has face textures + var17 = _read_ubyte(data, off + 10) # has vertex skins + var18 = _read_ubyte(data, off + 11) # has animaya + var19 = _read_ushort(data, off + 12) # vertex X len + var20 = _read_ushort(data, off + 14) # vertex Y len + var21 = _read_ushort(data, off + 16) # vertex Z len + var22 = _read_ushort(data, off + 18) # face index len + var23 = _read_ushort(data, off + 20) # tex map len + var24 = _read_ushort(data, off + 22) # tex index len + # off+24..25 = magic (0xFF, 0xFD) + + # count texture render types + tex_type0 = 0 + tex_type13 = 0 + tex_type2 = 0 + if var11 > 0: + for i in range(var11): + t = data[i] + if t == 0: + tex_type0 += 1 + if 1 <= t <= 3: + tex_type13 += 1 + if t == 2: + tex_type2 += 1 + + # section offsets (exact mirror of Java decodeType3) + var28 = var11 + var9 + var58 = var28 # face render type offset + if var12 == 1: + var28 += var10 + + var30 = var28 # face strip type offset + var28 += var10 + + var31 = var28 # face priority offset + if var13 == 255: + var28 += var10 + + var32 = var28 # face skin offset + if var15 == 1: + var28 += var10 + + var33 = var28 # tex index / vertex skin region + var28 += var24 # tex_index_len + + var34 = var28 # face transparency offset + if var14 == 1: + var28 += var10 + + var35 = var28 # face index data offset + var28 += var22 # face_index_len + + var36 = var28 # face texture offset + if var16 == 1: + var28 += var10 * 2 + + var37 = var28 # tex map offset + var28 += var23 # tex_map_len + + var38 = var28 # FACE COLOR offset + var28 += var10 * 2 + + var39 = var28 # vertex X offset + var28 += var19 + var40 = var28 # vertex Y offset + var28 += var20 + var41 = var28 # vertex Z offset + var28 += var21 + + # texture coordinates section (we skip but need for total size check) + var42 = var28 # tex type 0 coords + var28 += tex_type0 * 6 + var43 = var28 # tex type 1-3 coords + var28 += tex_type13 * 6 + # ... (more tex data for type 1-3 and type 2) + + # vertex flags start at offset var11 (after textureRenderTypes) + vx, vy, vz = _decode_vertices(data, var11, var39, var40, var41, var9) + fa, fb, fc = _decode_faces(data, var35, var30, var10) + colors = _decode_face_colors(data, var38, var10) + + # type3: faceTextures read from dedicated stream (readUShort() - 1) + face_textures: list[int] = [] + if var16 == 1: + face_textures = _decode_face_textures_from_stream(data, var36, var10) + + priorities = _decode_face_priorities(data, var31, var10, var13) + skins = _decode_vertex_skins(data, var33, var9, var17) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +# --- HSL to RGB conversion --- + + +def hsl15_to_rgb(hsl: int) -> tuple[int, int, int]: + """Convert OSRS 15-bit HSL face color to RGB. + + HSL packing: (hue_sat << 7) | lightness + where hue_sat = hue * 8 + saturation (0..511) + hue: 0..63 (6 bits), saturation: 0..7 (3 bits), lightness: 0..127 (7 bits) + + Reimplements Rasterizer3D.Rasterizer3D_buildPalette without brightness adjustment. + """ + hue_sat = (hsl >> 7) & 0x1FF + lightness = hsl & 0x7F + + hue_f = (hue_sat >> 3) / 64.0 + 0.0078125 + sat_f = (hue_sat & 7) / 8.0 + 0.0625 + light_f = lightness / 128.0 + + # HSL to RGB (same algorithm as Rasterizer3D) + r, g, b = light_f, light_f, light_f + + if sat_f != 0.0: + if light_f < 0.5: + q = light_f * (1.0 + sat_f) + else: + q = light_f + sat_f - light_f * sat_f + + p = 2.0 * light_f - q + + h_r = hue_f + 1.0 / 3.0 + if h_r > 1.0: + h_r -= 1.0 + h_g = hue_f + h_b = hue_f - 1.0 / 3.0 + if h_b < 0.0: + h_b += 1.0 + + r = _hue_to_channel(p, q, h_r) + g = _hue_to_channel(p, q, h_g) + b = _hue_to_channel(p, q, h_b) + + ri = min(255, int(r * 256.0)) + gi = min(255, int(g * 256.0)) + bi = min(255, int(b * 256.0)) + return (max(0, ri), max(0, gi), max(0, bi)) + + +def _hue_to_channel(p: float, q: float, t: float) -> float: + """HSL hue-to-channel helper (same as Rasterizer3D).""" + if 6.0 * t < 1.0: + return p + (q - p) * 6.0 * t + if 2.0 * t < 1.0: + return q + if 3.0 * t < 2.0: + return p + (q - p) * (2.0 / 3.0 - t) * 6.0 + return p + + +# --- binary output --- + +MDLS_MAGIC = 0x4D444C53 # "MDLS" (v1) +MDL2_MAGIC = 0x4D444C32 # "MDL2" (v2, adds animation data) + + +def _merge_models(models: list[ModelData]) -> ModelData: + """Merge multiple ModelData into a single model (concatenate geometry). + + Face vertex indices are offset by accumulated vertex counts. + Used for identity kit body parts that consist of multiple sub-models. + """ + merged = ModelData(model_id=models[0].model_id) + + vert_offset = 0 + for md in models: + merged.vertices_x.extend(md.vertices_x) + merged.vertices_y.extend(md.vertices_y) + merged.vertices_z.extend(md.vertices_z) + + merged.face_a.extend(a + vert_offset for a in md.face_a) + merged.face_b.extend(b + vert_offset for b in md.face_b) + merged.face_c.extend(c + vert_offset for c in md.face_c) + merged.face_colors.extend(md.face_colors) + merged.face_priorities.extend(md.face_priorities) + merged.face_alphas.extend(md.face_alphas) + merged.face_textures.extend(md.face_textures) + merged.face_tex_coords.extend(md.face_tex_coords) + merged.tex_u.extend(md.tex_u) + merged.tex_v.extend(md.tex_v) + merged.tex_w.extend(md.tex_w) + merged.vertex_skins.extend(md.vertex_skins) + + vert_offset += md.vertex_count + merged.vertex_count += md.vertex_count + merged.face_count += md.face_count + merged.tex_face_count += md.tex_face_count + + return merged + + +def expand_model( + model: ModelData, + tex_colors: dict[int, int] | None = None, + atlas: "TextureAtlas | None" = None, +) -> tuple[list[float], list[tuple[int, int, int, int]], list[float]]: + """Expand indexed model to per-vertex (3 verts per face, no index buffer). + + Returns (flat_vertices[face_count*3*3], colors[face_count*3], uvs[face_count*3*2]). + Each color is (r, g, b, 255). + + When atlas is provided: + - Textured faces get UV coordinates mapped to atlas slots, vertex color = white + - Non-textured faces get UV pointing to atlas white pixel, vertex color = HSL + When atlas is None (legacy path): + - Textured faces use averageRGB from tex_colors as vertex color + - UVs are all zeros + """ + verts: list[float] = [] + colors: list[tuple[int, int, int, int]] = [] + uvs: list[float] = [] + + # compute minimum priority so we only offset faces that differ from baseline. + # a model with all faces at priority 10 needs zero offset (no coplanar conflict). + min_pri = min(model.face_priorities) if model.face_priorities else 0 + + for fi in range(model.face_count): + a = model.face_a[fi] + b = model.face_b[fi] + c = model.face_c[fi] + + # bounds check + if ( + a < 0 + or a >= model.vertex_count + or b < 0 + or b >= model.vertex_count + or c < 0 + or c >= model.vertex_count + ): + # degenerate face, emit zero triangle + verts.extend([0.0] * 9) + colors.extend([(128, 128, 128, 255)] * 3) + uvs.extend([0.0] * 6) + continue + + # vertex positions (OSRS Y is negative-up, we negate) + ax, ay, az = float(model.vertices_x[a]), float(-model.vertices_y[a]), float(model.vertices_z[a]) + bx, by, bz = float(model.vertices_x[b]), float(-model.vertices_y[b]), float(model.vertices_z[b]) + cx, cy, cz = float(model.vertices_x[c]), float(-model.vertices_y[c]), float(model.vertices_z[c]) + + # priority-based normal offset to prevent z-fighting on coplanar faces. + # OSRS uses a painter's algorithm with per-face priorities (0-9 drawn in + # strict ascending order). we only offset faces above the model's minimum + # priority — if all faces share the same priority, no offset is needed. + pri = model.face_priorities[fi] if fi < len(model.face_priorities) else 0 + pri_delta = pri - min_pri + if pri_delta > 0: + # face normal via cross product of edges + e1x, e1y, e1z = bx - ax, by - ay, bz - az + e2x, e2y, e2z = cx - ax, cy - ay, cz - az + nx = e1y * e2z - e1z * e2y + ny = e1z * e2x - e1x * e2z + nz = e1x * e2y - e1y * e2x + length = math.sqrt(nx * nx + ny * ny + nz * nz) + if length > 0.001: + # 0.15 OSRS units per priority level — small enough to be invisible, + # large enough to resolve depth buffer ambiguity at 1/128 scale + bias = pri_delta * 0.15 / length + nx *= bias + ny *= bias + nz *= bias + ax -= nx; ay -= ny; az -= nz + bx -= nx; by -= ny; bz -= nz + cx -= nx; cy -= ny; cz -= nz + + verts.extend([ax, ay, az, bx, by, bz, cx, cy, cz]) + + tex_id = ( + model.face_textures[fi] + if fi < len(model.face_textures) + else -1 + ) + + if atlas and tex_id >= 0 and tex_id in atlas.uv_map: + # textured face: compute UV in atlas space + u_off, v_off, u_size, v_size = atlas.uv_map[tex_id] + + # face vertices ARE the UV basis (textureCoords = -1 case): + # vertex A → (0,0), B → (1,0), C → (0,1) in texture space + # mapped to atlas: offset + fraction * size + uv_a = (u_off + 0.0 * u_size, v_off + 0.0 * v_size) + uv_b = (u_off + 1.0 * u_size, v_off + 0.0 * v_size) + uv_c = (u_off + 0.0 * u_size, v_off + 1.0 * v_size) + uvs.extend([uv_a[0], uv_a[1], uv_b[0], uv_b[1], uv_c[0], uv_c[1]]) + + # vertex color = white (texture provides color) + colors.extend([(255, 255, 255, 255)] * 3) + else: + # non-textured face: UV points to white pixel, vertex color = HSL + if atlas: + uvs.extend([atlas.white_u, atlas.white_v] * 3) + else: + uvs.extend([0.0] * 6) + + if tex_id >= 0 and tex_colors and tex_id in tex_colors: + hsl = tex_colors[tex_id] + else: + hsl = model.face_colors[fi] if fi < len(model.face_colors) else 0 + r, g, b_col = hsl15_to_rgb(hsl) + color = (r, g, b_col, 255) + colors.extend([color, color, color]) + + return verts, colors, uvs + + +def write_models_binary( + output_path: Path, models: list[ModelData], +) -> None: + """Write models to .models v2 binary format (MDL2). + + V2 format adds animation data per model: + - base vertex positions (indexed, pre-animation reference pose) + - vertex skin labels (label group per base vertex, for animation transforms) + - face indices (triangle index buffer into base vertices) + + Per-model binary layout: + uint32 model_id + uint16 expanded_vert_count (face_count * 3, for rendering) + uint16 face_count + uint16 base_vert_count (original indexed vertex count) + float expanded_verts[expanded_vert_count * 3] (x,y,z) + uint8 colors[expanded_vert_count * 4] (r,g,b,a) + int16 base_verts[base_vert_count * 3] (x,y,z — original OSRS coords, y NOT negated) + uint8 vertex_skins[base_vert_count] (label group per vertex) + uint16 face_indices[face_count * 3] (a,b,c per face) + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "wb") as f: + # header + f.write(struct.pack(" None: + """Write C header with item → model ID mapping. + + Each entry: (item_id, inv_model_id, wield_model_id, has_sleeves). + has_sleeves indicates whether the body item provides its own arm model + (male_wield2), meaning default arm body parts should be hidden. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w") as f: + f.write("/* generated by scripts/export_models.py — do not edit */\n") + f.write("#ifndef ITEM_MODELS_H\n") + f.write("#define ITEM_MODELS_H\n\n") + f.write("#include \n\n") + f.write("typedef struct {\n") + f.write(" uint16_t item_id;\n") + f.write(" uint32_t inv_model;\n") + f.write(" uint32_t wield_model;\n") + f.write(" uint8_t has_sleeves;\n") + f.write("} ItemModelMapping;\n\n") + f.write( + f"#define ITEM_MODEL_COUNT {len(mappings)}\n\n" + ) + f.write( + "static const ItemModelMapping ITEM_MODEL_MAP[] = {\n" + ) + for item_id, inv, wield, sleeves in mappings: + f.write(f" {{ {item_id}, {inv}, {wield}, {sleeves} }},\n") + f.write("};\n\n") + f.write("#endif /* ITEM_MODELS_H */\n") + + +def write_player_model_header( + output_path: Path, + body_part_model_ids: dict[int, int], + item_mappings: list[tuple[int, int, int, int]], + item_defs: dict[int, ItemDef], +) -> None: + """Write C header for player model rendering. + + Provides: + - Default body part model IDs (from identity kits) + - Item ID to wield model lookup (for equipped items) + - Equipment slot coverage info (which body parts to hide) + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w") as f: + f.write("/* generated by scripts/export_models.py — do not edit */\n") + f.write("#ifndef PLAYER_MODELS_H\n") + f.write("#define PLAYER_MODELS_H\n\n") + f.write("#include \n\n") + + f.write("/* body part indices (male) */\n") + for i, name in enumerate(BODY_PART_NAMES): + f.write(f"#define BODY_PART_{name} {i}\n") + f.write(f"#define BODY_PART_COUNT {len(BODY_PART_NAMES)}\n\n") + + f.write("/* default male body part model IDs (synthetic: 0xF0000 + part_id) */\n") + f.write("static const uint32_t DEFAULT_BODY_MODELS[BODY_PART_COUNT] = {\n") + for i in range(len(BODY_PART_NAMES)): + mid = body_part_model_ids.get(i, 0xFFFFFFFF) + f.write(f" 0x{mid:X}, /* {BODY_PART_NAMES[i]} */\n") + f.write("};\n\n") + + f.write("#endif /* PLAYER_MODELS_H */\n") + + +# --- item IDs from our simulation (osrs_items.h) --- + +SIM_ITEM_IDS = [ + 10828, # Helm of Neitiznot + 21795, # Imbued god cape + 1712, # Amulet of glory + 2503, # Black d'hide body + 4091, # Mystic robe top + 1079, # Rune platelegs + 4093, # Mystic robe bottom + 4151, # Abyssal whip + 9185, # Rune crossbow + 4710, # Ahrim's staff + 5698, # Dragon dagger + 12954, # Dragon defender + 12829, # Spirit shield + 7462, # Barrows gloves + 3105, # Climbing boots + 6737, # Berserker ring + 9243, # Diamond bolts (e) + 22324, # Ghrazi rapier + 24417, # Inquisitor's mace + 11791, # Staff of the dead + 21006, # Kodai wand + 24424, # Volatile nightmare staff + 13867, # Zuriel's staff + 11785, # Armadyl crossbow + 26374, # Zaryte crossbow + 13652, # Dragon claws + 11802, # Armadyl godsword + 25730, # Ancient godsword + 4153, # Granite maul + 21003, # Elder maul + 11235, # Dark bow + 19481, # Heavy ballista + 22613, # Vesta's longsword + 27690, # Voidwaker + 22622, # Statius's warhammer + 22636, # Morrigan's javelin + 21018, # Ancestral hat + 21021, # Ancestral robe top + 21024, # Ancestral robe bottom + 4712, # Ahrim's robetop + 4714, # Ahrim's robeskirt + 4736, # Karil's leathertop + 11834, # Bandos tassets + 12831, # Blessed spirit shield + 6585, # Amulet of fury + 12002, # Occult necklace + 21295, # Infernal cape + 13235, # Eternal boots + 11770, # Seers ring (i) + 25975, # Lightbearer + 6889, # Mage's book + 11212, # Dragon arrows + 4751, # Torag's platelegs + 4722, # Dharok's platelegs + 4759, # Verac's plateskirt + 4745, # Torag's helm + 4716, # Dharok's helm + 4753, # Verac's helm + 4724, # Guthan's helm + 21932, # Opal dragon bolts (e) + # --- Zulrah encounter items --- + # tier 2 (BIS) + 21791, # Imbued saradomin cape + 31113, # Eye of ayak (charged) + 27251, # Elidinis' ward (f) + 31106, # Confliction gauntlets + 31097, # Avernic treads (max) + 20657, # Ring of suffering (ri) + 20997, # Twisted bow + 27235, # Masori mask (f) + 27238, # Masori body (f) + 27241, # Masori chaps (f) + 19547, # Necklace of anguish + 28947, # Dizana's quiver (uncharged) + 26235, # Zaryte vambraces + 12926, # Toxic blowpipe + # tier 1 (mid) + 4708, # Ahrim's hood + 19544, # Tormented bracelet + 22481, # Sanguinesti staff + 6920, # Infinity boots + 20220, # Holy blessing (god blessing) + 2550, # Ring of recoil + 23971, # Crystal helm + 22109, # Ava's assembler + 23975, # Crystal body + 23979, # Crystal legs + 25865, # Bow of faerdhinen (c) + 19921, # Blessed d'hide boots + # tier 0 (budget) + 4089, # Mystic hat + 12899, # Trident of the swamp + 12612, # Book of darkness + 21326, # Amethyst arrow + 4097, # Mystic boots + 10382, # Blessed coif (Guthix) + 2497, # Black d'hide chaps + 12788, # Magic shortbow (i) + 10499, # Ava's accumulator + # --- Inferno encounter items --- + 22326, # Justiciar faceguard + 22327, # Justiciar chestguard + 22328, # Justiciar legguards + 4224, # Crystal shield + 13237, # Pegasian boots +] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="export OSRS 3D models from 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 + 26379, # GFX 1122 dragon dart projectile (blowpipe) + 3131, # GFX 231 rune dart projectile + 29421, # GFX 1043 blowpipe special attack + } + needed_models |= ENCOUNTER_MODELS + print(f"added {len(ENCOUNTER_MODELS)} encounter NPC/projectile models") + + # add extra models from CLI (NPC models, etc.) + if args.extra_models: + extra = {int(x.strip()) for x in args.extra_models.split(",")} + needed_models |= extra + print(f"added {len(extra)} extra models from --extra-models: {sorted(extra)}") + + # dragon bolt (GFX 1468) is model 3135 with recolors: 41->1692, 61->670, 57->1825 + # build it as a synthetic recolored model like wield models + dragon_bolt_base = _load_model(3135) + if dragon_bolt_base: + for src, dst in [(41, 1692), (61, 670), (57, 1825)]: + for fi in range(dragon_bolt_base.face_count): + if dragon_bolt_base.face_colors[fi] == src: + dragon_bolt_base.face_colors[fi] = dst + dragon_bolt_base.model_id = 0xD0001 # synthetic ID for dragon bolt + wield_models.append(dragon_bolt_base) + print(f" built dragon bolt model (recolored 3135 -> 0xD0001)") + + print(f"\n{len(needed_models)} unique equipment + spotanim models to export") + + # read and decode models from cache index 1 + decoded_models: list[ModelData] = [] + errors = 0 + + for model_id in sorted(needed_models): + 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..350bca9d6d --- /dev/null +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -0,0 +1,373 @@ +"""Export sprites from modern OSRS cache (OpenRS2 flat format) to PNG files. + +Reads sprite archives from cache index 8 and decodes them using the +SpriteLoader format from the deobfuscated client (trailer-based format +with palette, per-frame offsets, and optional alpha channel). + +Exports specific sprite IDs needed for the debug viewer GUI: + - equipment slot backgrounds (156-165, 170) + - prayer icons enabled/disabled (115-154, 502-509, 945-951, 1420-1427) + - tab icons (168, 776, 779, 780, 900, 901) + - spell icons (325-336, 375-386, 557, 561, 564, 607, 611, 614) + - combat interface sprites (657) + +Usage: + uv run python scripts/export_sprites_modern.py \ + --cache ../reference/osrs-cache-modern \ + --output data/sprites/gui +""" + +import argparse +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# add parent for modern_cache_reader import +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ModernCacheReader + + +@dataclass +class SpriteFrame: + """Single sprite frame decoded from cache archive.""" + + group_id: int = 0 + frame: int = 0 + offset_x: int = 0 + offset_y: int = 0 + width: int = 0 + height: int = 0 + max_width: int = 0 + max_height: int = 0 + pixels: list[int] = field(default_factory=list) # ARGB int array + + +def decode_sprites(group_id: int, data: bytes) -> list[SpriteFrame]: + """Decode sprite archive using SpriteLoader format from deob client. + + Ported from SpriteLoader.java (runelite-cache). Format is trailer-based: + [pixel data for all frames] + [palette: (palette_len - 1) x 3 bytes RGB] + [per-frame: offset_x, offset_y, width, height as u16 arrays] x frame_count + [max_width u16, max_height u16, palette_len_minus1 u8] (5 bytes) + [frame_count u16] (last 2 bytes) + """ + if len(data) < 2: + return [] + + buf = io.BytesIO(data) + + # trailer: frame_count at very end (SpriteLoader.java line 41) + buf.seek(len(data) - 2) + frame_count = struct.unpack(">H", buf.read(2))[0] + if frame_count == 0: + return [] + + # header block: 5 bytes before per-frame data before frame_count + # (SpriteLoader.java line 48) + header_start = len(data) - 7 - frame_count * 8 + buf.seek(header_start) + + max_width = struct.unpack(">H", buf.read(2))[0] + max_height = struct.unpack(">H", buf.read(2))[0] + # SpriteLoader.java line 53: paletteLength = readUnsignedByte() + 1 + palette_len = buf.read(1)[0] + 1 + + # per-frame dimensions: 4 arrays of frame_count u16 values + # (SpriteLoader.java lines 64-82) + offsets_x = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + offsets_y = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + widths = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + heights = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + + # palette: (palette_len - 1) RGB entries before the header block + # (SpriteLoader.java line 85) + palette_start = header_start - (palette_len - 1) * 3 + buf.seek(palette_start) + palette = [0] * palette_len # index 0 = transparent + for i in range(1, palette_len): + r = buf.read(1)[0] + g = buf.read(1)[0] + b = buf.read(1)[0] + rgb = (r << 16) | (g << 8) | b + palette[i] = rgb if rgb != 0 else 1 + + # pixel data from start of file (SpriteLoader.java line 98) + buf.seek(0) + frames = [] + for fi in range(frame_count): + w = widths[fi] + h = heights[fi] + dimension = w * h + + # per-frame arrays matching Java's byte[] layout + indices = [0] * dimension + alphas = [0] * dimension + + flags = buf.read(1)[0] + + # read palette indices (SpriteLoader.java lines 113-131) + if not (flags & 0x01): + # horizontal + for j in range(dimension): + indices[j] = buf.read(1)[0] + else: + # vertical: iterate columns then rows + for j in range(w): + for k in range(h): + indices[w * k + j] = buf.read(1)[0] + + # read alpha channel if FLAG_ALPHA (SpriteLoader.java lines 134-155) + if flags & 0x02: + if not (flags & 0x01): + for j in range(dimension): + alphas[j] = buf.read(1)[0] + else: + for j in range(w): + for k in range(h): + alphas[w * k + j] = buf.read(1)[0] + + # force opaque for all non-zero palette indices + # (SpriteLoader.java lines 157-166 — runs AFTER alpha read) + for j in range(dimension): + if indices[j] != 0: + alphas[j] = 0xFF + + # build ARGB pixels (SpriteLoader.java lines 168-176) + pixels = [0] * dimension + for j in range(dimension): + idx = indices[j] & 0xFF + pixels[j] = palette[idx] | (alphas[j] << 24) + + frame = SpriteFrame( + group_id=group_id, + frame=fi, + offset_x=offsets_x[fi], + offset_y=offsets_y[fi], + width=w, + height=h, + max_width=max_width, + max_height=max_height, + pixels=pixels, + ) + frames.append(frame) + + return frames + + +def save_sprite_png(sprite: SpriteFrame, path: Path) -> None: + """Save a sprite frame as RGBA PNG using pure Python (no PIL dependency).""" + try: + from PIL import Image + except ImportError: + print(f" pillow not available, skipping {path}", file=sys.stderr) + return + + img = Image.new("RGBA", (sprite.width, sprite.height)) + rgba_data = [] + for argb in sprite.pixels: + a = (argb >> 24) & 0xFF + r = (argb >> 16) & 0xFF + g = (argb >> 8) & 0xFF + b = argb & 0xFF + rgba_data.append((r, g, b, a)) + img.putdata(rgba_data) + img.save(str(path)) + + +# sprite IDs to export, organized by category +SPRITE_IDS: dict[str, list[int]] = { + # equipment slot background icons + "equip": [156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 170], + # prayer icons (enabled) + "prayer": [ + 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, + 127, 128, 129, 130, 131, 132, 133, 134, # base prayers + 502, 503, 504, 505, # hawk eye, mystic lore, eagle eye, mystic might + 945, 946, 947, # chivalry, piety, preserve + 1420, 1421, # rigour, augury + ], + # prayer icons (disabled/greyed) + "prayer_off": [ + 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, + 147, 148, 149, 150, 151, 152, 153, 154, # base disabled + 506, 507, 508, 509, # hawk/mystic disabled + 949, 950, 951, # chivalry/piety/preserve disabled + 1424, 1425, # rigour/augury disabled + ], + # tab icons (combat, stats, quests, inventory, equipment, prayer, magic) + "tab": [168, 776, 779, 780, 898, 899, 900, 901], + # ancient spell icons (enabled + disabled) + "spell_ancient": [ + 325, 326, 327, 328, # ice rush/burst/blitz/barrage + 333, 334, 335, 336, # blood rush/burst/blitz/barrage + 375, 376, 377, 378, # ice disabled + 383, 384, 385, 386, # blood disabled + ], + # lunar spell icons + "spell_lunar": [557, 561, 564, 607, 611, 614], + # combat interface + "combat": [657], + # interface chrome: side panel background, tab stones, equipment slot chrome + "chrome": [ + 1031, # FIXED_MODE_SIDE_PANEL_BACKGROUND + 1032, # FIXED_MODE_TABS_ROW_BOTTOM + 1036, # FIXED_MODE_TABS_ROW_TOP + 1026, 1027, 1028, 1029, 1030, # TAB_STONE_*_SELECTED corners + middle + 179, # EQUIPMENT_SLOT_SELECTED + 952, 953, # SLANTED_TAB, SLANTED_TAB_HOVERED + 1071, 1072, # MINIMAP_ORB_FRAME, _HOVERED + 1017, # CHATBOX background + 1018, # CHATBOX_BUTTONS_BACKGROUND_STONES + 166, # EQUIPMENT_SLOT_AMMUNITION (we had 156-165, was missing 166) + 171, 172, 173, # iron rivets (square, vertical, horizontal) + ], + # click cross animations (4 yellow move + 4 red attack, 16x16 each) + "click_cross": [515, 516, 517, 518, 519, 520, 521, 522], + # overhead prayer headicons (multi-frame: 0=melee, 1=ranged, 2=magic, 3=retribution, 4=smite, 5=redemption) + "headicons_prayer": [440], + # hitsplat sprites (each is a single-frame sprite group) + "hitmarks": [1358, 1359, 1360, 1361, 1362], +} + +# human-readable names for specific sprite IDs +SPRITE_NAMES: dict[int, str] = { + 156: "slot_head", 157: "slot_cape", 158: "slot_neck", 159: "slot_weapon", + 160: "slot_ring", 161: "slot_body", 162: "slot_shield", 163: "slot_legs", + 164: "slot_hands", 165: "slot_feet", 170: "slot_tile", + 168: "tab_combat", 776: "tab_quests", 779: "tab_prayer", + 780: "tab_magic", 898: "tab_stats", 899: "tab_quests2", + 900: "tab_inventory", 901: "tab_equipment", + 127: "pray_mage", 128: "pray_range", 129: "pray_melee", + 130: "pray_redemption", 131: "pray_retribution", 132: "pray_smite", + 504: "pray_eagle_eye", 505: "pray_mystic_might", + 945: "pray_chivalry", 946: "pray_piety", 947: "pray_preserve", + 1420: "pray_rigour", 1421: "pray_augury", + 325: "spell_ice_rush", 326: "spell_ice_burst", + 327: "spell_ice_blitz", 328: "spell_ice_barrage", + 333: "spell_blood_rush", 334: "spell_blood_burst", + 335: "spell_blood_blitz", 336: "spell_blood_barrage", + 564: "spell_vengeance", + 657: "special_attack", + # interface chrome + 1031: "side_panel_bg", + 1032: "tabs_row_bottom", + 1036: "tabs_row_top", + 1026: "tab_stone_tl_sel", + 1027: "tab_stone_tr_sel", + 1028: "tab_stone_bl_sel", + 1029: "tab_stone_br_sel", + 1030: "tab_stone_mid_sel", + 179: "slot_selected", + 952: "slanted_tab", + 953: "slanted_tab_hover", + 1071: "orb_frame", + 1072: "orb_frame_hover", + 1017: "chatbox_bg", + 1018: "chatbox_stones", + 166: "slot_ammo", + 171: "rivets_square", + 172: "rivets_vertical", + 173: "rivets_horizontal", + 515: "cross_yellow_1", 516: "cross_yellow_2", + 517: "cross_yellow_3", 518: "cross_yellow_4", + 519: "cross_red_1", 520: "cross_red_2", + 521: "cross_red_3", 522: "cross_red_4", + # overhead prayer headicons (group 440, 6 frames) + 440: "headicons_prayer", + # hitsplats: 0=blue miss, 1=red damage, 2=green poison, 3=disease, 4=venom + 1358: "hitmarks_0", 1359: "hitmarks_1", + 1360: "hitmarks_2", 1361: "hitmarks_3", 1362: "hitmarks_4", +} + + +def main() -> None: + """Export GUI sprites from modern OSRS cache.""" + parser = argparse.ArgumentParser(description="Export OSRS GUI sprites") + parser.add_argument( + "--cache", default="../reference/osrs-cache-modern", + help="Path to modern cache directory", + ) + parser.add_argument( + "--output", default="data/sprites/gui", + help="Output directory for PNGs", + ) + parser.add_argument( + "--list-all", action="store_true", + help="List all sprite group IDs in index 8 and exit", + ) + args = parser.parse_args() + + reader = ModernCacheReader(args.cache) + + if args.list_all: + manifest = reader.read_index_manifest(8) + print(f"index 8 has {len(manifest.group_ids)} sprite groups") + print(f" range: {min(manifest.group_ids)} - {max(manifest.group_ids)}") + return + + out_dir = Path(args.output) + out_dir.mkdir(parents=True, exist_ok=True) + + # collect all sprite IDs to export + all_ids: set[int] = set() + for ids in SPRITE_IDS.values(): + all_ids.update(ids) + + # check which IDs exist in the cache + manifest = reader.read_index_manifest(8) + available = set(manifest.group_ids) + + exported = 0 + failed = 0 + + for sprite_id in sorted(all_ids): + if sprite_id not in available: + print(f" sprite {sprite_id}: NOT in cache index 8") + failed += 1 + continue + + data = reader.read_container(8, sprite_id) + if data is None: + print(f" sprite {sprite_id}: failed to read container") + failed += 1 + continue + + try: + frames = decode_sprites(sprite_id, data) + except Exception as e: + print(f" sprite {sprite_id}: decode error: {e}", file=sys.stderr) + failed += 1 + continue + + if not frames: + print(f" sprite {sprite_id}: no frames decoded") + failed += 1 + continue + + for frame in frames: + name = SPRITE_NAMES.get(sprite_id, str(sprite_id)) + if len(frames) > 1: + filename = f"{name}_{frame.frame}.png" + else: + filename = f"{name}.png" + path = out_dir / filename + save_sprite_png(frame, path) + exported += 1 + + if len(frames) == 1: + f = frames[0] + print(f" sprite {sprite_id} ({SPRITE_NAMES.get(sprite_id, '?')}): " + f"{f.width}x{f.height}") + else: + print(f" sprite {sprite_id} ({SPRITE_NAMES.get(sprite_id, '?')}): " + f"{len(frames)} frames") + + print(f"\nexported {exported} sprite frames, {failed} failed") + print(f"output: {out_dir}/") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_terrain.py b/ocean/osrs/scripts/export_terrain.py new file mode 100644 index 0000000000..cdf9355812 --- /dev/null +++ b/ocean/osrs/scripts/export_terrain.py @@ -0,0 +1,935 @@ +"""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 + l = (mn + mx) / 2.0 + + if mn != mx: + if l < 0.5: + s = (mx - mn) / (mx + mn) + else: + s = (mx - mn) / (2.0 - mx - mn) + + if r == mx: + h = (g - b) / (mx - mn) + elif g == mx: + h = 2.0 + (b - r) / (mx - mn) + elif b == mx: + h = 4.0 + (r - g) / (mx - mn) + + h /= 6.0 + + flo.hue = max(0, min(255, int(h * 256.0))) + flo.saturation = max(0, min(255, int(s * 256.0))) + flo.lightness = max(0, min(255, int(l * 256.0))) + flo.luminance = flo.lightness + + if l > 0.5: + flo.blend_hue_multiplier = int((1.0 - l) * s * 512.0) + else: + flo.blend_hue_multiplier = int(l * s * 512.0) + + if flo.blend_hue_multiplier < 1: + flo.blend_hue_multiplier = 1 + + flo.blend_hue = int(h * flo.blend_hue_multiplier) + flo.hsl16 = _hsl24to16(flo.hue, flo.saturation, flo.luminance) + + +def _hsl24to16(h: int, s: int, l: int) -> int: + """Convert 24-bit HSL to 16-bit packed HSL (FloorDefinition.hsl24to16).""" + if l > 179: + s //= 2 + if l > 192: + s //= 2 + if l > 217: + s //= 2 + if l > 243: + s //= 2 + return ((h // 4) << 10) + ((s // 32) << 7) + l // 2 + + + +# --- modern cache floor definition + texture color decoders --- + +# modern cache config index groups for floor definitions +MODERN_UNDERLAY_GROUP = 1 +MODERN_OVERLAY_GROUP = 4 + + +def _decode_underlay_entry(fid: int, data: bytes) -> FloorDef: + """Decode a single underlay floor definition from modern cache opcode stream. + + Underlay opcodes: 1=rgb(3 bytes), 0=terminator. + """ + flo = FloorDef(floor_id=fid) + buf = io.BytesIO(data) + while True: + op = buf.read(1) + if not op or op[0] == 0: + break + if op[0] == 1: + flo.rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + _rgb_to_hsl(flo.rgb, flo) + return flo + + +def _decode_overlay_entry(fid: int, data: bytes) -> FloorDef: + """Decode a single overlay floor definition from modern cache opcode stream. + + Overlay opcodes (matching RuneLite OverlayDefinition): + 1=rgb(3 bytes), 2=texture(1 byte), 5=hide_underlay=false (no data), + 7=secondary_rgb(3 bytes), 0=terminator. + """ + flo = FloorDef(floor_id=fid) + buf = io.BytesIO(data) + while True: + op = buf.read(1) + if not op or op[0] == 0: + break + opcode = op[0] + if opcode == 1: + flo.rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + elif opcode == 2: + flo.texture = buf.read(1)[0] + elif opcode == 5: + flo.hide_underlay = False + elif opcode == 7: + flo.secondary_rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + # post-decode HSL + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + flo.secondary_hue = flo.hue + flo.secondary_saturation = flo.saturation + flo.secondary_lightness = flo.lightness + _rgb_to_hsl(flo.rgb, flo) + return flo + + +def decode_floor_definitions_modern( + reader: ModernCacheReader, +) -> tuple[dict[int, FloorDef], dict[int, FloorDef]]: + """Decode underlay and overlay floor definitions from modern cache. + + Modern cache stores floor defs in config index 2: + group 1 = underlays, group 4 = overlays. + Each entry is a separate file within the group. + """ + underlays: dict[int, FloorDef] = {} + overlays: dict[int, FloorDef] = {} + + # underlays: config index 2, group 1 + underlay_files = reader.read_group(2, MODERN_UNDERLAY_GROUP) + for fid, data in underlay_files.items(): + underlays[fid] = _decode_underlay_entry(fid, data) + + # overlays: config index 2, group 4 + overlay_files = reader.read_group(2, MODERN_OVERLAY_GROUP) + for fid, data in overlay_files.items(): + overlays[fid] = _decode_overlay_entry(fid, data) + + return underlays, overlays + + +def load_texture_average_colors_modern(reader: ModernCacheReader) -> dict[int, int]: + """Load per-texture average color (HSL16) from modern cache. + + Modern OSRS (rev233+) texture format in index 9, group 0: + u16 fileId (sprite group in index 8) + u16 missingColor (HSL16 fallback — used as the texture's average color) + u8 field1778 + u8 animationDirection + u8 animationSpeed + + The missingColor field is the precomputed average color of the texture + sprite, stored as 16-bit packed HSL. This is what the OSRS client uses + for terrain tile coloring. + """ + tex_files = reader.read_group(9, 0) + result: dict[int, int] = {} + for tex_id, data in tex_files.items(): + if len(data) < 4: + continue + missing_color = struct.unpack(">H", data[2:4])[0] + result[tex_id] = missing_color + return result + + +def djb2(name: str) -> int: + """Compute djb2 hash for modern cache group name lookup.""" + h = 0 + for c in name.lower(): + h = (h * 31 + ord(c)) & 0xFFFFFFFF + if h >= 0x80000000: + h -= 0x100000000 + return h + + +def find_map_groups( + reader: ModernCacheReader, +) -> dict[int, tuple[int | None, int | None]]: + """Build mapping of mapsquare -> (terrain_group_id, object_group_id). + + Scans the index 5 manifest for groups whose djb2 name hashes match + m{rx}_{ry} (terrain) patterns. + """ + manifest = reader.read_index_manifest(5) + + hash_to_gid: dict[int, int] = {} + for gid in manifest.group_ids: + nh = manifest.group_name_hashes.get(gid) + if nh is not None: + hash_to_gid[nh] = gid + + result: dict[int, tuple[int | None, int | None]] = {} + for rx in range(256): + for ry in range(256): + terrain_hash = djb2(f"m{rx}_{ry}") + obj_hash = djb2(f"l{rx}_{ry}") + + terrain_gid = hash_to_gid.get(terrain_hash) + obj_gid = hash_to_gid.get(obj_hash) + + if terrain_gid is not None or obj_gid is not None: + mapsquare = (rx << 8) | ry + result[mapsquare] = (terrain_gid, obj_gid) + + return result + + +# --- terrain parser --- + + +@dataclass +class RegionTerrain: + """Parsed terrain data for one 64x64 region.""" + + region_x: int = 0 + region_y: int = 0 + # [4][64+1][64+1] - tile corner heights (need +1 for neighbor corners) + heights: list = field(default_factory=list) + # [4][64][64] + underlay_ids: list = field(default_factory=list) + overlay_ids: list = field(default_factory=list) + shapes: list = field(default_factory=list) + rotations: list = field(default_factory=list) + settings: list = field(default_factory=list) + + +def parse_terrain_full( + data: bytes, region_chunk_x: int, region_chunk_y: int +) -> RegionTerrain: + """Parse terrain binary to extract heights, underlays, overlays, shapes, settings.""" + rt = RegionTerrain() + rt.heights = [[[0 for _ in range(65)] for _ in range(65)] for _ in range(4)] + rt.underlay_ids = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.overlay_ids = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.shapes = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.rotations = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.settings = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + + buf = io.BytesIO(data) + + for plane in range(4): + for x in range(64): + for y in range(64): + rt.settings[plane][x][y] = 0 + while True: + raw = buf.read(2) + if len(raw) < 2: + break + attr = struct.unpack(">H", raw)[0] + + if attr == 0: + # procedural height + if plane == 0: + rt.heights[0][x][y] = -calculate_height( + 0xE3B7B + x + region_chunk_x, + 0x87CCE + y + region_chunk_y, + ) * 8 + else: + rt.heights[plane][x][y] = rt.heights[plane - 1][x][y] - 240 + break + elif attr == 1: + height = buf.read(1)[0] + if height == 1: + height = 0 + if plane == 0: + rt.heights[0][x][y] = -height * 8 + else: + rt.heights[plane][x][y] = rt.heights[plane - 1][x][y] - height * 8 + break + elif attr <= 49: + rt.overlay_ids[plane][x][y] = struct.unpack(">h", buf.read(2))[0] + rt.shapes[plane][x][y] = (attr - 2) // 4 + rt.rotations[plane][x][y] = (attr - 2) & 3 + elif attr <= 81: + rt.settings[plane][x][y] = attr - 49 + else: + rt.underlay_ids[plane][x][y] = attr - 81 + + return rt + + +# --- lighting + color blending (mirrors method171) --- + + +def hsl_encode(hue: int, sat: int, light: int) -> int: + """Encode HSL to 16-bit (method177 / hsl24to16).""" + if light > 179: + sat //= 2 + if light > 192: + sat //= 2 + if light > 217: + sat //= 2 + if light > 243: + sat //= 2 + return ((hue // 4) << 10) + ((sat // 32) << 7) + light // 2 + + +def apply_light(hsl16: int, light: int) -> int: + """Apply lighting intensity to an HSL16 color (method187).""" + if hsl16 == -1: + return 0xBC614E + l = (light * (hsl16 & 0x7F)) // 128 + if l < 2: + l = 2 + elif l > 126: + l = 126 + return (hsl16 & 0xFF80) + l + + +def hsl16_to_rgb(hsl16: int) -> tuple[int, int, int]: + """Convert 16-bit packed HSL to RGB. + + 16-bit HSL: (hue << 10) | (sat << 7) | lightness + hue: 6 bits (0-63), sat: 3 bits (0-7), lightness: 7 bits (0-127) + Same as the Rasterizer3D palette lookup. + """ + if hsl16 == 0xBC614E or hsl16 < 0: + return (0, 0, 0) + + h_raw = (hsl16 >> 10) & 0x3F + s_raw = (hsl16 >> 7) & 0x7 + l_raw = hsl16 & 0x7F + + hue_f = h_raw / 64.0 + 0.0078125 + sat_f = s_raw / 8.0 + 0.0625 + light_f = l_raw / 128.0 + + r, g, b = light_f, light_f, light_f + + if sat_f != 0.0: + if light_f < 0.5: + q = light_f * (1.0 + sat_f) + else: + q = light_f + sat_f - light_f * sat_f + p = 2.0 * light_f - q + + h_r = hue_f + 1.0 / 3.0 + if h_r > 1.0: + h_r -= 1.0 + h_g = hue_f + h_b = hue_f - 1.0 / 3.0 + if h_b < 0.0: + h_b += 1.0 + + r = _hue_channel(p, q, h_r) + g = _hue_channel(p, q, h_g) + b = _hue_channel(p, q, h_b) + + ri = max(0, min(255, int(r * 256.0))) + gi = max(0, min(255, int(g * 256.0))) + bi = max(0, min(255, int(b * 256.0))) + return (ri, gi, bi) + + +def _hue_channel(p: float, q: float, t: float) -> float: + if 6.0 * t < 1.0: + return p + (q - p) * 6.0 * t + if 2.0 * t < 1.0: + return q + if 3.0 * t < 2.0: + return p + (q - p) * (2.0 / 3.0 - t) * 6.0 + return p + + +# --- terrain mesh builder --- + + +@dataclass +class TerrainVertex: + """A single terrain vertex with position and color.""" + + x: float = 0.0 + y: float = 0.0 # height (OSRS Y = up) + z: float = 0.0 + r: int = 0 + g: int = 0 + b: int = 0 + + +def stitch_region_edges( + regions: dict[tuple[int, int], RegionTerrain], +) -> None: + """Stitch height values at region boundaries. + + Each region's height array is [65][65] but only indices 0..63 are computed. + Index 64 (the far edge) defaults to 0, causing visible seams. Fix by copying + heights from the neighboring region's index 0 to the current region's index 64. + + Also computes edge heights for regions without a neighbor by extrapolating + from the nearest interior values. + """ + for (rx, ry), rt in regions.items(): + for plane in range(4): + # stitch X=64 edge from neighbor (rx+1, ry) + neighbor_x = regions.get((rx + 1, ry)) + for y in range(65): + if neighbor_x and y < 65: + # use neighbor's x=0 column + ny = min(y, 64) + rt.heights[plane][64][y] = neighbor_x.heights[plane][0][ny] + elif y <= 63: + # no neighbor: extrapolate from x=63 + rt.heights[plane][64][y] = rt.heights[plane][63][y] + + # stitch Y=64 edge from neighbor (rx, ry+1) + neighbor_y = regions.get((rx, ry + 1)) + for x in range(65): + if neighbor_y and x < 65: + nx = min(x, 64) + rt.heights[plane][x][64] = neighbor_y.heights[plane][nx][0] + elif x <= 63: + rt.heights[plane][x][64] = rt.heights[plane][x][63] + + # corner (64, 64): prefer diagonal neighbor + diag = regions.get((rx + 1, ry + 1)) + if diag: + rt.heights[plane][64][64] = diag.heights[plane][0][0] + elif neighbor_x: + rt.heights[plane][64][64] = neighbor_x.heights[plane][0][min(64, 63)] + elif neighbor_y: + rt.heights[plane][64][64] = neighbor_y.heights[plane][min(64, 63)][0] + else: + rt.heights[plane][64][64] = rt.heights[plane][63][63] + + +def build_terrain_mesh( + regions: dict[tuple[int, int], RegionTerrain], + underlays: dict[int, FloorDef], + overlays: dict[int, FloorDef], + target_plane: int = 0, + tex_colors: dict[int, int] | None = None, + brightness: float = 1.0, +) -> tuple[list[float], list[int]]: + """Build a vertex-colored terrain mesh from parsed regions. + + Returns (vertices[N*3], colors[N*4]) as flat lists. + Each tile = 2 triangles = 6 vertices. + """ + verts: list[float] = [] + colors: list[int] = [] + + # we work region by region. for each tile, emit 2 triangles. + # world coords: base_x = region_x * 64, base_y = region_y * 64 + # OSRS tile = 128 world units. we'll use 1 tile = 1 unit for simplicity. + + for (rx, ry), rt in sorted(regions.items()): + base_wx = rx * 64 + base_wy = ry * 64 + + z = target_plane + + # compute lighting per vertex (mirrors method171 lighting pass) + # intensity[65][65] for tile corners + base_intensity = int(96 * brightness) + intensity = [[base_intensity] * 65 for _ in range(65)] + + light_x, light_y, light_z = -50, -10, -50 + light_len = int(math.sqrt(light_x * light_x + light_y * light_y + light_z * light_z)) + distribution = (768 * light_len) >> 8 + + for ty in range(65): + for tx in range(65): + dx = rt.heights[z][min(tx + 1, 64)][ty] - rt.heights[z][max(tx - 1, 0)][ty] + dy = rt.heights[z][tx][min(ty + 1, 64)] - rt.heights[z][tx][max(ty - 1, 0)] + length = int(math.sqrt(dx * dx + 256 * 256 + dy * dy)) + if length == 0: + length = 1 + nx = (dx << 8) // length + ny = (256 << 8) // length + nz = (dy << 8) // length + intensity[tx][ty] = base_intensity + (light_x * nx + light_y * ny + light_z * nz) // distribution + + # compute blended underlay colors using 11x11 window + # for simplicity, compute per-tile center color + for ty in range(64): + for tx in range(64): + uid = rt.underlay_ids[z][tx][ty] + oid = rt.overlay_ids[z][tx][ty] & 0xFFFF + + # default: dark ground color + tile_rgb = (40, 50, 30) + + if uid > 0 or oid > 0: + # get 4 corner heights + h_sw = rt.heights[z][tx][ty] + h_se = rt.heights[z][min(tx + 1, 64)][ty] + h_ne = rt.heights[z][min(tx + 1, 64)][min(ty + 1, 64)] + h_nw = rt.heights[z][tx][min(ty + 1, 64)] + + # underlay color: blend in 11x11 window + if uid > 0: + blend_h, blend_s, blend_l, blend_m, blend_c = 0, 0, 0, 0, 0 + for bx in range(max(0, tx - 5), min(64, tx + 6)): + for by in range(max(0, ty - 5), min(64, ty + 6)): + uid2 = rt.underlay_ids[z][bx][by] + if uid2 > 0: + flo = underlays.get(uid2 - 1) + if flo: + blend_h += flo.blend_hue + blend_s += flo.saturation + blend_l += flo.luminance + blend_m += flo.blend_hue_multiplier + blend_c += 1 + + if blend_c > 0 and blend_m > 0: + avg_hue = (blend_h * 256) // blend_m + avg_sat = blend_s // blend_c + avg_lum = blend_l // blend_c + underlay_hsl16 = hsl_encode(avg_hue, avg_sat, avg_lum) + else: + underlay_hsl16 = -1 + else: + underlay_hsl16 = -1 + + # corner lighting + l_sw = intensity[tx][ty] + l_se = intensity[min(tx + 1, 64)][ty] + l_ne = intensity[min(tx + 1, 64)][min(ty + 1, 64)] + l_nw = intensity[tx][min(ty + 1, 64)] + avg_light = (l_sw + l_se + l_ne + l_nw) // 4 + + if oid > 0: + # overlay tile + oflo = overlays.get(oid - 1) + if oflo and oflo.rgb == 0xFF00FF: + continue # void tile — skip geometry so objects show through + elif oflo and oflo.texture >= 0 and tex_colors: + # textured overlay: use texture average color (water, dirt, stone) + tex_hsl = tex_colors.get(oflo.texture) + if tex_hsl is not None: + lit = apply_light(tex_hsl, avg_light) + tile_rgb = hsl16_to_rgb(lit) + elif underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + elif oflo and oflo.texture < 0: + overlay_hsl16 = hsl_encode(oflo.hue, oflo.saturation, oflo.lightness) + lit = apply_light(overlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + else: + # no overlay def — use underlay + if underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + else: + # pure underlay tile + if underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + + # emit 2 triangles (SW, SE, NE) and (SW, NE, NW) + # world position: tile (tx, ty) in region (rx, ry) + wx = float(base_wx + tx) + wz = float(base_wy + ty) + + # OSRS heights are negative (higher = more negative) + # negate so mountains go up, scale by 1/128 to get tile units + scale_h = -1.0 / 128.0 + + y_sw = float(h_sw) * scale_h + y_se = float(h_se) * scale_h + y_ne = float(h_ne) * scale_h + y_nw = float(h_nw) * scale_h + + r, g, b = tile_rgb + + # negate Z so OSRS north (+Y) maps to -Z (correct for right-handed coords) + nz = -wz + + # triangle 1: SW, SE, NE (with negated Z, gives upward normals) + verts.extend([wx, y_sw, nz, wx + 1, y_se, nz, wx + 1, y_ne, nz - 1]) + colors.extend([r, g, b, 255] * 3) + + # triangle 2: SW, NE, NW + verts.extend([wx, y_sw, nz, wx + 1, y_ne, nz - 1, wx, y_nw, nz - 1]) + colors.extend([r, g, b, 255] * 3) + + else: + # empty tile (no underlay or overlay) — skip entirely + pass + + return verts, colors + + +# --- binary output --- + +TERR_MAGIC = 0x54455252 # "TERR" + + +def build_heightmap( + regions: dict[tuple[int, int], RegionTerrain], + target_plane: int = 0, +) -> tuple[int, int, int, int, list[float]]: + """Build a flat heightmap grid from parsed regions. + + Returns (min_x, min_y, width, height, heights[width*height]). + Heights are in terrain units (OSRS height / 128.0). + """ + min_rx = min(rx for rx, _ in regions.keys()) + max_rx = max(rx for rx, _ in regions.keys()) + min_ry = min(ry for _, ry in regions.keys()) + max_ry = max(ry for _, ry in regions.keys()) + + min_wx = min_rx * 64 + min_wy = min_ry * 64 + width = (max_rx - min_rx + 1) * 64 + height = (max_ry - min_ry + 1) * 64 + + # default to 0 (flat) for uncovered tiles + hmap = [0.0] * (width * height) + scale_h = -1.0 / 128.0 + + for (rx, ry), rt in regions.items(): + base_x = rx * 64 - min_wx + base_y = ry * 64 - min_wy + for tx in range(64): + for ty in range(64): + idx = (base_x + tx) + (base_y + ty) * width + hmap[idx] = float(rt.heights[target_plane][tx][ty]) * scale_h + + return min_wx, min_wy, width, height, hmap + + +def write_terrain_binary( + output_path: Path, + verts: list[float], + colors: list[int], + regions: dict[tuple[int, int], RegionTerrain], + heightmap: tuple[int, int, int, int, list[float]], +) -> None: + """Write terrain mesh to binary .terrain file. + + Format: + magic: uint32 "TERR" + vertex_count: uint32 + region_count: uint32 + min_world_x: int32 (for coordinate offset) + min_world_y: int32 + vertices: float32[vertex_count * 3] (x, y, z) + colors: uint8[vertex_count * 4] (r, g, b, a) + heightmap_min_x: int32 + heightmap_min_y: int32 + heightmap_width: uint32 + heightmap_height: uint32 + heightmap: float32[width * height] + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + vert_count = len(verts) // 3 + + # find min world coords for centering + min_wx = min(rx * 64 for rx, _ in regions.keys()) + min_wy = min(ry * 64 for _, ry in regions.keys()) + + hm_min_x, hm_min_y, hm_w, hm_h, hm_data = heightmap + + with open(output_path, "wb") as f: + f.write(struct.pack(" None: + """Export terrain from 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 + + +# --- binary reading helpers --- + + +def read_u8(buf: io.BytesIO) -> int: + """Read unsigned 8-bit integer.""" + b = buf.read(1) + if not b: + return 0 + return b[0] + + +def read_u16(buf: io.BytesIO) -> int: + """Read big-endian unsigned 16-bit integer.""" + b = buf.read(2) + if len(b) < 2: + return 0 + return struct.unpack(">H", b)[0] + + +def read_u32(buf: io.BytesIO) -> int: + """Read big-endian unsigned 32-bit integer.""" + b = buf.read(4) + if len(b) < 4: + return 0 + return struct.unpack(">I", b)[0] + + +def read_i32(buf: io.BytesIO) -> int: + """Read big-endian signed 32-bit integer.""" + b = buf.read(4) + if len(b) < 4: + return 0 + return struct.unpack(">i", b)[0] + + +def read_u24(buf: io.BytesIO) -> int: + """Read big-endian unsigned 24-bit (medium) integer.""" + b = buf.read(3) + if len(b) < 3: + return 0 + return (b[0] << 16) | (b[1] << 8) | b[2] + + +def read_smart(buf: io.BytesIO) -> int: + """Read RS2 smart value (1 or 2 bytes, unsigned). + + If first byte < 128: returns byte value. + If first byte >= 128: reads 2 bytes, returns value - 32768. + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + if peek[0] < 128: + return peek[0] + buf.seek(pos) + return read_u16(buf) - 32768 + + +def read_big_smart(buf: io.BytesIO) -> int: + """Read RS2 big smart (2 or 4 bytes, unsigned). + + If first byte < 128 (sign bit clear): reads u16. + Otherwise: reads i32 & 0x7FFFFFFF. + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + buf.seek(pos) + if peek[0] < 128: + return read_u16(buf) + return read_i32(buf) & 0x7FFFFFFF + + +def read_string(buf: io.BytesIO) -> str: + """Read null-terminated string.""" + chars = [] + while True: + b = buf.read(1) + if not b or b[0] == 0: + break + chars.append(chr(b[0])) + return "".join(chars) + + +# --- container decompression --- + +COMPRESSION_NONE = 0 +COMPRESSION_BZIP2 = 1 +COMPRESSION_GZIP = 2 + + +def decompress_container(data: bytes) -> bytes: + """Decompress an RS2 container (.dat file). + + Container format: + byte 0: compression type (0=none, 1=bzip2, 2=gzip) + bytes 1-4: compressed length (big-endian u32) + if compressed: bytes 5-8: decompressed length (big-endian u32) + remaining: payload + + For bzip2: RS2 strips the 'BZ' magic header. We prepend 'BZh1' before + feeding to the standard bz2 decompressor. + """ + if len(data) < 5: + msg = f"container too small: {len(data)} bytes" + raise ValueError(msg) + + compression = data[0] + compressed_len = struct.unpack(">I", data[1:5])[0] + + if compression == COMPRESSION_NONE: + return data[5 : 5 + compressed_len] + + if len(data) < 9: + msg = f"compressed container too small: {len(data)} bytes" + raise ValueError(msg) + + decompressed_len = struct.unpack(">I", data[5:9])[0] + payload = data[9 : 9 + compressed_len] + + if compression == COMPRESSION_BZIP2: + # RS2 strips the full 4-byte bzip2 header ('BZh' + block size digit). + # the payload starts directly at the block magic (31 41 59 26...). + # prepend 'BZh1' to reconstruct a valid bzip2 stream. + bz2_data = b"BZh1" + payload + result = bz2.decompress(bz2_data) + elif compression == COMPRESSION_GZIP: + result = gzip.decompress(payload) + else: + msg = f"unknown compression type: {compression}" + raise ValueError(msg) + + if len(result) != decompressed_len: + msg = ( + f"decompressed size mismatch: expected {decompressed_len}, " + f"got {len(result)}" + ) + raise ValueError(msg) + + return result + + +# --- index manifest (RS2 reference table) --- + + +@dataclass +class IndexManifest: + """Parsed RS2 reference table for a cache index. + + Contains metadata about all groups in the index: which file IDs each + group contains, CRCs, revisions, and optional name hashes. + """ + + protocol: int = 0 + revision: int = 0 + has_names: bool = False + group_ids: list[int] = field(default_factory=list) + group_name_hashes: dict[int, int] = field(default_factory=dict) + group_crcs: dict[int, int] = field(default_factory=dict) + group_revisions: dict[int, int] = field(default_factory=dict) + group_file_ids: dict[int, list[int]] = field(default_factory=dict) + group_file_name_hashes: dict[int, dict[int, int]] = field(default_factory=dict) + + +def parse_index_manifest(data: bytes) -> IndexManifest: + """Parse an RS2 reference table from decompressed container data. + + Reference table format (protocol 5-7): + u8 protocol + if protocol >= 6: u32 revision + u8 flags: + bit 0 = has names + bit 1 = has whirlpool digests + bit 2 = has lengths (compressed + decompressed sizes per group) + bit 3 = has uncompressed CRC32s + group count: big_smart if protocol >= 7, else u16 + group IDs: delta-encoded (big_smart if >= 7, else u16) + if has_names: i32[group_count] name hashes + u32[group_count] CRC32s + if has_whirlpool: 64 bytes per group (whirlpool digests, skipped) + if has_lengths: u32[group_count] compressed sizes, u32[group_count] decompressed sizes + if has_uncompressed_crc: u32[group_count] uncompressed CRC32s + u32[group_count] revisions + file counts per group: big_smart if >= 7, else u16 + file IDs per group: delta-encoded (same width) + if has_names: i32[total_files] file name hashes + """ + buf = io.BytesIO(data) + manifest = IndexManifest() + + manifest.protocol = read_u8(buf) + if manifest.protocol < 5 or manifest.protocol > 7: + msg = f"unsupported reference table protocol: {manifest.protocol}" + raise ValueError(msg) + + if manifest.protocol >= 6: + manifest.revision = read_u32(buf) + + flags = read_u8(buf) + manifest.has_names = bool(flags & 0x01) + has_whirlpool = bool(flags & 0x02) + has_lengths = bool(flags & 0x04) + has_uncompressed_crc = bool(flags & 0x08) + + # group count + if manifest.protocol >= 7: + group_count = read_big_smart(buf) + else: + group_count = read_u16(buf) + + # group IDs (delta-encoded) + manifest.group_ids = [] + accumulator = 0 + for _ in range(group_count): + if manifest.protocol >= 7: + delta = read_big_smart(buf) + else: + delta = read_u16(buf) + accumulator += delta + manifest.group_ids.append(accumulator) + + # name hashes + if manifest.has_names: + for gid in manifest.group_ids: + manifest.group_name_hashes[gid] = read_i32(buf) + + # CRC32s + for gid in manifest.group_ids: + manifest.group_crcs[gid] = read_u32(buf) + + # whirlpool digests (64 bytes each, skip) + if has_whirlpool: + buf.read(64 * group_count) + + # compressed + decompressed sizes per group (skip, metadata only) + if has_lengths: + buf.read(4 * group_count) # compressed sizes + buf.read(4 * group_count) # decompressed sizes + + # uncompressed CRC32s (skip) + if has_uncompressed_crc: + buf.read(4 * group_count) + + # revisions + for gid in manifest.group_ids: + manifest.group_revisions[gid] = read_u32(buf) + + # file counts per group + file_counts: dict[int, int] = {} + for gid in manifest.group_ids: + if manifest.protocol >= 7: + file_counts[gid] = read_big_smart(buf) + else: + file_counts[gid] = read_u16(buf) + + # file IDs per group (delta-encoded) + for gid in manifest.group_ids: + count = file_counts[gid] + file_ids = [] + acc = 0 + for _ in range(count): + if manifest.protocol >= 7: + delta = read_big_smart(buf) + else: + delta = read_u16(buf) + acc += delta + file_ids.append(acc) + manifest.group_file_ids[gid] = file_ids + + # file name hashes + if manifest.has_names: + for gid in manifest.group_ids: + fids = manifest.group_file_ids[gid] + name_map: dict[int, int] = {} + for fid in fids: + name_map[fid] = read_i32(buf) + manifest.group_file_name_hashes[gid] = name_map + + return manifest + + +# --- group splitting (multi-file groups) --- + + +def split_group(data: bytes, file_ids: list[int]) -> dict[int, bytes]: + """Split a decompressed group into individual files. + + If the group has only one file, the entire data IS that file. + + Multi-file groups use a trailer format: + - last byte of data = chunk_count + - before that: chunk_count * file_count * 4 bytes of delta-encoded + sizes (big-endian i32) + - data region: chunks concatenated, each chunk has one segment per file + - final file bytes = concatenation of that file's segment from each chunk + + Size encoding (matching RuneLite GroupDecompressor): for each chunk, iterate + through files reading i32 deltas. A running accumulator (reset per chunk) + tracks the current size. The accumulated value IS the segment size for that + file in that chunk. + """ + if len(file_ids) == 1: + return {file_ids[0]: data} + + file_count = len(file_ids) + + # last byte = chunk count + chunk_count = data[-1] + if chunk_count < 1: + msg = f"invalid chunk count: {chunk_count}" + raise ValueError(msg) + + # read the size table from the trailer + # size table: chunk_count * file_count * 4 bytes, right before the last byte + size_table_len = chunk_count * file_count * 4 + size_table_start = len(data) - 1 - size_table_len + if size_table_start < 0: + msg = f"group data too small for {chunk_count} chunks x {file_count} files" + raise ValueError(msg) + + size_buf = io.BytesIO(data[size_table_start : len(data) - 1]) + + # parse delta-encoded sizes: for each chunk, accumulate deltas across files. + # the accumulator resets to 0 at the start of each chunk. the accumulated + # value is the segment size for that file in that chunk. + chunk_sizes: list[list[int]] = [] + for _chunk in range(chunk_count): + sizes = [] + chunk_size = 0 + for _f in range(file_count): + delta = struct.unpack(">i", size_buf.read(4))[0] + chunk_size += delta + sizes.append(chunk_size) + chunk_sizes.append(sizes) + + # extract file data by concatenating each file's segment from each chunk + file_buffers: dict[int, bytearray] = {fid: bytearray() for fid in file_ids} + offset = 0 + for chunk_idx in range(chunk_count): + for f_idx, fid in enumerate(file_ids): + size = chunk_sizes[chunk_idx][f_idx] + file_buffers[fid].extend(data[offset : offset + size]) + offset += size + + return {fid: bytes(buf) for fid, buf in file_buffers.items()} + + +# --- main reader class --- + + +class ModernCacheReader: + """Read OSRS cache in OpenRS2 flat file format. + + Expects a directory with numbered subdirectories (0/, 1/, 2/, ..., 255/) + each containing .dat files named by group ID. + """ + + def __init__(self, cache_dir: str | Path) -> None: + """Initialize with path to cache root directory.""" + self.cache_dir = Path(cache_dir) + if not self.cache_dir.is_dir(): + msg = f"cache directory not found: {self.cache_dir}" + raise FileNotFoundError(msg) + self._manifest_cache: dict[int, IndexManifest] = {} + + def _read_raw(self, index_id: int, group_id: int) -> bytes | None: + """Read raw container bytes from disk.""" + path = self.cache_dir / str(index_id) / f"{group_id}.dat" + if not path.exists(): + return None + return path.read_bytes() + + def read_container(self, index_id: int, group_id: int) -> bytes | None: + """Read and decompress a container from the cache.""" + raw = self._read_raw(index_id, group_id) + if raw is None: + return None + return decompress_container(raw) + + def read_index_manifest(self, index_id: int) -> IndexManifest: + """Read and parse the reference table (manifest) for an index. + + The manifest for index N is stored at 255/N.dat. + """ + if index_id in self._manifest_cache: + return self._manifest_cache[index_id] + + data = self.read_container(255, index_id) + if data is None: + msg = f"manifest not found for index {index_id}" + raise FileNotFoundError(msg) + + manifest = parse_index_manifest(data) + self._manifest_cache[index_id] = manifest + return manifest + + def read_group(self, index_id: int, group_id: int) -> dict[int, bytes]: + """Read a group and split into individual file entries. + + Returns dict mapping file_id -> decompressed file bytes. + """ + manifest = self.read_index_manifest(index_id) + + if group_id not in manifest.group_file_ids: + msg = f"group {group_id} not in index {index_id} manifest" + raise KeyError(msg) + + data = self.read_container(index_id, group_id) + if data is None: + msg = f"group data not found: index={index_id} group={group_id}" + raise FileNotFoundError(msg) + + file_ids = manifest.group_file_ids[group_id] + return split_group(data, file_ids) + + def read_config_entry(self, group_id: int, entry_id: int) -> bytes: + """Read a single config entry from index 2. + + Convenience method: configs live in index 2, where group_id selects + the config type (6=obj/items, 12=seq/animations, 13=spotanim) and + entry_id selects the specific entry. + """ + files = self.read_group(2, group_id) + if entry_id not in files: + msg = f"config entry {entry_id} not found in group {group_id}" + raise KeyError(msg) + return files[entry_id] + + +# --- sequence parsing (modern OSRS cache format, rev226+) --- + + +def _read_frame_sound_rev226(buf: io.BytesIO) -> None: + """Skip a frame sound entry (rev226 format with rev220FrameSounds). + + Format: u16 id, u8 weight, u8 loops, u8 location, u8 retain. + """ + read_u16(buf) # sound id + read_u8(buf) # weight + read_u8(buf) # loops + read_u8(buf) # location + read_u8(buf) # retain + + +@dataclass +class SequenceDef: + """Animation sequence definition parsed from modern OSRS cache. + + Field names match RuneLite's SequenceDefinition. Opcode mapping is for + the modern (rev226+) format used by current OSRS. + """ + + seq_id: int = 0 + frame_count: int = 0 + frame_delays: list[int] = field(default_factory=list) + primary_frame_ids: list[int] = field(default_factory=list) + frame_step: int = -1 + interleave_order: list[int] = field(default_factory=list) + stretches: bool = False + forced_priority: int = 5 + left_hand_item: int = -1 + right_hand_item: int = -1 + max_loops: int = 99 + precedence_animating: int = -1 + priority: int = -1 + reply_mode: int = -1 + + +def parse_sequence(seq_id: int, data: bytes) -> SequenceDef: + """Parse a single sequence from opcode stream (modern OSRS, rev226+). + + Opcode layout from RuneLite's SequenceLoader.decodeValues with rev226=true + and rev220FrameSounds=true (modern OSRS cache revisions are well above + both thresholds of 1141 and 1268). + + Opcode map (rev226=true): + 1: frame data (delays, file IDs, group IDs) + 2: frame step + 3: interleave order + 4: stretches flag + 5: forced priority + 6: left hand item + 7: right hand item + 8: max loops + 9: precedence animating + 10: priority + 11: reply mode + 12: chat frame IDs + 13: animMayaID (i32) — rev226 remaps this from old opcode 14 + 14: frame sounds (u16 count) — rev226 remaps this from old opcode 15 + 15: animMayaStart/End — rev226 remaps this from old opcode 16 + 16: vertical offset (i8) + 17: animMayaMasks + 18: debug name (string) + 19: sounds cross world view flag + """ + seq = SequenceDef(seq_id=seq_id) + buf = io.BytesIO(data) + + while True: + opcode = read_u8(buf) + if opcode == 0: + break + elif opcode == 1: + seq.frame_count = read_u16(buf) + seq.frame_delays = [read_u16(buf) for _ in range(seq.frame_count)] + file_ids = [read_u16(buf) for _ in range(seq.frame_count)] + group_ids = [read_u16(buf) for _ in range(seq.frame_count)] + seq.primary_frame_ids = [ + (group_ids[i] << 16) | file_ids[i] for i in range(seq.frame_count) + ] + elif opcode == 2: + seq.frame_step = read_u16(buf) + elif opcode == 3: + n = read_u8(buf) + seq.interleave_order = [read_u8(buf) for _ in range(n)] + elif opcode == 4: + seq.stretches = True + elif opcode == 5: + seq.forced_priority = read_u8(buf) + elif opcode == 6: + seq.left_hand_item = read_u16(buf) + elif opcode == 7: + seq.right_hand_item = read_u16(buf) + elif opcode == 8: + seq.max_loops = read_u8(buf) + elif opcode == 9: + seq.precedence_animating = read_u8(buf) + elif opcode == 10: + seq.priority = read_u8(buf) + elif opcode == 11: + seq.reply_mode = read_u8(buf) + elif opcode == 12: + # chat frame IDs: u8 count, then u16[count] + u16[count] (low + high) + n = read_u8(buf) + for _ in range(n): + read_u16(buf) + for _ in range(n): + read_u16(buf) + elif opcode == 13: + # rev226: animMayaID (remapped from old opcode 14) + read_i32(buf) + elif opcode == 14: + # rev226: frame sounds (remapped from old opcode 15) + n = read_u16(buf) + for _ in range(n): + read_u16(buf) # frame index + _read_frame_sound_rev226(buf) + elif opcode == 15: + # rev226: animMayaStart + animMayaEnd (remapped from old opcode 16) + read_u16(buf) + read_u16(buf) + elif opcode == 16: + # vertical offset (signed byte) + read_u8(buf) + elif opcode == 17: + # animMayaMasks: u8 count, then u8[count] mask indices + n = read_u8(buf) + for _ in range(n): + read_u8(buf) + elif opcode == 18: + # debug name (null-terminated string) + read_string(buf) + elif opcode == 19: + pass # soundsCrossWorldView = true + else: + print( + f" warning: unknown seq opcode {opcode} for id {seq_id}", + file=sys.stderr, + ) + break + + if seq.frame_count == 0: + seq.frame_count = 1 + seq.primary_frame_ids = [-1] + seq.frame_delays = [-1] + + return seq + + +# --- test --- + + +def main() -> None: + """Test the modern cache reader against a local cache.""" + cache_path = "../reference/osrs-cache-modern/" + print(f"opening cache at {cache_path}") + + reader = ModernCacheReader(cache_path) + + # parse index 2 manifest (configs) + print("\nparsing index 2 (configs) manifest...") + manifest = reader.read_index_manifest(2) + print(f" protocol: {manifest.protocol}") + print(f" revision: {manifest.revision}") + print(f" has_names: {manifest.has_names}") + print(f" total groups: {len(manifest.group_ids)}") + + # report key config groups + key_groups = {6: "obj/items", 12: "seq/animations", 13: "spotanim"} + for gid, name in key_groups.items(): + if gid in manifest.group_file_ids: + file_count = len(manifest.group_file_ids[gid]) + print(f" group {gid} ({name}): {file_count} entries") + else: + print(f" group {gid} ({name}): NOT FOUND") + + # read and parse sequences from group 12 + print("\nreading seq group 12...") + seq_files = reader.read_group(2, 12) + print(f" loaded {len(seq_files)} sequence entries") + + # parse known sequences + test_seqs = {808: "idle", 1979: "cast_barrage"} + for seq_id, name in test_seqs.items(): + if seq_id not in seq_files: + print(f" seq {seq_id} ({name}): NOT FOUND in group") + continue + seq = parse_sequence(seq_id, seq_files[seq_id]) + print( + f" seq {seq_id} ({name}): " + f"frames={seq.frame_count}, " + f"priority={seq.priority}, " + f"forced_priority={seq.forced_priority}, " + f"precedence_animating={seq.precedence_animating}, " + f"delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}" + ) + + # also check a spotanim entry from group 13 + print("\nreading spotanim group 13...") + spotanim_files = reader.read_group(2, 13) + print(f" loaded {len(spotanim_files)} spotanim entries") + + # check a few obj entries from group 6 + print("\nreading obj group 6 (sampling first 3 entries)...") + obj_files = reader.read_group(2, 6) + print(f" loaded {len(obj_files)} obj/item entries") + sample_ids = sorted(obj_files.keys())[:3] + for oid in sample_ids: + print(f" obj {oid}: {len(obj_files[oid])} bytes") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/tests/test_bolt_procs.c b/ocean/osrs/tests/test_bolt_procs.c new file mode 100644 index 0000000000..a51d703d01 --- /dev/null +++ b/ocean/osrs/tests/test_bolt_procs.c @@ -0,0 +1,379 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness */ +/* ======================================================================== */ + +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) + +/* ======================================================================== */ +/* 1. diamond proc chance: ~11% over 10000 trials */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 2. diamond effect max: floor(max_hit * 115/100) normal, 126/100 ZCB */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 3. diamond ZCB spec: guaranteed proc on accurate hit, enhanced effectMax */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 4. diamond miss: no proc on miss (unless ZCB spec) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 5. opal bonus damage: floor(99/10)=9 normal, floor(99/9)=11 ZCB */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 6. opal works on miss: proc can trigger when hit_accurate=0 */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 7. opal proc chance: ~5.5% over many trials */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 8. ruby HP-based damage: 500HP → normal=100, ZCB=110 */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 9. ruby cap: 1000HP capped at 100 (normal) / 110 (ZCB) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 10. ruby miss: no proc on miss */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 11. ruby ZCB spec: guaranteed proc + enhanced */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 12. unknown bolt: dragon arrows → no proc */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* 13. edge cases: max_hit=0, ranged_level=1, target_hp=1 */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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..f6868ae6ea --- /dev/null +++ b/ocean/osrs/tests/test_collision.c @@ -0,0 +1,422 @@ +/** + * @file test_collision.c + * @brief Tests for the collision system and BFS pathfinder + * + * Validates that collision flags block movement correctly, that the pathfinder + * routes around obstacles, and that NULL collision map preserves flat arena behavior. + * + * Compile: cc -O2 -o test_collision test_collision.c -lm + * Run: ./test_collision + */ + +#include +#include +#include +#include "osrs_collision.h" +#include "osrs_pathfinding.h" + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) static void name(void) +#define RUN(name) do { \ + printf(" %-50s", #name); \ + name(); \ + printf("PASS\n"); \ + tests_passed++; \ +} while(0) + +#define ASSERT(cond) do { \ + if (!(cond)) { \ + printf("FAIL at %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + tests_failed++; \ + return; \ + } \ +} while(0) + +/* ========================================================================= + * collision map basics + * ========================================================================= */ + +TEST(test_null_map_all_traversable) { + ASSERT(collision_traversable_north(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south(NULL, 0, 100, 100)); + ASSERT(collision_traversable_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_west(NULL, 0, 100, 100)); + ASSERT(collision_traversable_north_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_north_west(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south_west(NULL, 0, 100, 100)); + ASSERT(collision_tile_walkable(NULL, 0, 100, 100)); + ASSERT(collision_traversable_step(NULL, 0, 100, 100, 1, 1)); +} + +TEST(test_empty_map_all_traversable) { + CollisionMap* map = collision_map_create(); + ASSERT(collision_traversable_north(map, 0, 100, 100)); + ASSERT(collision_traversable_south(map, 0, 100, 100)); + ASSERT(collision_tile_walkable(map, 0, 100, 100)); + collision_map_free(map); +} + +TEST(test_region_create_and_lookup) { + CollisionMap* map = collision_map_create(); + ASSERT(map->count == 0); + + /* setting a flag auto-creates the region */ + collision_set_flag(map, 0, 3200, 3200, COLLISION_BLOCKED); + ASSERT(map->count == 1); + + int flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == COLLISION_BLOCKED); + + /* different tile in same region doesn't add another region */ + collision_set_flag(map, 0, 3201, 3201, COLLISION_WALL_NORTH); + ASSERT(map->count == 1); + + /* tile in a different region increments count */ + collision_set_flag(map, 0, 3264, 3200, COLLISION_BLOCKED); + ASSERT(map->count == 2); + + collision_map_free(map); +} + +TEST(test_flag_set_and_unset) { + CollisionMap* map = collision_map_create(); + collision_set_flag(map, 0, 3200, 3200, COLLISION_WALL_NORTH | COLLISION_WALL_SOUTH); + int flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == (COLLISION_WALL_NORTH | COLLISION_WALL_SOUTH)); + + collision_unset_flag(map, 0, 3200, 3200, COLLISION_WALL_NORTH); + flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == COLLISION_WALL_SOUTH); + + collision_map_free(map); +} + +/* ========================================================================= + * directional traversal checks + * ========================================================================= */ + +TEST(test_blocked_tile_not_traversable) { + CollisionMap* map = collision_map_create(); + + /* block tile (100, 101) — north of (100, 100) */ + collision_mark_blocked(map, 0, 100, 101); + + /* can't walk north from (100, 100) because (100, 101) is blocked */ + ASSERT(!collision_traversable_north(map, 0, 100, 100)); + + /* can still walk south, east, west from (100, 100) */ + ASSERT(collision_traversable_south(map, 0, 100, 100)); + ASSERT(collision_traversable_east(map, 0, 100, 100)); + ASSERT(collision_traversable_west(map, 0, 100, 100)); + + collision_map_free(map); +} + +TEST(test_wall_blocks_direction) { + CollisionMap* map = collision_map_create(); + + /* place a south-facing wall on tile (100, 101). + * this means: entering (100, 101) from the south is blocked. */ + collision_set_flag(map, 0, 100, 101, COLLISION_WALL_SOUTH); + + /* walking north from (100, 100) to (100, 101) is blocked (wall on south side of dest) */ + ASSERT(!collision_traversable_north(map, 0, 100, 100)); + + /* walking south from (100, 102) to (100, 101) is fine — no north wall on dest */ + ASSERT(collision_traversable_south(map, 0, 100, 102)); + + collision_map_free(map); +} + +TEST(test_diagonal_blocked_by_intermediate) { + CollisionMap* map = collision_map_create(); + + /* block the east intermediate tile (101, 100) — this should block NE diagonal */ + collision_mark_blocked(map, 0, 101, 100); + + /* NE from (100, 100): checks (101, 101) + (101, 100) + (100, 101) */ + /* (101, 100) is blocked, so diagonal should fail */ + ASSERT(!collision_traversable_north_east(map, 0, 100, 100)); + + /* NW from (100, 100) should still work (different intermediate tiles) */ + ASSERT(collision_traversable_north_west(map, 0, 100, 100)); + + collision_map_free(map); +} + +TEST(test_multi_tile_occupant) { + CollisionMap* map = collision_map_create(); + + /* place a 2x2 object at (100, 100) */ + collision_mark_occupant(map, 0, 100, 100, 2, 2, 0); + + ASSERT(!collision_tile_walkable(map, 0, 100, 100)); + ASSERT(!collision_tile_walkable(map, 0, 101, 100)); + ASSERT(!collision_tile_walkable(map, 0, 100, 101)); + ASSERT(!collision_tile_walkable(map, 0, 101, 101)); + ASSERT(collision_tile_walkable(map, 0, 102, 100)); /* outside the object */ + + collision_map_free(map); +} + +/* ========================================================================= + * binary save/load + * ========================================================================= */ + +TEST(test_save_and_load) { + CollisionMap* map = collision_map_create(); + collision_mark_blocked(map, 0, 3200, 3520); + collision_set_flag(map, 0, 3201, 3520, COLLISION_WALL_NORTH | COLLISION_WALL_EAST); + collision_mark_blocked(map, 0, 3264, 3520); /* different region */ + + const char* path = "/tmp/test_collision.cmap"; + int rc = collision_map_save(map, path); + ASSERT(rc == 0); + + CollisionMap* loaded = collision_map_load(path); + ASSERT(loaded != NULL); + ASSERT(loaded->count == 2); + + ASSERT(collision_get_flags(loaded, 0, 3200, 3520) == COLLISION_BLOCKED); + ASSERT(collision_get_flags(loaded, 0, 3201, 3520) == (COLLISION_WALL_NORTH | COLLISION_WALL_EAST)); + ASSERT(collision_get_flags(loaded, 0, 3264, 3520) == COLLISION_BLOCKED); + ASSERT(collision_get_flags(loaded, 0, 3202, 3520) == COLLISION_NONE); /* untouched tile */ + + collision_map_free(map); + collision_map_free(loaded); + remove(path); +} + +/* ========================================================================= + * pathfinding + * ========================================================================= */ + +TEST(test_pathfind_already_at_dest) { + PathResult r = pathfind_step(NULL, 0, 100, 100, 100, 100); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 0 && r.next_dy == 0); +} + +TEST(test_pathfind_straight_line_no_obstacles) { + /* no collision map — straight path east */ + PathResult r = pathfind_step(NULL, 0, 100, 100, 105, 100); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 1); + ASSERT(r.next_dy == 0); +} + +TEST(test_pathfind_diagonal_no_obstacles) { + PathResult r = pathfind_step(NULL, 0, 100, 100, 105, 105); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 1); + ASSERT(r.next_dy == 1); +} + +TEST(test_pathfind_around_wall) { + CollisionMap* map = collision_map_create(); + + /* create a vertical wall of blocked tiles at x=102, y=98..102 + * player at (100, 100) wants to reach (105, 100) + * direct east path is blocked — must go around */ + for (int y = 98; y <= 102; y++) { + collision_mark_blocked(map, 0, 102, y); + } + + PathResult r = pathfind_step(map, 0, 100, 100, 105, 100); + ASSERT(r.found == 1); + + /* first step should NOT be straight east into the wall. + * BFS will route north or south to go around. */ + /* it could be east (toward x=101 which is open) — that's fine */ + /* but it should never step to (102, any) since those are blocked */ + ASSERT(!(r.next_dx == 1 && r.next_dy == 0) + || (100 + r.next_dx != 102)); + + collision_map_free(map); +} + +TEST(test_pathfind_completely_blocked) { + CollisionMap* map = collision_map_create(); + + /* surround destination (105, 100) with blocked tiles */ + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) continue; + collision_mark_blocked(map, 0, 105 + dx, 100 + dy); + } + } + /* also block the dest tile itself */ + collision_mark_blocked(map, 0, 105, 100); + + PathResult r = pathfind_step(map, 0, 100, 100, 105, 100); + /* should use fallback — find closest reachable tile */ + ASSERT(r.found == 1); + /* the actual destination should differ from requested since it's blocked */ + ASSERT(r.dest_x != 105 || r.dest_y != 100); + + collision_map_free(map); +} + +TEST(test_pathfind_respects_wall_flags) { + CollisionMap* map = collision_map_create(); + + /* place a south wall on tile (101, 101) — blocks entry from south. + * player at (101, 100) going north to (101, 101) should be blocked. */ + collision_set_flag(map, 0, 101, 101, COLLISION_WALL_SOUTH); + + /* pathfind from (100, 100) to (102, 102) — going NE */ + PathResult r = pathfind_step(map, 0, 100, 100, 102, 102); + ASSERT(r.found == 1); + + /* the BFS should find a path (there are many routes around one wall tile) */ + /* just verify it found something valid */ + ASSERT(r.next_dx >= -1 && r.next_dx <= 1); + ASSERT(r.next_dy >= -1 && r.next_dy <= 1); + + collision_map_free(map); +} + +/* ========================================================================= + * integration: step_toward_destination with collision + * + * we can't include the full osrs_pvp.h here (too many deps), so we + * replicate the core logic of step_toward_destination for testing. + * ========================================================================= */ + +typedef struct { int x, y, dest_x, dest_y; } TestPlayer; + +static int test_step_toward(TestPlayer* p, const CollisionMap* cmap) { + int dx = p->dest_x - p->x; + int dy = p->dest_y - p->y; + if (dx == 0 && dy == 0) return 0; + + int step_x = (dx > 0) ? 1 : (dx < 0 ? -1 : 0); + int step_y = (dy > 0) ? 1 : (dy < 0 ? -1 : 0); + + if (step_x != 0 && step_y != 0) { + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; p->y += step_y; return 1; + } + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, 0)) { + p->x += step_x; return 1; + } + if (collision_traversable_step(cmap, 0, p->x, p->y, 0, step_y)) { + p->y += step_y; return 1; + } + return 0; + } + + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; p->y += step_y; return 1; + } + return 0; +} + +TEST(test_step_blocked_by_wall) { + CollisionMap* map = collision_map_create(); + + /* block tile east of player */ + collision_mark_blocked(map, 0, 101, 100); + + TestPlayer p = {100, 100, 105, 100}; + int moved = test_step_toward(&p, map); + + /* east is blocked, no y component, should not move */ + ASSERT(!moved); + ASSERT(p.x == 100 && p.y == 100); + + collision_map_free(map); +} + +TEST(test_step_diagonal_falls_back_to_cardinal) { + CollisionMap* map = collision_map_create(); + + /* block the diagonal NE tile (101, 101) */ + collision_mark_blocked(map, 0, 101, 101); + + TestPlayer p = {100, 100, 105, 105}; + int moved = test_step_toward(&p, map); + + /* diagonal NE blocked, should fall back to east (101, 100) or north (100, 101) */ + ASSERT(moved); + /* should have moved in one cardinal direction only */ + ASSERT((p.x == 101 && p.y == 100) || (p.x == 100 && p.y == 101)); + + collision_map_free(map); +} + +TEST(test_step_no_collision_map) { + TestPlayer p = {100, 100, 105, 105}; + int moved = test_step_toward(&p, NULL); + ASSERT(moved); + /* diagonal step with no obstacles */ + ASSERT(p.x == 101 && p.y == 101); +} + +/* ========================================================================= + * wilderness coordinates test — verify the region hash works for real coords + * ========================================================================= */ + +TEST(test_wilderness_coordinates) { + CollisionMap* map = collision_map_create(); + + /* wilderness is roughly x=3008-3390, y=3520-3968. + * our arena center is around (3222, 3544). + * verify collision works at real coords. */ + int arena_x = 3222; + int arena_y = 3544; + + collision_mark_blocked(map, 0, arena_x + 5, arena_y); + ASSERT(!collision_tile_walkable(map, 0, arena_x + 5, arena_y)); + ASSERT(collision_tile_walkable(map, 0, arena_x + 4, arena_y)); + ASSERT(!collision_traversable_east(map, 0, arena_x + 4, arena_y)); + + collision_map_free(map); +} + +/* ========================================================================= + * main + * ========================================================================= */ + +int main(void) { + printf("collision system tests\n"); + printf("======================\n\n"); + + printf("collision map basics:\n"); + RUN(test_null_map_all_traversable); + RUN(test_empty_map_all_traversable); + RUN(test_region_create_and_lookup); + RUN(test_flag_set_and_unset); + + printf("\ndirectional traversal:\n"); + RUN(test_blocked_tile_not_traversable); + RUN(test_wall_blocks_direction); + RUN(test_diagonal_blocked_by_intermediate); + RUN(test_multi_tile_occupant); + + printf("\nbinary I/O:\n"); + RUN(test_save_and_load); + + printf("\npathfinding:\n"); + RUN(test_pathfind_already_at_dest); + RUN(test_pathfind_straight_line_no_obstacles); + RUN(test_pathfind_diagonal_no_obstacles); + RUN(test_pathfind_around_wall); + RUN(test_pathfind_completely_blocked); + RUN(test_pathfind_respects_wall_flags); + + printf("\nmovement integration:\n"); + RUN(test_step_blocked_by_wall); + RUN(test_step_diagonal_falls_back_to_cardinal); + RUN(test_step_no_collision_map); + + printf("\nwilderness coordinates:\n"); + RUN(test_wilderness_coordinates); + + printf("\n======================\n"); + printf("%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c new file mode 100644 index 0000000000..cf1bacbca6 --- /dev/null +++ b/ocean/osrs/tests/test_combat_math.c @@ -0,0 +1,1112 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness */ +/* ======================================================================== */ + +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, + ENCOUNTER_PRAYER_NONE, + 99, /* base_level */ + 3, /* style_bonus (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); + + /* eff_level = floor(99 * 1.0) + 3 + 8 = 110 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 110); + + /* 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) = 110 * 158 = 17380 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 17380); + + 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, + ENCOUNTER_PRAYER_PIETY, + 99, /* base_level */ + 3, /* style_bonus (aggressive) */ + 0, /* spell_base_damage */ + &stats + ); + + /* piety: att_mult=1.20, str_mult=1.23 */ + /* ref: PlayerVsNPCCalc.ts — Piety factorAccuracy=[120,100], factorStrength=[123,100] */ + + /* eff_att_level = floor(99 * 1.20) + 3 + 8 = 118 + 11 = 129 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 129); + + /* 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 = 129 * (94 + 64) = 129 * 158 = 20382 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 20382); +} + +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, + ENCOUNTER_PRAYER_RIGOUR, + 99, /* base_level */ + 0, /* style_bonus (rapid = 0) */ + 0, /* spell_base_damage */ + &stats + ); + + /* rigour: att_mult=1.20, str_mult=1.23 */ + /* 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, + ENCOUNTER_PRAYER_AUGURY, + 99, /* base_level */ + 0, /* style_bonus (autocast = 0) */ + 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, + ENCOUNTER_PRAYER_NONE, + 99, + 0, /* 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, + ENCOUNTER_PRAYER_RIGOUR, + 99, + 0, /* 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, ENCOUNTER_PRAYER_PIETY, + 99, 3, 0, &stats); + + /* base: eff=129, max=32 (tested above) */ + ASSERT_INT_EQ("base eff", stats.eff_level, 129); + 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, 90, 90); + + /* eff_att = floor(90 * 1.20) + 3 + 8 = 108 + 11 = 119 */ + ASSERT_INT_EQ("drained eff", stats.eff_level, 119); + + /* 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, 99, 99); + ASSERT_INT_EQ("restored eff", stats.eff_level, 129); + 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, ENCOUNTER_PRAYER_AUGURY, + 99, 0, 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, 80, 80); + /* eff = floor(80 * 1.25) + 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); + 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); + /* 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 */); + 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, ENCOUNTER_PRAYER_NONE, + 99, 0, 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 */ + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + 1, 0, 0, &stats); + + /* eff = floor(1*1.0) + 0 + 8 = 9 */ + ASSERT_INT_EQ("lv1 melee eff", stats.eff_level, 9); + + /* eff_str = 9, 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 */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, + 1, 0, 30, &stats); + + /* eff = floor(1*1.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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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..82761f9742 --- /dev/null +++ b/ocean/osrs/tests/test_damage.c @@ -0,0 +1,389 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness */ +/* ======================================================================== */ + +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) + +/* ======================================================================== */ +/* prayer reduction through pipeline */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* vengeance */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* recoil */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* smite */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* full pipeline: PvP with all effects active */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* full pipeline: PvE with veng + recoil, no smite, 100% prayer block */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* edge cases */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* osrs_has_recoil_ring helper */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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_interaction.c b/ocean/osrs/tests/test_interaction.c new file mode 100644 index 0000000000..e4060ccc2a --- /dev/null +++ b/ocean/osrs/tests/test_interaction.c @@ -0,0 +1,264 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness */ +/* ======================================================================== */ + +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) + +/* ======================================================================== */ +/* test: init */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: set */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: clear */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: interrupt — MOVE */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: interrupt — EAT */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: interrupt — DRINK */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: interrupt — EQUIP */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: no interrupt — NONE */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: no interrupt — PRAYER */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: no interrupt — SPEC */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: no interrupt — ATTACK */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: interrupt when no interaction */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: set replaces */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: spec toggle */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: spec disarm */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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..281202ba95 --- /dev/null +++ b/ocean/osrs/tests/test_inventory.c @@ -0,0 +1,417 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness */ +/* ======================================================================== */ + +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) + +/* ======================================================================== */ +/* test: init */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: inventory add/remove */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: inventory full */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: find */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: equip direct */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: equip from inventory */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: equip swap (slot occupied, old item goes to inventory) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: two-handed equip (shield unequipped to inventory) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: two-handed equip with no old weapon */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: two-handed fail (full inventory + shield equipped) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: two-handed succeeds when exactly enough space */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: unequip to inventory */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: gear slot mapping */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: edge cases */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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..c26b248500 --- /dev/null +++ b/ocean/osrs/tests/test_item_effects.c @@ -0,0 +1,911 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness (same macros as test_combat_math.c) */ +/* ======================================================================== */ + +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, ENCOUNTER_PRAYER_AUGURY, + 99, 0 /* 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, ENCOUNTER_PRAYER_PIETY, + 99, 3 /* 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); + + /* eff_level = floor(99 * 1.20) + 3 + 8 = 118 + 11 = 129 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 129); + + /* 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 = 129 * (123 + 64) = 129 * 187 = 24123 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 24123); +} + +/* ======================================================================== */ +/* 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, ENCOUNTER_PRAYER_RIGOUR, + 99, 0 /* 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=3 (rapid) */ + ASSERT_INT_EQ("attack_speed", stats.attack_speed, 3); + + /* 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, no stance */ + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + 99, 0, 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 + 0 + 8 = 107 */ + ASSERT_INT_EQ("empty melee eff", stats.eff_level, 107); + /* 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 */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_NONE, + 99, 0, 0, &stats); + + ASSERT_INT_EQ("empty ranged att_bonus", stats.attack_bonus, 0); + ASSERT_INT_EQ("empty ranged str_bonus", stats.strength_bonus, 0); + /* same eff_level formula: max_hit = floor(0.5 + 107*64/640) = 11 */ + ASSERT_INT_EQ("empty ranged max", stats.max_hit, 11); + + /* magic with ice barrage */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, + 99, 0, 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 + 9 = 108 (invisible +9 boost) */ + 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, ENCOUNTER_PRAYER_AUGURY, + 99, 0, 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, ENCOUNTER_PRAYER_PIETY, + 99, 3 /* 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); + + /* eff_level = floor(99 * 1.20) + 3 + 8 = 129 */ + ASSERT_INT_EQ("2h eff_level", stats.eff_level, 129); + + /* 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 = 129 * (132+64) = 129 * 196 = 25284 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("2h attack_roll", att_roll, 25284); +} + +/* ======================================================================== */ +/* 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, ENCOUNTER_PRAYER_AUGURY, + 99, 0, 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, ENCOUNTER_PRAYER_PIETY, + 99, 3, 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, ENCOUNTER_PRAYER_AUGURY, + 99, 0, 30, &stats); + + /* eff=132, 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, ENCOUNTER_PRAYER_NONE, + 99, 0, 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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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(); + + 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_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_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c new file mode 100644 index 0000000000..5a9a6da078 --- /dev/null +++ b/ocean/osrs/tests/test_special_attacks.c @@ -0,0 +1,1212 @@ +/** + * @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" + +/* ======================================================================== */ +/* test harness (same pattern as test_combat_math.c) */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: ranged spec energy costs */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: magic spec energy costs */ +/* ======================================================================== */ + +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; + p.has_dharok = 0; + + /* 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); +} + +/* ======================================================================== */ +/* main */ +/* ======================================================================== */ + +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/tools/README.md b/ocean/osrs/tools/README.md new file mode 100644 index 0000000000..d783b99988 --- /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 ../../.. && python setup.py build_osrs_my_encounter --force # 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..9c2be219ee --- /dev/null +++ b/ocean/osrs/tools/export_encounter_npcs.py @@ -0,0 +1,693 @@ +"""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_big_smart, + 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 ( + MDL2_MAGIC, + ModelData, + _merge_models, + decode_model, + expand_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + ANIM_MAGIC, + FrameBaseDef, + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + write_animations_binary, +) + +# gameval parser from tools dir +sys.path.insert(0, str(Path(__file__).parent)) +from gameval_parser import load_gameval, resolve_names, reverse_lookup + +# 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 + + +# ---- dataclasses (copied from export_inferno_npcs.py) ---- + +@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 + + +# ---- cache parsers (copied from export_inferno_npcs.py) ---- + +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 + + +# ---- model helpers (copied from export_inferno_npcs.py) ---- + +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) + + +# ---- main pipeline ---- + +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() + + # ================================================================ + # step 1: load gameval constants + # ================================================================ + 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") + + # ================================================================ + # step 2: load manifest, filter by group + # ================================================================ + 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}") + + # ================================================================ + # step 3: open cache, read NPC + spotanim configs + # ================================================================ + 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}") + + # ================================================================ + # step 4: export NPC models + # ================================================================ + 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") + + # ================================================================ + # step 5: resolve spotanims, export GFX models + # ================================================================ + 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) + + # ================================================================ + # step 6: write models binary + # ================================================================ + 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}") + + # ================================================================ + # step 7: export animations (follows export_inferno_npcs.py lines 618-690) + # ================================================================ + 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}") + + # ================================================================ + # step 8: write C header + # ================================================================ + 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(f"/* 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..f36cb5c06c --- /dev/null +++ b/ocean/osrs/tools/generate_items.py @@ -0,0 +1,405 @@ +"""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 +""" + +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, +} + + +def load_equipment_json(path: str) -> dict[int, dict]: + """Load equipment.json and index by OSRS item ID.""" + with open(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) + + 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)}" + ) + 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, " + ".magic_damage = 0, .prayer = 0" + ) + 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)}" + ) + 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..1360ac8609 --- /dev/null +++ b/ocean/osrs/tools/generate_monsters.py @@ -0,0 +1,315 @@ +"""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 + # strip HTML entities like   + cleaned = re.sub(r"&\w+;", "", str(raw)).strip() + # handle "X (Style)" format + cleaned = re.split(r"\s*\(", cleaned)[0].strip() + # handle "X-Y" ranges (take max) + if "-" in cleaned: + parts = cleaned.split("-") + return max(int(p.strip()) for p in parts if p.strip().isdigit()) + if cleaned.isdigit(): + return int(cleaned) + 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..3cd6fc56f5 --- /dev/null +++ b/ocean/osrs/tools/items_manifest.json @@ -0,0 +1,820 @@ +[ + { + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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" + }, + { + "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)" + }, + { + "index": "ITEM_TWISTED_BOW", + "item_id": 20997, + "attack_range": 10, + "comment": "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" + }, + { + "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" + }, + { + "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" + }, + { + "index": "ITEM_VIRTUS_ROBE_TOP", + "item_id": 26243, + "attack_range": 0, + "comment": "Virtus robe top" + }, + { + "index": "ITEM_VIRTUS_ROBE_BOTTOM", + "item_id": 26245, + "attack_range": 0, + "comment": "Virtus robe bottom" + }, + { + "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_DRAGONFIRE_SHIELD", + "item_id": 11283, + "version": "Charged", + "attack_range": 0, + "comment": "Dragonfire shield" + } +] \ No newline at end of file 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..af857f5cbc --- /dev/null +++ b/ocean/osrs_inferno/binding.c @@ -0,0 +1,430 @@ +/** + * @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_encounter.h" +#include "osrs_types.h" +#include "encounters/encounter_inferno.h" + +#define INF_TOTAL_OBS (INF_NUM_OBS + INF_ACTION_MASK_SIZE) + +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + EncounterState* enc_state; + int config_start_wave; /* the 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 */ +} InfernoEnv; + +#define OBS_SIZE INF_TOTAL_OBS +#define NUM_ATNS INF_NUM_ACTION_HEADS +#define ACT_SIZES { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 } +#define OBS_TENSOR_T FloatTensor +#define Env InfernoEnv + +/* global best episode tracking */ +static int g_best_wave = 0; +static int g_best_ticks = 999999; +static int g_best_zuk_hp = 999999; /* lowest Zuk HP seen (for Zuk-only training) */ + +void c_step(Env* env) { + for (int i = 0; i < NUM_ATNS; i++) + env->acts_staging[i] = (int)env->actions[i]; + + /* buffer actions for best-episode recording */ + if (env->episode_actions) { + /* capture RNG state at the very start of the episode (before first action) */ + if (env->episode_action_len == 0) + env->episode_rng_start = ((InfernoState*)env->enc_state)->rng_state; + if (env->episode_action_len < env->episode_action_cap) { + memcpy(&env->episode_actions[env->episode_action_len * NUM_ATNS], + env->acts_staging, NUM_ATNS * sizeof(int)); + env->episode_action_len++; + } + } + + ENCOUNTER_INFERNO.step(env->enc_state, env->acts_staging); + + float* obs = (float*)env->observations; + ENCOUNTER_INFERNO.write_obs(env->enc_state, obs); + ENCOUNTER_INFERNO.write_mask(env->enc_state, obs + INF_NUM_OBS); + + env->rewards[0] = ENCOUNTER_INFERNO.get_reward(env->enc_state); + + int is_term = ENCOUNTER_INFERNO.is_terminal(env->enc_state); + env->term_staging = (unsigned char)is_term; + env->terminals[0] = (float)is_term; + + /* 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.damage_received += s->total_damage_received; + 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; + } + + /* 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 (start_wave 0): best = highest wave reached, then fewest ticks. + for zuk-only (start_wave 68+): best = most damage to zuk (lowest zuk HP), 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 = most damage to zuk (lowest HP remaining). + if zuk is dead (winner==0), fastest kill (fewest ticks) wins. */ + int zuk_hp = 999999; + for (int n = 0; n < INF_MAX_NPCS; n++) { + if (st->npcs[n].active && st->npcs[n].type == INF_NPC_ZUK) { + zuk_hp = st->npcs[n].hp; + break; + } + } + if (st->winner == 0) zuk_hp = 0; /* zuk dead */ + is_new_best = (zuk_hp < g_best_zuk_hp || + (zuk_hp == g_best_zuk_hp && zuk_hp == 0 && ticks < g_best_ticks)); + if (is_new_best) g_best_zuk_hp = 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) { + 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 zuk hp=%d (%d ticks, rng=%u) saved to %s\n", + g_best_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); + } +} + +void c_reset(Env* env) { + ENCOUNTER_INFERNO.reset(env->enc_state, 0); + + float* obs = (float*)env->observations; + ENCOUNTER_INFERNO.write_obs(env->enc_state, obs); + ENCOUNTER_INFERNO.write_mask(env->enc_state, obs + INF_NUM_OBS); + + env->rewards[0] = 0.0f; + env->term_staging = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { + free(env->episode_actions); + env->episode_actions = NULL; + if (env->enc_state) { + ENCOUNTER_INFERNO.destroy(env->enc_state); + env->enc_state = NULL; + } +} + +void c_render(Env* env) { (void)env; } + +#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"); + if (start_wave) + ENCOUNTER_INFERNO.put_int(env->enc_state, "start_wave", (int)start_wave->value); + /* match the 1-indexed → 0-indexed conversion done by encounter's put_int */ + int sw = start_wave ? (int)start_wave->value : 0; + env->config_start_wave = (sw > 0) ? sw - 1 : 0; + + /* allocate action buffer for best-episode recording (all envs buffer) */ + if (getenv("RECORD_REPLAY") && getenv("RECORD_REPLAY")[0]) { + env->episode_actions = (int*)malloc(REPLAY_MAX_TICKS * NUM_ATNS * sizeof(int)); + env->episode_action_cap = REPLAY_MAX_TICKS; + } else { + env->episode_actions = NULL; + env->episode_action_cap = 0; + } + env->episode_action_len = 0; +} + +/* curriculum wave mixing: start some agents at later waves for late-game gradient signal. + wave-0 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; + + /* 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] = (int)w->value; + 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 wave0_count = num_envs - curriculum_total; + int cursor = wave0_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-0", wave0_count); + 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, "unavoidable_off_prayer", log->unavoidable_off_prayer); + + dict_set(out, "brews_remaining", log->brews_remaining); + dict_set(out, "restores_remaining", log->restores_remaining); + dict_set(out, "prayer_at_death", log->prayer_at_death); + + dict_set(out, "npc_kills", log->npc_kills); + dict_set(out, "gear_switches", log->gear_switches); + dict_set(out, "current_ranged", log->current_ranged); + dict_set(out, "current_magic", log->current_magic); + dict_set(out, "behind_shield_pct", log->behind_shield_pct); + dict_set(out, "zuk_hp_remaining", log->zuk_hp_remaining); + dict_set(out, "noop_move", log->noop_move); + dict_set(out, "noop_prayer", log->noop_prayer); + dict_set(out, "noop_target", log->noop_target); + dict_set(out, "noop_gear", log->noop_gear); + dict_set(out, "noop_eat", log->noop_eat); + dict_set(out, "noop_potion", log->noop_potion); + dict_set(out, "noop_spell", log->noop_spell); + dict_set(out, "noop_spec", log->noop_spec); + 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; + if (log->start_wave >= 68) { + /* Zuk-only: score = fraction of Zuk HP removed (0..1), wins = 1.0 */ + score = (1200.0f - log->zuk_hp_remaining) / 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..1e1eb6a2bb --- /dev/null +++ b/ocean/osrs_pvp/binding.c @@ -0,0 +1,247 @@ +/** + * @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" + +/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. + * vecenv.h's create_static_vec assigns to env->observations, env->actions, + * env->rewards, env->terminals directly. These fields must match vecenv's + * expected types (void*, float*, float*, float*). The embedded OsrsEnv has + * its own identically-named fields with different types — pvp_init sets those + * to internal inline buffers, so there's no conflict. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsEnv pvp; + + /* staging buffers for type conversion */ + 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} +#define OBS_TENSOR_T FloatTensor +#define Env PvpEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h + * because vecenv.h calls them inside its implementation section without + * forward-declaring them (they're expected to come from the env header). */ + +void c_step(Env* env) { + /* float actions from vecenv → int staging for PVP */ + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + /* terminal: unsigned char → float for vecenv */ + env->terminals[0] = (float)env->ocean_term_staging; + + /* copy PVP log to wrapper log on episode end */ + if (env->ocean_term_staging) { + env->log.episode_return += env->pvp.log.episode_return; + env->log.episode_length += env->pvp.log.episode_length; + env->log.wins += env->pvp.log.wins; + env->log.damage_dealt += env->pvp.log.damage_dealt; + env->log.damage_received += env->pvp.log.damage_received; + env->log.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) { + /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because + * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ + env->pvp.ocean_obs = (float*)env->observations; + env->pvp.ocean_rew = env->rewards; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_rew[0] = 0.0f; + env->pvp.ocean_term[0] = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { pvp_close(&env->pvp); } +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + + pvp_init(&env->pvp); + + /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside + * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers + * (env->observations, env->actions, env->rewards, env->terminals are NULL + * at this point). c_reset runs after buffer assignment and does the wiring. + * + * For now, point ocean pointers at internal staging so pvp_reset doesn't + * crash on writes to ocean_term/ocean_rew. */ + env->pvp.ocean_obs = NULL; + env->pvp.ocean_rew = env->pvp._rews_buf; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + env->pvp.ocean_obs_p1 = NULL; + env->pvp.ocean_selfplay_mask = NULL; + + /* config from Dict (all values are double) */ + env->pvp.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.opponent.type = opp ? (OpponentType)(int)opp->value : OPP_IMPROVED; + + DictItem* shaping_scale = dict_get_unsafe(kwargs, "shaping_scale"); + env->pvp.shaping.shaping_scale = shaping_scale ? (float)shaping_scale->value : 0.0f; + + DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); + env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; + + /* reward shaping coefficients (same defaults as ocean_binding.c) */ + env->pvp.shaping.damage_dealt_coef = 0.005f; + env->pvp.shaping.damage_received_coef = -0.005f; + env->pvp.shaping.correct_prayer_bonus = 0.03f; + env->pvp.shaping.wrong_prayer_penalty = -0.02f; + env->pvp.shaping.prayer_switch_no_attack_penalty = -0.01f; + env->pvp.shaping.off_prayer_hit_bonus = 0.03f; + env->pvp.shaping.melee_frozen_penalty = -0.05f; + env->pvp.shaping.wasted_eat_penalty = -0.001f; + env->pvp.shaping.premature_eat_penalty = -0.02f; + env->pvp.shaping.magic_no_staff_penalty = -0.05f; + env->pvp.shaping.gear_mismatch_penalty = -0.05f; + env->pvp.shaping.spec_off_prayer_bonus = 0.02f; + env->pvp.shaping.spec_low_defence_bonus = 0.01f; + env->pvp.shaping.spec_low_hp_bonus = 0.02f; + env->pvp.shaping.smart_triple_eat_bonus = 0.05f; + env->pvp.shaping.wasted_triple_eat_penalty = -0.0005f; + env->pvp.shaping.damage_burst_bonus = 0.002f; + env->pvp.shaping.damage_burst_threshold = 30; + env->pvp.shaping.premature_eat_threshold = 0.7071f; + env->pvp.shaping.ko_bonus = 0.15f; + env->pvp.shaping.wasted_resources_penalty = -0.07f; + env->pvp.shaping.prayer_penalty_enabled = 1; + env->pvp.shaping.click_penalty_enabled = 0; + env->pvp.shaping.click_penalty_threshold = 5; + env->pvp.shaping.click_penalty_coef = -0.003f; + + /* gear: default tier 0 (basic LMS) */ + env->pvp.gear_tier_weights[0] = 1.0f; + env->pvp.gear_tier_weights[1] = 0.0f; + env->pvp.gear_tier_weights[2] = 0.0f; + env->pvp.gear_tier_weights[3] = 0.0f; + + /* pvp_reset sets up game state (players, positions, gear, etc.) + * but does NOT write to ocean buffers — that happens in c_reset. */ + pvp_reset(&env->pvp); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); + + /* 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); +} + +/* ======================================================================== + * PFSP: set/get opponent pool weights across all envs + * Called from Python via pybind11 wrappers in metal_bindings.mm + * ======================================================================== */ + +void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { + Env* envs = (Env*)vec->envs; + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + for (int e = 0; e < vec->size; e++) { + int was_unconfigured = (envs[e].pvp.pfsp.pool_size == 0); + envs[e].pvp.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pfsp.cum_weights[i] = cum_weights[i]; + } + /* Only reset on first configuration — restarts the episode that was started + * during env creation before the pool was set (would have used fallback opponent). + * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ + if (was_unconfigured) { + c_reset(&envs[e]); + } + } +} + +void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes, int* out_pool_size) { + Env* envs = (Env*)vec->envs; + int pool_size = 0; + + for (int e = 0; e < vec->size; e++) { + if (envs[e].pvp.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pfsp.pool_size; + } + *out_pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + out_wins[i] = 0.0f; + out_episodes[i] = 0.0f; + } + + /* Aggregate and reset (read-and-reset pattern) */ + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pfsp.episodes[i]; + } + memset(envs[e].pvp.pfsp.wins, 0, sizeof(envs[e].pvp.pfsp.wins)); + memset(envs[e].pvp.pfsp.episodes, 0, sizeof(envs[e].pvp.pfsp.episodes)); + } +} diff --git a/ocean/osrs_pvp/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..6f0a051d03 --- /dev/null +++ b/ocean/osrs_zulrah/binding.c @@ -0,0 +1,156 @@ +/** + * @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_encounter.h" +#include "osrs_types.h" +#include "encounters/encounter_zulrah.h" + +/* total obs = raw obs + action mask */ +#define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) + +/* wrapper struct: vecenv-compatible fields at top + encounter state. + * vecenv.h's create_static_vec assigns env->observations, env->actions, + * env->rewards, env->terminals directly. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + EncounterState* enc_state; + + /* staging buffer for action type conversion */ + int acts_staging[ZUL_NUM_ACTION_HEADS]; + unsigned char term_staging; +} ZulrahEnv; + +#define OBS_SIZE ZUL_TOTAL_OBS +#define NUM_ATNS ZUL_NUM_ACTION_HEADS +#define ACT_SIZES {ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env ZulrahEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h */ + +void c_step(Env* env) { + /* float actions -> int staging */ + for (int i = 0; i < NUM_ATNS; i++) { + env->acts_staging[i] = (int)env->actions[i]; + } + + ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); + + /* write obs + mask directly (mask appended after raw obs) */ + float* obs = (float*)env->observations; + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + + /* reward */ + env->rewards[0] = ENCOUNTER_ZULRAH.get_reward(env->enc_state); + + /* terminal */ + int is_term = ENCOUNTER_ZULRAH.is_terminal(env->enc_state); + env->term_staging = (unsigned char)is_term; + env->terminals[0] = (float)is_term; + + /* log directly into env->log (vecenv accumulates + clears periodically). + bypass get_log vtable to avoid double-counting from encounter's own += */ + if (is_term) { + ZulrahState* zs = (ZulrahState*)env->enc_state; + env->log.episode_return += zs->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; + + /* auto-reset */ + ENCOUNTER_ZULRAH.reset(env->enc_state, 0); + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + } +} + +void c_reset(Env* env) { + ENCOUNTER_ZULRAH.reset(env->enc_state, 0); + + float* obs = (float*)env->observations; + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + + env->rewards[0] = 0.0f; + env->term_staging = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { + if (env->enc_state) { + ENCOUNTER_ZULRAH.destroy(env->enc_state); + env->enc_state = NULL; + } +} + +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + env->enc_state = ENCOUNTER_ZULRAH.create(); + memset(&env->log, 0, sizeof(Log)); + + /* gear tier config (default 0 = budget) */ + DictItem* gear = dict_get_unsafe(kwargs, "gear_tier"); + if (gear) { + ENCOUNTER_ZULRAH.put_int(env->enc_state, "gear_tier", (int)gear->value); + } +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); + + /* 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); + + /* 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 */ + + /* composite score: winrate-gated efficiency */ + 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/src/osrs/data/item_models.h b/src/osrs/data/item_models.h new file mode 100644 index 0000000000..0a3cf62e28 --- /dev/null +++ b/src/osrs/data/item_models.h @@ -0,0 +1,118 @@ +/* generated by scripts/export_models.py — do not edit */ +#ifndef ITEM_MODELS_H +#define ITEM_MODELS_H + +#include + +typedef struct { + uint16_t item_id; + uint32_t inv_model; + uint32_t wield_model; + uint8_t has_sleeves; +} ItemModelMapping; + +#define ITEM_MODEL_COUNT 99 + +static const ItemModelMapping ITEM_MODEL_MAP[] = { + { 10828, 21938, 917504, 0 }, + { 21795, 34166, 917505, 0 }, + { 1712, 2796, 917506, 0 }, + { 2503, 2745, 917507, 0 }, + { 4091, 5043, 917508, 1 }, + { 1079, 2582, 917509, 0 }, + { 4093, 5042, 917510, 0 }, + { 4151, 5412, 917511, 0 }, + { 9185, 16876, 917512, 0 }, + { 4710, 6590, 917513, 0 }, + { 5698, 2718, 917514, 0 }, + { 12954, 10422, 917515, 0 }, + { 12829, 11308, 917516, 0 }, + { 7462, 13631, 917517, 0 }, + { 3105, 2837, 917518, 0 }, + { 6737, 9931, 4294967295, 0 }, + { 9243, 16856, 4294967295, 0 }, + { 22324, 35739, 917521, 0 }, + { 24417, 39068, 917522, 0 }, + { 11791, 2810, 917523, 0 }, + { 21006, 32789, 917524, 0 }, + { 24424, 39072, 917525, 0 }, + { 11785, 19967, 917527, 0 }, + { 26374, 43246, 917528, 0 }, + { 13652, 32784, 917529, 0 }, + { 11802, 28075, 917530, 0 }, + { 25730, 4845, 4294967295, 0 }, + { 4153, 5413, 917532, 0 }, + { 21003, 32792, 917533, 0 }, + { 11235, 26386, 917534, 0 }, + { 19481, 31523, 917535, 0 }, + { 22613, 35995, 917536, 0 }, + { 27690, 47422, 917537, 0 }, + { 22622, 35986, 917538, 0 }, + { 22636, 35997, 917539, 0 }, + { 21018, 32794, 917540, 0 }, + { 21021, 32790, 917541, 1 }, + { 21024, 32787, 917542, 0 }, + { 4712, 6578, 917543, 1 }, + { 4714, 6577, 917544, 0 }, + { 4736, 6588, 917545, 1 }, + { 11834, 28047, 917546, 0 }, + { 12831, 11307, 917547, 0 }, + { 6585, 9633, 917548, 0 }, + { 12002, 28438, 917549, 0 }, + { 21295, 33144, 917550, 0 }, + { 13235, 29394, 917551, 0 }, + { 11770, 21850, 4294967295, 0 }, + { 25975, 46473, 4294967295, 0 }, + { 6889, 10573, 917554, 0 }, + { 11212, 26306, 4294967295, 0 }, + { 4751, 6584, 917556, 0 }, + { 4722, 6581, 917557, 0 }, + { 4759, 6595, 917558, 0 }, + { 4745, 6592, 917559, 0 }, + { 4716, 6580, 917560, 0 }, + { 4753, 6597, 917561, 0 }, + { 4724, 6583, 917562, 0 }, + { 21932, 16856, 4294967295, 0 }, + { 21791, 34261, 917564, 0 }, + { 31113, 56713, 917565, 0 }, + { 27251, 46472, 917566, 0 }, + { 31106, 56703, 917567, 0 }, + { 31097, 56694, 917568, 0 }, + { 20657, 31519, 4294967295, 0 }, + { 20997, 32799, 917570, 0 }, + { 27235, 46466, 917571, 0 }, + { 27238, 46469, 917572, 0 }, + { 27241, 46475, 917573, 0 }, + { 19547, 31510, 917574, 0 }, + { 28947, 52244, 917575, 0 }, + { 26235, 43237, 917576, 0 }, + { 12926, 19219, 917577, 0 }, + { 4708, 5419, 917578, 0 }, + { 19544, 31515, 917579, 0 }, + { 22481, 35744, 917580, 0 }, + { 6920, 10580, 917581, 0 }, + { 20220, 31976, 4294967295, 0 }, + { 2550, 2677, 4294967295, 0 }, + { 23971, 38761, 917584, 0 }, + { 22109, 35041, 917585, 0 }, + { 23975, 38766, 917586, 0 }, + { 23979, 38765, 917587, 0 }, + { 25865, 42605, 917588, 0 }, + { 19921, 32033, 917589, 0 }, + { 4089, 5040, 917590, 0 }, + { 12899, 19223, 917591, 0 }, + { 12612, 2543, 917592, 0 }, + { 21326, 2711, 4294967295, 0 }, + { 4097, 5038, 917594, 0 }, + { 10382, 20231, 917595, 1 }, + { 2497, 2507, 917596, 0 }, + { 12788, 48061, 917597, 0 }, + { 10499, 20454, 917598, 0 }, + { 22326, 35751, 917599, 0 }, + { 22327, 35750, 917600, 0 }, + { 22328, 35752, 917601, 0 }, + { 4224, 5198, 917602, 0 }, + { 13237, 29396, 917603, 0 }, +}; + +#endif /* ITEM_MODELS_H */ diff --git a/src/osrs/data/npc_models.h b/src/osrs/data/npc_models.h new file mode 100644 index 0000000000..17d9bc24ab --- /dev/null +++ b/src/osrs/data/npc_models.h @@ -0,0 +1,113 @@ +/** + * @fileoverview NPC model/animation mappings for encounter rendering. + * + * Maps NPC definition IDs to cache model IDs and animation sequence IDs. + * Generated by scripts/export_inferno_npcs.py — do not edit. + */ + +#ifndef NPC_MODELS_H +#define NPC_MODELS_H + +#include + +typedef struct { + uint16_t npc_id; + uint32_t model_id; + uint32_t idle_anim; + uint32_t attack_anim; + uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */ +} NpcModelMapping; + +/* zulrah forms + snakeling */ +static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { + {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */ + {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */ + {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */ +}; + +/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */ +#define SNAKELING_MODEL_ID 10415 +#define SNAKELING_ANIM_IDLE 1721 +#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */ +#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */ +#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */ +#define SNAKELING_ANIM_WALK 2405 /* walk cycle */ + +/* zulrah spotanim (projectile/cloud) model IDs */ +#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */ +#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */ +#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */ +#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */ +#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */ + +/* zulrah animation sequence IDs */ +#define ZULRAH_ANIM_ATTACK 5068 +#define ZULRAH_ANIM_IDLE 5069 +#define ZULRAH_ANIM_DIVE 5072 +#define ZULRAH_ANIM_SURFACE 5071 +#define ZULRAH_ANIM_RISE 5073 +#define ZULRAH_ANIM_5070 5070 +#define ZULRAH_ANIM_5806 5806 +#define ZULRAH_ANIM_5807 5807 +#define GFX_SNAKELING_SPAWN_ANIM 5358 + +/* ================================================================ */ +/* inferno NPC model/animation mappings (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_447_MODEL INF_GEN_GFX_447_MODEL +#define INF_GFX_447_ANIM INF_GEN_GFX_447_ANIM +#define INF_GFX_448_MODEL INF_GEN_GFX_448_MODEL +#define INF_GFX_448_ANIM INF_GEN_GFX_448_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 + +/* tbow projectile — not in inferno group, keep hardcoded */ +#define INF_GFX_942_MODEL 19374 +#define INF_GFX_942_ANIM 5233 + +/* 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/src/osrs/data/npc_models_inferno.h b/src/osrs/data/npc_models_inferno.h new file mode 100644 index 0000000000..a493acaa1c --- /dev/null +++ b/src/osrs/data/npc_models_inferno.h @@ -0,0 +1,94 @@ +/* generated by tools/export_encounter_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}, /* Nibbler */ + {7692, 0xC1E0C, 7577, 7578, 7577}, /* Bat */ + {7693, 0xC1E0D, 7586, 7581, 7587}, /* Blob */ + {7694, 0xC1E0E, 7586, 65535, 7587}, /* Blob mage split */ + {7695, 0xC1E0F, 7586, 65535, 7587}, /* Blob range split */ + {7696, 0xC1E10, 7586, 65535, 7587}, /* Blob melee split */ + {7697, 0xC1E11, 7595, 7597, 7596}, /* Meleer */ + {7698, 0xC1E12, 7602, 7605, 7603}, /* Ranger */ + {7699, 0xC1E13, 7609, 7610, 7608}, /* Mager */ + {7700, 0xC1E14, 7589, 7593, 7588}, /* Jad */ + {7701, 0xC1E15, 2636, 65535, 2634}, /* Jad healer */ + {7706, 0xC1E1A, 7564, 7566, 65535}, /* Zuk */ + {7707, 0xC1E1B, 7567, 65535, 7567}, /* Ancestral Glyph */ + {7708, 0xC1E1C, 2867, 65535, 2863}, /* Zuk healer */ +}; + +/* inferno animation IDs */ +#define INF_GEN_ANIM_ZUK_DEATH 7562 +#define INF_GEN_ANIM_ZUK_SPAWN 7563 +#define INF_GEN_ANIM_ZUK_DEFEND 7565 +#define INF_GEN_ANIM_ZUK_ATTACK 7566 +#define INF_GEN_ANIM_MOVING_SAFE_SPOT_HIT 7568 +#define INF_GEN_ANIM_MOVING_SAFE_SPOT_DEATH 7569 +#define INF_GEN_ANIM_JALNIB_ATTACK 7574 +#define INF_GEN_ANIM_JALNIB_DEFEND 7575 +#define INF_GEN_ANIM_JALNIB_DEATH 7576 +#define INF_GEN_ANIM_JALMEJRAH_ATTACK 7578 +#define INF_GEN_ANIM_JALMEJRAH_DEFEND 7579 +#define INF_GEN_ANIM_JALMEJRAH_DEATH 7580 +#define INF_GEN_ANIM_JALAK_ATTACK_MAGIC 7581 +#define INF_GEN_ANIM_JALAK_ATTACK_MELEE 7582 +#define INF_GEN_ANIM_JALAK_ATTACK_RANGED 7583 +#define INF_GEN_ANIM_JALAK_DEATH 7584 +#define INF_GEN_ANIM_JALAK_DEFEND 7585 +#define INF_GEN_ANIM_JALTOKJAD_ATTACK_MELEE 7590 +#define INF_GEN_ANIM_JALTOKJAD_DEFEND 7591 +#define INF_GEN_ANIM_JALTOKJAD_ATTACK_MAGIC 7592 +#define INF_GEN_ANIM_JALTOKJAD_ATTACK_RANGED 7593 +#define INF_GEN_ANIM_JALTOKJAD_DEATH 7594 +#define INF_GEN_ANIM_JALIMKOT_ATTACK 7597 +#define INF_GEN_ANIM_JALIMKOT_DEFEND 7598 +#define INF_GEN_ANIM_JALIMKOT_DEATH 7599 +#define INF_GEN_ANIM_JALIMKOT_DIGDOWN 7600 +#define INF_GEN_ANIM_JALIMKOT_DIGUP 7601 +#define INF_GEN_ANIM_JALXIL_ATTACK_MELEE 7604 +#define INF_GEN_ANIM_JALXIL_ATTACK_RANGED 7605 +#define INF_GEN_ANIM_JALXIL_DEATH 7606 +#define INF_GEN_ANIM_JALXIL_DEFEND 7607 +#define INF_GEN_ANIM_JALAKXIL_ATTACK_MAGIC 7610 +#define INF_GEN_ANIM_JALAKXIL_RESURRECT 7611 +#define INF_GEN_ANIM_JALAKXIL_ATTACK_MELEE 7612 +#define INF_GEN_ANIM_JALAKXIL_DEATH 7613 + +/* inferno spotanim GFX model + animation IDs */ +#define INF_GEN_GFX_157_MODEL 3116 /* FIREWAVE_IMPACT */ +#define INF_GEN_GFX_157_ANIM 693 +#define INF_GEN_GFX_447_MODEL 9334 /* TZHAAR_FIRE_SPIT_LAUNCH */ +#define INF_GEN_GFX_447_ANIM 2658 +#define INF_GEN_GFX_448_MODEL 9337 /* TZHAAR_FIRE_SPIT_TRAVEL */ +#define INF_GEN_GFX_448_ANIM 2659 +#define INF_GEN_GFX_451_MODEL 9342 /* TZHAAR_ROCK_SMASH */ +#define INF_GEN_GFX_451_ANIM 2660 +#define INF_GEN_GFX_1374_MODEL 853342 /* SLAYER_MAGICDART_ENCHANTED_IMPACT */ +#define INF_GEN_GFX_1374_ANIM 660 +#define INF_GEN_GFX_1375_MODEL 33006 /* INFERNO_ZUK_PROJECTILE */ +#define INF_GEN_GFX_1375_ANIM 7571 +#define INF_GEN_GFX_1376_MODEL 33007 /* INFERNO_ZEK_PROJECTILE */ +#define INF_GEN_GFX_1376_ANIM 7571 +#define INF_GEN_GFX_1377_MODEL 33013 /* INFERNO_XIL_PROJECTILE */ +#define INF_GEN_GFX_1378_MODEL 33015 /* INFERNO_SPLITTER_RANGE */ +#define INF_GEN_GFX_1378_ANIM 7615 +#define INF_GEN_GFX_1379_MODEL 33016 /* INFERNO_BABYSPLITTER_RANGE */ +#define INF_GEN_GFX_1379_ANIM 7614 +#define INF_GEN_GFX_1380_MODEL 33008 /* INFERNO_SPLITTER_MAGE */ +#define INF_GEN_GFX_1380_ANIM 7616 +#define INF_GEN_GFX_1381_MODEL 33009 /* INFERNO_BABYSPLITTER_MAGE */ +#define INF_GEN_GFX_1381_ANIM 7616 +#define INF_GEN_GFX_1382_MODEL 33017 /* INFERNO_HARPIE_PROJ */ +#define INF_GEN_GFX_1382_ANIM 7614 +#define INF_GEN_GFX_1383_MODEL 853351 /* DOUBLE_AMETHYST_ARROW_LAUNCH */ +#define INF_GEN_GFX_1383_ANIM 366 +#define INF_GEN_GFX_1384_MODEL 853352 /* AMETHYST_ARROW_TRAVEL */ +#define INF_GEN_GFX_1385_MODEL 853353 /* AMETHYST_ARROW_LAUNCH */ +#define INF_GEN_GFX_1385_ANIM 366 + +#endif /* NPC_MODELS_INFERNO_H */ diff --git a/src/osrs/data/npc_models_zulrah.h b/src/osrs/data/npc_models_zulrah.h new file mode 100644 index 0000000000..edcb08628d --- /dev/null +++ b/src/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/src/osrs/data/player_models.h b/src/osrs/data/player_models.h new file mode 100644 index 0000000000..c66816d018 --- /dev/null +++ b/src/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/src/osrs/encounters/encounter_inferno.h b/src/osrs/encounters/encounter_inferno.h new file mode 100644 index 0000000000..99a8c358b0 --- /dev/null +++ b/src/osrs/encounters/encounter_inferno.h @@ -0,0 +1,3348 @@ +/** + * @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_encounter.h" +#include "../osrs_interaction.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* arena constants */ +/* ======================================================================== */ + +#define INF_ARENA_MIN_X 11 +#define INF_ARENA_MAX_X 39 +#define INF_ARENA_MIN_Y 14 +#define INF_ARENA_MAX_Y 43 +#define INF_ARENA_WIDTH (INF_ARENA_MAX_X - INF_ARENA_MIN_X + 1) /* 29 */ +#define INF_ARENA_HEIGHT (INF_ARENA_MAX_Y - INF_ARENA_MIN_Y + 1) /* 30 */ + +#define INF_PLAYER_START_X 25 +#define INF_PLAYER_START_Y 16 +#define INF_ZUK_PLAYER_START_X 25 +#define INF_ZUK_PLAYER_START_Y 42 + +#define INF_NUM_PILLARS 3 +#define INF_PILLAR_SIZE 3 +#define INF_PILLAR_HP 255 + +static const int INF_PILLAR_POS[INF_NUM_PILLARS][2] = { + { 21, 20 }, /* south pillar */ + { 11, 34 }, /* west pillar */ + { 28, 36 }, /* north pillar */ +}; + +/* 9 mob spawn positions (shuffled per wave) */ +#define INF_NUM_SPAWN_POS 9 +static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { + {12, 38}, {33, 38}, {14, 32}, {34, 31}, {27, 26}, + {16, 20}, {34, 18}, {12, 15}, {26, 15}, +}; + +/* nibbler spawn position (near pillars) */ +#define INF_NIBBLER_SPAWN_X 20 +#define INF_NIBBLER_SPAWN_Y 32 + +#define INF_MAX_TICKS 18000 /* 3 hours at 0.6s/tick */ +#define INF_NUM_WAVES 69 + +/* ======================================================================== */ +/* NPC types */ +/* ======================================================================== */ + +typedef enum { + INF_NPC_NIBBLER = 0, /* Jal-Nib: melee, eats pillars */ + INF_NPC_BAT, /* Jal-MejRah: short-range ranged, drains run */ + INF_NPC_BLOB, /* Jal-Ak: prayer reader, splits into 3 on death */ + INF_NPC_BLOB_MELEE, /* Jal-Ak-Rek-Ket: melee split from blob */ + INF_NPC_BLOB_RANGE, /* Jal-Ak-Rek-Xil: range split from blob */ + INF_NPC_BLOB_MAGE, /* Jal-Ak-Rek-Mej: mage split from blob */ + INF_NPC_MELEER, /* Jal-ImKot: melee, can dig */ + INF_NPC_RANGER, /* Jal-Xil: ranged, can melee if close */ + INF_NPC_MAGER, /* Jal-Zek: magic, resurrects dead mobs, can melee if close */ + INF_NPC_JAD, /* JalTok-Jad: random 50/50 range/mage */ + INF_NPC_ZUK, /* TzKal-Zuk: final boss */ + INF_NPC_HEALER_JAD, /* Yt-HurKot: jad healer */ + INF_NPC_HEALER_ZUK, /* Jal-MejJak: zuk healer */ + INF_NPC_ZUK_SHIELD, /* shield NPC during Zuk */ + INF_NUM_NPC_TYPES +} InfNPCType; + +/* OSRS NPC definition IDs — maps InfNPCType enum to actual cache NPC IDs + * used by the renderer to look up models/animations in npc_models.h */ +static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 7691, /* Jal-Nib */ + [INF_NPC_BAT] = 7692, /* Jal-MejRah */ + [INF_NPC_BLOB] = 7693, /* Jal-Ak */ + [INF_NPC_BLOB_MELEE] = 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, 0, 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, 0, 1 }, + [INF_NPC_HEALER_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 10, 100, 0, 2, 0 }, /* stun_on_spawn=2 per InfernoTrainer TzKalZuk.ts:168 */ + [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; + } +} + +/* ======================================================================== */ +/* wave compositions */ +/* ======================================================================== */ + +#define INF_MAX_NPCS_PER_WAVE 9 /* wave 62: NNN BB BL M R MA = 9 */ + +typedef struct { + uint8_t types[INF_MAX_NPCS_PER_WAVE]; + int count; +} InfWaveDef; + +static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { + #define N INF_NPC_NIBBLER + #define B INF_NPC_BAT + #define BL INF_NPC_BLOB + #define M INF_NPC_MELEER + #define R INF_NPC_RANGER + #define MA INF_NPC_MAGER + #define J INF_NPC_JAD + #define Z INF_NPC_ZUK + #define W(...) { .types = { __VA_ARGS__ }, .count = sizeof((uint8_t[]){__VA_ARGS__}) } + + /* waves 1-8: bats + nibblers introduction */ + [0] = W(N,N,N, B), + [1] = W(N,N,N, B,B), + [2] = W(N,N,N, N,N,N), + [3] = W(N,N,N, BL), + [4] = W(N,N,N, B,BL), + [5] = W(N,N,N, B,B,BL), + [6] = W(N,N,N, BL,BL), + [7] = W(N,N,N, N,N,N), + + /* waves 9-17: meleer introduction */ + [8] = W(N,N,N, M), + [9] = W(N,N,N, B,M), + [10] = W(N,N,N, B,B,M), + [11] = W(N,N,N, BL,M), + [12] = W(N,N,N, B,BL,M), + [13] = W(N,N,N, B,B,BL,M), + [14] = W(N,N,N, BL,BL,M), + [15] = W(N,N,N, M,M), + [16] = W(N,N,N, N,N,N), + + /* waves 18-34: ranger introduction */ + [17] = W(N,N,N, R), + [18] = W(N,N,N, B,R), + [19] = W(N,N,N, B,B,R), + [20] = W(N,N,N, BL,R), + [21] = W(N,N,N, B,BL,R), + [22] = W(N,N,N, B,B,BL,R), + [23] = W(N,N,N, BL,BL,R), + [24] = W(N,N,N, M,R), + [25] = W(N,N,N, B,M,R), + [26] = W(N,N,N, B,B,M,R), + [27] = W(N,N,N, BL,M,R), + [28] = W(N,N,N, B,BL,M,R), + [29] = W(N,N,N, B,B,BL,M,R), + [30] = W(N,N,N, BL,BL,M,R), + [31] = W(N,N,N, M,M,R), + [32] = W(N,N,N, R,R), + [33] = W(N,N,N, N,N,N), + + /* waves 35-66: mager introduction (all combinations) */ + [34] = W(N,N,N, MA), + [35] = W(N,N,N, B,MA), + [36] = W(N,N,N, B,B,MA), + [37] = W(N,N,N, BL,MA), + [38] = W(N,N,N, B,BL,MA), + [39] = W(N,N,N, B,B,BL,MA), + [40] = W(N,N,N, BL,BL,MA), + [41] = W(N,N,N, M,MA), + [42] = W(N,N,N, B,M,MA), + [43] = W(N,N,N, B,B,M,MA), + [44] = W(N,N,N, BL,M,MA), + [45] = W(N,N,N, B,BL,M,MA), + [46] = W(N,N,N, B,B,BL,M,MA), + [47] = W(N,N,N, BL,BL,M,MA), + [48] = W(N,N,N, M,M,MA), + [49] = W(N,N,N, R,MA), + [50] = W(N,N,N, B,R,MA), + [51] = W(N,N,N, B,B,R,MA), + [52] = W(N,N,N, BL,R,MA), + [53] = W(N,N,N, B,BL,R,MA), + [54] = W(N,N,N, B,B,BL,R,MA), + [55] = W(N,N,N, BL,BL,R,MA), + [56] = W(N,N,N, M,R,MA), + [57] = W(N,N,N, B,M,R,MA), + [58] = W(N,N,N, B,B,M,R,MA), + [59] = W(N,N,N, BL,M,R,MA), + [60] = W(N,N,N, B,BL,M,R,MA), + [61] = W(N,N,N, B,B,BL,M,R,MA), + [62] = W(N,N,N, BL,BL,M,R,MA), + [63] = W(N,N,N, M,M,R,MA), + [64] = W(N,N,N, R,R,MA), + [65] = W(N,N,N, MA,MA), + + /* waves 67-69: jads + zuk */ + [66] = W(J), + [67] = W(J,J,J), + [68] = W(Z), + + #undef N + #undef B + #undef BL + #undef M + #undef R + #undef MA + #undef J + #undef Z + #undef W +}; + +/* ======================================================================== */ +/* NPC state */ +/* ======================================================================== */ + +/* max active NPCs: wave 62 has 9 + blob splits (3 per blob, up to 2 blobs = 6) + healers */ +#define INF_MAX_NPCS 32 + +/* dead mob store for mager resurrection */ +#define INF_MAX_DEAD_MOBS 16 + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; +} InfDeadMob; + +#define INF_MAX_PENDING_SPARKS 16 + +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: current attack style (random 50/50) */ + int jad_healer_spawned; /* jad: 1 if healers have been spawned */ + int jad_owner_idx; /* healer: which jad this healer belongs to (-1 = none) */ + + /* mager resurrection state */ + int resurrect_cooldown; /* mager: ticks until next resurrection attempt */ + + /* freeze state (ice barrage) */ + int frozen_ticks; /* ticks remaining in ice barrage freeze */ + + /* heal state */ + int heal_target; /* healer: NPC index being healed (-1 = none) */ + int heal_timer; /* healer: ticks until next heal tick */ + + /* pending hit from player attack (projectile in flight) */ + EncounterPendingHit pending_hit; + + /* death linger: NPC stays visible for death animation + final hitsplat. + >0 means dying (decremented each tick), 0 = alive or fully removed. */ + int death_ticks; + + /* 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_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; + +/* ======================================================================== */ +/* pillar state */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int hp; + int active; +} InfPillar; + +/* ======================================================================== */ +/* zuk state */ +/* ======================================================================== */ + +typedef struct { + /* shield */ + int shield_idx; /* NPC index of shield (-1 if dead) */ + int shield_dir; /* +1 or -1 */ + int shield_freeze; /* ticks of freeze at boundary */ + + /* spawn timers */ + int initial_delay; /* 14 ticks before first attack */ + int set_timer; /* ticks until next set spawn (starts at 72) */ + int set_interval; /* 350 ticks between set spawns */ + int enraged; /* 1 when HP < 240 */ + + int healer_spawned; /* 1 when healers have been spawned */ + int jad_spawned; /* 1 when jad has been spawned during shield phase */ + + /* set timer pause: pauses between HP 600→480, resumes with +175 when jad spawns */ + int timer_paused; + int has_paused; +} InfZukState; + +/* ======================================================================== */ +/* weapon sets and pre-computed stats */ +/* ======================================================================== */ + +typedef enum { + INF_GEAR_MAGE = 0, + INF_GEAR_TBOW, + INF_GEAR_BP, + INF_NUM_WEAPON_SETS +} InfWeaponSet; + +/* gear loadout arrays per weapon set */ +static const uint8_t INF_MAGE_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND, + [GEAR_SLOT_SHIELD] = ITEM_CRYSTAL_SHIELD, + [GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP, + [GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_TBOW_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW, + [GEAR_SLOT_SHIELD] = ITEM_NONE, /* tbow is 2h */ + [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, + [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_BP_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_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_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +/* pointer array for loadout switching */ +static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { + INF_MAGE_LOADOUT, + INF_RANGE_TBOW_LOADOUT, + INF_RANGE_BP_LOADOUT, +}; + +/* tank overlay items (justiciar) */ +#define INF_TANK_HEAD ITEM_JUSTICIAR_FACEGUARD +#define INF_TANK_BODY ITEM_JUSTICIAR_CHESTGUARD +#define INF_TANK_LEGS ITEM_JUSTICIAR_LEGGUARDS + +/* ======================================================================== */ +/* encounter state */ +/* ======================================================================== */ + +typedef struct { + Player player; + + InfNPC npcs[INF_MAX_NPCS]; + InfPillar pillars[INF_NUM_PILLARS]; + InfZukState zuk; + + /* dead mob store for mager resurrection */ + InfDeadMob dead_mobs[INF_MAX_DEAD_MOBS]; + int dead_mob_count; + + /* LOS blockers (rebuilt when pillars change) */ + LOSBlocker los_blockers[INF_NUM_PILLARS]; + int los_blocker_count; + + /* wave tracking */ + int wave; /* current wave (0-indexed, 0-68) */ + int 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 damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_correct_this_tick; /* count of NPC attacks blocked by prayer this tick */ + int wave_completed_this_tick; + int pillar_lost_this_tick; /* -1 = none, 0-2 = which pillar was destroyed */ + + /* cumulative stats for diagnostics */ + float total_damage_dealt; + float total_damage_received; + int total_waves_cleared; + int ticks_without_action; /* consecutive ticks with no attack or movement */ + int total_prayer_correct; /* times prayer blocked an NPC attack */ + int total_npc_attacks; /* total NPC attacks on player (for prayer_correct_rate) */ + int total_unavoidable_off; /* off-prayer hits where a different style was correctly prayed */ + /* per-tick tracking for multi-style analysis */ + int tick_styles_fired; /* bitmask of styles that fired this tick (bit0=mel,1=rng,2=mag) */ + int tick_attacks_fired; /* count of NPC attacks that fired this tick */ + /* 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[8]; /* 8 = INF_NUM_ACTION_HEADS (defined later in file) */ + 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 */ + + /* gear state */ + InfWeaponSet weapon_set; + EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; + int armor_tank; /* 1 = justiciar overlay active */ + int stamina_active_ticks; /* countdown for stamina effect */ + int spell_choice; /* 0 = blood barrage, 1 = ice barrage */ + + /* per-tick player attack event for renderer projectiles */ + int player_attacked_this_tick; /* 1 if player fired an attack this tick */ + int player_attack_npc_idx; /* NPC index targeted by player attack */ + int player_attack_dmg; /* total damage dealt */ + int player_attack_style_id; /* ATTACK_STYLE_* of the player attack */ + + /* pending hits on player from NPC attacks (projectiles in flight) */ + EncounterPendingHit player_pending_hits[ENCOUNTER_MAX_PENDING_HITS]; + int player_pending_hit_count; + 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]; + + /* NPC occupancy grid: tile -> NPC index+1 (0 = empty). + covers the 29x30 arena. nibblers excluded (transparent to movement). */ + uint8_t npc_occupancy[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; + + /* config */ + int start_wave; /* for curriculum: start from a later wave */ + uint32_t rng_state; + + 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); +} + +/* ======================================================================== */ +/* LOS helper: rebuild blocker array from active pillars */ +/* ======================================================================== */ + +static void inf_rebuild_los(InfernoState* s) { + s->los_blocker_count = 0; + for (int i = 0; i < INF_NUM_PILLARS; i++) { + if (s->pillars[i].active) { + LOSBlocker* b = &s->los_blockers[s->los_blocker_count++]; + b->x = s->pillars[i].x; + b->y = s->pillars[i].y; + b->size = INF_PILLAR_SIZE; + b->los_mask = LOS_FULL_MASK; + } + } +} + +/* check if NPC at index i has LOS to player (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)); +} + +/* ======================================================================== */ +/* dead mob store for mager resurrection */ +/* ======================================================================== */ + +static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { + if (s->dead_mob_count >= INF_MAX_DEAD_MOBS) return; + /* only store resurrectable types (not healers, not shield, not jad/zuk) */ + if (npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || + npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_ZUK || + npc->type == INF_NPC_JAD) return; + + InfDeadMob* dm = &s->dead_mobs[s->dead_mob_count++]; + dm->type = npc->type; + dm->x = npc->x; + dm->y = npc->y; + dm->hp = npc->max_hp / 2; /* resurrect at 50% HP */ + dm->max_hp = npc->max_hp; +} + +/* ======================================================================== */ +/* forward declarations */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s); +static void inf_spawn_wave(InfernoState* s); +static void inf_tick_npcs(InfernoState* s); +static void inf_tick_player(InfernoState* s, const int* actions); +static void inf_apply_npc_death(InfernoState* s, int npc_idx); +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); + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* inf_create(void) { + InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); + s->rng_state = 12345; + return (EncounterState*)s; +} + +static void inf_destroy(EncounterState* state) { + free(state); +} + +static void inf_reset(EncounterState* state, uint32_t seed) { + 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; + 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); + + /* human click-to-move: no destination after reset */ + s->player_dest_x = -1; + s->player_dest_y = -1; + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + s->player.base_hitpoints = 99; + s->player.current_hitpoints = 99; + s->player.base_prayer = 99; + s->player.current_prayer = 99; + s->player.base_attack = 99; + s->player.base_strength = 99; + s->player.base_defence = 99; + s->player.base_ranged = 99; + s->player.base_magic = 99; + s->player.current_ranged = 99; + s->player.current_magic = 99; + s->player.current_attack = 99; + s->player.current_strength = 99; + s->player.current_defence = 99; + /* start in mage gear (kodai + crystal shield + ancestral) */ + s->weapon_set = INF_GEAR_MAGE; + s->armor_tank = 0; + encounter_apply_loadout(&s->player, INF_MAGE_LOADOUT, GEAR_MAGE); + { + uint8_t tank_extra[NUM_GEAR_SLOTS]; + memset(tank_extra, ITEM_NONE, NUM_GEAR_SLOTS); + tank_extra[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + tank_extra[GEAR_SLOT_BODY] = INF_TANK_BODY; + tank_extra[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, tank_extra); + } + s->player.brew_doses = 32; /* 8 pots x 4 doses */ + s->player.restore_doses = 40; /* 10 pots x 4 doses */ + s->player.bastion_doses = 4; /* 1 pot x 4 doses */ + s->player.stamina_doses = 4; /* 1 pot x 4 doses */ + s->stamina_active_ticks = 0; + s->player.prayer = PRAYER_NONE; + osrs_interaction_init(&s->interaction); + s->player.spec_armed = 0; + s->player.special_energy = 100; + s->player.special_regen_ticks = 0; + 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) */ + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, 99, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + + /* spawn position depends on wave */ + int is_zuk_wave = (saved_start >= 68); + s->player.x = is_zuk_wave ? INF_ZUK_PLAYER_START_X : INF_PLAYER_START_X; + s->player.y = is_zuk_wave ? INF_ZUK_PLAYER_START_Y : INF_PLAYER_START_Y; + + /* pillars: all destroyed at end of wave 66 (index 65), so waves 66+ have none */ + 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; +} + +/* ======================================================================== */ +/* spawn: place NPCs for current wave */ +/* ======================================================================== */ + +/* find a free NPC slot, return index or -1 */ +static int inf_find_free_npc(InfernoState* s) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active) return i; + } + return -1; +} + +/* initialize an NPC at a given slot */ +static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y) { + InfNPC* npc = &s->npcs[idx]; + const InfNPCStats* stats = &INF_NPC_STATS[type]; + memset(npc, 0, sizeof(InfNPC)); + + npc->type = type; + npc->hp = stats->hp; + npc->max_hp = stats->hp; + npc->size = stats->size; + npc->attack_timer = stats->attack_speed; + npc->attack_style = stats->default_style; + npc->active = 1; + npc->x = x; + npc->y = y; + npc->target_x = x; + npc->target_y = y; + npc->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; + + /* stamp occupancy grid (nibblers excluded — transparent to movement) */ + if (type != INF_NPC_NIBBLER) { + for (int dx = 0; dx < stats->size; dx++) { + for (int dy = 0; dy < stats->size; dy++) { + int gx = x + dx - INF_ARENA_MIN_X; + int gy = y + dy - INF_ARENA_MIN_Y; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) + s->npc_occupancy[gx][gy] = (uint8_t)(idx + 1); + } + } + } +} + +static void inf_spawn_wave(InfernoState* s) { + if (s->wave >= INF_NUM_WAVES) return; + + const InfWaveDef* w = &INF_WAVES[s->wave]; + + /* clear all NPCs and pending hits */ + for (int i = 0; i < INF_MAX_NPCS; i++) s->npcs[i].active = 0; + s->player_pending_hit_count = 0; + + /* clear dead mob store each wave */ + s->dead_mob_count = 0; + + /* shuffle spawn positions */ + inf_shuffle_spawns(s); + + /* pick random active pillar for nibblers this wave */ + { + int active_pillars[INF_NUM_PILLARS]; + int num_active = 0; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (s->pillars[p].active) active_pillars[num_active++] = p; + } + s->nibbler_target_pillar = (num_active > 0) + ? active_pillars[encounter_rand_int(&s->rng_state, num_active)] : -1; + } + + /* zuk wave (wave 69, index 68) is special */ + if (s->wave == 68) { + /* spawn Zuk — fixed position, cannot move */ + int zuk_idx = inf_find_free_npc(s); + if (zuk_idx >= 0) { + inf_init_npc(s, zuk_idx, INF_NPC_ZUK, 22, 50); + /* 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; + return; + } + + /* regular waves: spawn NPCs at shuffled positions */ + int spawn_idx = 0; + for (int i = 0; i < w->count && i < INF_MAX_NPCS; i++) { + InfNPCType type = (InfNPCType)w->types[i]; + int slot = inf_find_free_npc(s); + if (slot < 0) break; + + int sx, sy; + if (type == INF_NPC_NIBBLER) { + /* nibblers spawn near pillars with small random offset */ + sx = INF_NIBBLER_SPAWN_X + encounter_rand_int(&s->rng_state, 3) - 1; + sy = INF_NIBBLER_SPAWN_Y + encounter_rand_int(&s->rng_state, 3) - 1; + } else { + int pi = s->spawn_order[spawn_idx % INF_NUM_SPAWN_POS]; + sx = INF_SPAWN_POS[pi][0]; + sy = INF_SPAWN_POS[pi][1]; + spawn_idx++; + } + + inf_init_npc(s, slot, type, sx, sy); + } +} + +/* ======================================================================== */ +/* NPC AI: movement */ +/* ======================================================================== */ + +static int inf_in_arena(int x, int y) { + return x >= INF_ARENA_MIN_X && x <= INF_ARENA_MAX_X && + y >= INF_ARENA_MIN_Y && y <= INF_ARENA_MAX_Y; +} + +static int inf_blocked_by_pillar(InfernoState* s, int x, int y, int size) { + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!s->pillars[p].active) continue; + if (los_aabb_overlap(x, y, size, + s->pillars[p].x, s->pillars[p].y, INF_PILLAR_SIZE)) + return 1; + } + return 0; +} + +/* BFS dynamic obstacle callback — pillars block pathfinding. + receives absolute world coords, converts to local for pillar check. */ +static int inf_pathfind_blocked(void* ctx, int abs_x, int abs_y) { + InfernoState* s = (InfernoState*)ctx; + int lx = abs_x - s->world_offset_x; + int ly = abs_y - s->world_offset_y; + return inf_blocked_by_pillar(s, lx, ly, 1); +} + +/* rebuild NPC occupancy grid from scratch. + marks each non-nibbler active NPC's footprint on the 29x30 arena grid. + value = NPC index + 1 (0 = empty). call at start of NPC tick phase. */ +static void inf_rebuild_occupancy(InfernoState* s) { + memset(s->npc_occupancy, 0, sizeof(s->npc_occupancy)); + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + if (npc->type == INF_NPC_NIBBLER) continue; + int sz = INF_NPC_STATS[npc->type].size; + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + int gx = npc->x + dx - INF_ARENA_MIN_X; + int gy = npc->y + dy - INF_ARENA_MIN_Y; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) + s->npc_occupancy[gx][gy] = (uint8_t)(i + 1); + } + } + } +} + +/* update occupancy grid after a single NPC moves from (ox,oy) to (nx,ny). */ +static void inf_update_occupancy(InfernoState* s, int idx, int ox, int oy, int nx, int ny, int sz) { + /* clear old footprint */ + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + int gx = ox + dx - INF_ARENA_MIN_X; + int gy = oy + dy - INF_ARENA_MIN_Y; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) + s->npc_occupancy[gx][gy] = 0; + } + } + /* stamp new footprint */ + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + int gx = nx + dx - INF_ARENA_MIN_X; + int gy = ny + dy - INF_ARENA_MIN_Y; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) + s->npc_occupancy[gx][gy] = (uint8_t)(idx + 1); + } + } +} + +/* check if an NPC footprint at (x,y) with given size overlaps another NPC via occupancy grid */ +static int inf_occupancy_blocked(InfernoState* s, int self_idx, int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int gx = x + dx - INF_ARENA_MIN_X; + int gy = y + dy - INF_ARENA_MIN_Y; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) { + uint8_t occ = s->npc_occupancy[gx][gy]; + if (occ != 0 && (int)(occ - 1) != self_idx) + return 1; + } + } + } + return 0; +} + +/* NPC movement blocked callback for encounter_npc_step_toward. + checks arena bounds, pillars, collision map, and NPC-vs-NPC collision via occupancy grid. */ +typedef struct { InfernoState* s; int self_idx; } InfMoveCtx; + +static int inf_npc_blocked(void* ctx, int x, int y, int size) { + InfMoveCtx* mc = (InfMoveCtx*)ctx; + InfernoState* s = mc->s; + if (!inf_in_arena(x, y)) return 1; + if (inf_blocked_by_pillar(s, x, y, size)) return 1; + if (s->collision_map && + !collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y)) + return 1; + return inf_occupancy_blocked(s, mc->self_idx, x, y, size); +} + +/* forward declaration — defined after potions/food section */ +static int inf_tile_walkable(void* ctx, int x, int y); + +static void inf_npc_move(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) return; + if (npc->dig_freeze_timer > 0) return; + if (npc->frozen_ticks > 0) return; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + if (!stats->can_move) return; + + /* OSRS: NPC shuffles off player tile when overlapping (Mob.ts:109-153). + if the NPC steps out, skip further movement this tick. */ + if (npc->type != INF_NPC_NIBBLER) { + int ox = npc->x, oy = npc->y; + int stepped = encounter_npc_step_out_from_under( + &npc->x, &npc->y, npc->size, + s->player.x, s->player.y, + inf_tile_walkable, s, &s->rng_state); + if (stepped) { + npc->moved_this_tick = 1; + inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); + return; + } + } + + /* ranged/magic NPCs stop moving when they have LOS to the player. + this is the core OSRS mechanic: NPCs only walk toward their target + while they cannot see it. once LOS is established, they attack. */ + if (npc->type != INF_NPC_NIBBLER && stats->attack_range > 1) { + if (npc->aggro_target < 0 && inf_npc_has_los(s, idx)) return; + } + + /* target selection */ + int tx, ty; + if (npc->type == INF_NPC_NIBBLER) { + int p = s->nibbler_target_pillar; + if (p >= 0 && p < INF_NUM_PILLARS && s->pillars[p].active) { + tx = s->pillars[p].x; + ty = s->pillars[p].y; + } else { + int found = 0; + for (int pp = 0; pp < INF_NUM_PILLARS; pp++) { + if (s->pillars[pp].active) { + tx = s->pillars[pp].x; + ty = s->pillars[pp].y; + found = 1; + break; + } + } + if (!found) { tx = s->player.x; ty = s->player.y; } + } + } else 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; + } 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; + + /* greedy step toward target using shared helper. + target_size=1 for player, attack_range from NPC stats. + the shared function stops automatically when within attack range. */ + int ox = npc->x, oy = npc->y; + InfMoveCtx mc = { s, idx }; + int target_size = 1; + if (npc->type == INF_NPC_NIBBLER) { + target_size = INF_PILLAR_SIZE; + } else if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && + s->npcs[npc->aggro_target].active) { + target_size = s->npcs[npc->aggro_target].size; + } + encounter_npc_step_toward(&npc->x, &npc->y, tx, ty, npc->size, + target_size, stats->attack_range, + inf_npc_blocked, &mc); + if (npc->x != ox || npc->y != oy) { + npc->moved_this_tick = 1; + if (npc->type != INF_NPC_NIBBLER) + inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); + } +} + +/* ======================================================================== */ +/* NPC AI: meleer dig mechanic */ +/* ======================================================================== */ + +/* meleer digs when no LOS for 38+ ticks, 10% per tick, forced at 50 */ +static void inf_meleer_dig_check(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MELEER || !npc->active) return; + if (npc->dig_freeze_timer > 0) { + npc->dig_freeze_timer--; + if (npc->dig_freeze_timer == 0 && npc->dig_attack_delay == 0) { + /* emerge: place near player */ + int ox = npc->x, oy = npc->y; + npc->x = s->player.x + (encounter_rand_int(&s->rng_state, 3) - 1); + npc->y = s->player.y + (encounter_rand_int(&s->rng_state, 3) - 1); + inf_update_occupancy(s, idx, ox, oy, 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; + } + } +} + +/* ======================================================================== */ +/* NPC AI: attacks */ +/* ======================================================================== */ + +static void inf_npc_attack(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) { npc->stun_timer--; return; } + if (npc->dig_freeze_timer > 0) return; + if (npc->dig_attack_delay > 0) return; + 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; + 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. + zuk healers excluded — they have their own handler below that HEALS instead of damages. */ + if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS + && npc->type != INF_NPC_HEALER_ZUK) { + 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); + encounter_damage_npc(&target->hp, &target->hit_landed_this_tick, + &target->hit_damage, dmg); + /* shield death: redirect all NPCs targeting it to the player */ + if (target->hp <= 0 && target->type == INF_NPC_ZUK_SHIELD) { + target->active = 0; + 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_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_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 targeted this healer, attack player in melee range. */ + if (encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, 1) == 1) { + int max_hit = osrs_npc_melee_max_hit(stats->str_level, stats->melee_str_bonus); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + /* accuracy roll */ + int att_roll = osrs_npc_attack_roll(stats->att_level, stats->melee_att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus = ls->def_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; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + } + 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 */ + npc->blob_scanned_prayer = (int)s->player.prayer; + npc->attacked_this_tick = 1; /* triggers scan animation */ + npc->attack_timer = stats->attack_speed; /* 3 */ + return; + } + /* has pending scan → determine style and fall through to fire */ + OverheadPrayer read_prayer = (OverheadPrayer)npc->blob_scanned_prayer; + if (read_prayer == PRAYER_PROTECT_MAGIC) + npc->attack_style = ATTACK_STYLE_RANGED; + else if (read_prayer == PRAYER_PROTECT_RANGED) + npc->attack_style = ATTACK_STYLE_MAGIC; + else + npc->attack_style = (encounter_rand_int(&s->rng_state, 2) == 0) + ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + npc->blob_scanned_prayer = -1; + /* fall through to common attack code */ + } + + /* determine actual attack style */ + int actual_style = npc->attack_style; + + /* jad: random 50/50 range or magic each attack */ + if (npc->type == INF_NPC_JAD) { + actual_style = (encounter_rand_int(&s->rng_state, 2) == 0) ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; + npc->jad_attack_style = actual_style; + } + + /* zuk: typeless attack (not blockable by prayer). + 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_visual_target = si; + } else { + /* typeless hit on player — not blockable by prayer, no accuracy roll. + queued as pending hit with 4-tick delay (InfernoTrainer: setDelay=4). */ + 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; + } + s->last_hit_by_type = INF_NPC_ZUK; + npc->attacked_this_tick = 1; + /* attack_visual_target = -1 (player), already default */ + } + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + + /* melee switchover for ranger/mager: when close */ + if (stats->can_melee && dist == 1) { + actual_style = ATTACK_STYLE_MELEE; + } + + if (npc->type == INF_NPC_MAGER && + actual_style == ATTACK_STYLE_MAGIC && + inf_mager_resurrect(s, idx)) { + npc->attacked_this_tick = 1; + 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 dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + + /* accuracy roll: NPC attack roll vs player defence roll */ + { + int att_lvl, att_bonus; + if (actual_style == ATTACK_STYLE_MELEE) { + att_lvl = stats->att_level; att_bonus = stats->melee_att_bonus; + } else if (actual_style == ATTACK_STYLE_RANGED) { + att_lvl = stats->range_level; att_bonus = stats->range_att_bonus; + } else { + att_lvl = stats->magic_level; att_bonus = stats->magic_att_bonus; + } + int att_roll = osrs_npc_attack_roll(att_lvl, att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus = 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]++; } + 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]++; } + } + 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 = hit_delay; + ph->attack_style = actual_style; + ph->check_prayer = is_jad ? 1 : 0; + } + } + + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + + /* jad attack speed varies by wave */ + if (npc->type == INF_NPC_JAD) { + if (s->wave == 66) npc->attack_timer = 8; /* wave 67 */ + else if (s->wave == 67) npc->attack_timer = 9; /* wave 68 */ + else npc->attack_timer = 8; /* zuk wave */ + } +} + +/* ======================================================================== */ +/* NPC AI: mager resurrection */ +/* ======================================================================== */ + +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; + + /* 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; +} + +/* ======================================================================== */ +/* NPC AI: jad healer spawning */ +/* ======================================================================== */ + +static void inf_jad_check_healers(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_JAD || !npc->active) return; + if (npc->jad_healer_spawned) return; + + /* spawn healers when below 50% HP */ + if (npc->hp > npc->max_hp / 2) return; + npc->jad_healer_spawned = 1; + + int num_healers; + if (s->wave == 66) num_healers = 5; /* wave 67: 5 healers */ + else if (s->wave == 67) num_healers = 3; /* wave 68: 3 per jad */ + else num_healers = 3; /* zuk wave: 3 */ + + for (int h = 0; h < num_healers; h++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + int hx = npc->x + encounter_rand_int(&s->rng_state, 5) - 2; + int hy = npc->y + encounter_rand_int(&s->rng_state, 5) - 2; + inf_init_npc(s, slot, INF_NPC_HEALER_JAD, hx, hy); + s->npcs[slot].jad_owner_idx = idx; + s->npcs[slot].aggro_target = idx; + } +} + +/* ======================================================================== */ +/* NPC AI: zuk phases */ +/* ======================================================================== */ + +static void inf_zuk_tick(InfernoState* s) { + if (s->wave != 68) return; + + /* find zuk NPC */ + int zuk_idx = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { + zuk_idx = i; + break; + } + } + if (zuk_idx < 0) return; + InfNPC* zuk = &s->npcs[zuk_idx]; + + /* shield oscillation */ + int si = s->zuk.shield_idx; + if (si >= 0 && s->npcs[si].active) { + InfNPC* shield = &s->npcs[si]; + if (s->zuk.shield_freeze > 0) { + s->zuk.shield_freeze--; + } else { + shield->x += s->zuk.shield_dir; + /* boundary check: 5-tick freeze at edges */ + if (shield->x < 11) { + shield->x = 11; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = 1; + } else if (shield->x > 35) { + shield->x = 35; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = -1; + } + } + } + + /* set timer 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++) { + s->npcs[i].active = 0; + } + } +} + +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); + 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; +} + +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; + + 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, + 14 + 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, + 14 + 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; + } +} + +/* ======================================================================== */ +/* NPC AI: tick all NPCs */ +/* ======================================================================== */ + +static void inf_tick_npcs(InfernoState* s) { + /* NPC per-tick flags are cleared in inf_step BEFORE inf_tick_player, + so player hit flags survive through both tick functions into render_post_tick. */ + + /* zuk-specific phases first */ + inf_zuk_tick(s); + inf_rebuild_occupancy(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) s->npcs[i].active = 0; + continue; /* dying NPCs don't move or attack */ + } + + /* decrement ice barrage freeze timer */ + if (s->npcs[i].frozen_ticks > 0) s->npcs[i].frozen_ticks--; + + 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); + } +} + +/* ======================================================================== */ +/* player actions */ +/* ======================================================================== */ + +#define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ +#define INF_HEAD_PRAYER 1 /* 5: no_change, off, melee, range, mage (ENCOUNTER_PRAYER_DIM) */ +#define INF_HEAD_TARGET 2 /* INF_MAX_NPCS+1: none or NPC index */ +#define INF_HEAD_GEAR 3 /* 5: no_switch, mage, tbow, bp, tank */ +#define INF_HEAD_EAT 4 /* 2: none, brew */ +#define INF_HEAD_POTION 5 /* 4: none, restore, bastion, stamina */ +#define INF_HEAD_SPELL 6 /* 3: no_change, blood_barrage, ice_barrage */ +#define INF_HEAD_SPEC 7 /* 2: no_change, toggle (arm/disarm blowpipe spec) */ +#define INF_NUM_ACTION_HEADS 8 + +static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 }; +#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_MAX_NPCS+1 + 5 + 2 + 4 + 3 + 2) + +/* movement uses shared encounter_move_to_target from osrs_encounter.h */ + +/* walkability callback for encounter_move_to_target */ +static int inf_tile_walkable(void* ctx, int x, int y) { + InfernoState* s = (InfernoState*)ctx; + if (!inf_in_arena(x, y)) return 0; + if (inf_blocked_by_pillar(s, x, y, 1)) return 0; + if (s->collision_map) + return collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y); + return 1; +} + +/* 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) { + s->npcs[j].active = 0; + } + } + } +} + +static void inf_player_pretick(InfernoState* s, const int* actions) { + /* prayer switches become active in pretick, then drain uses the updated + overhead before NPCs act. ref: osrs-sdk World.ts + PrayerController.ts. */ + encounter_apply_prayer_action(&s->player.prayer, actions[INF_HEAD_PRAYER]); + int drain = encounter_prayer_drain_effect(s->player.prayer) + 24; + encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, + 0, &s->player.prayer_drain_counter, drain); +} + +static void inf_tick_player(InfernoState* s, const int* actions) { + /* gear switching */ + int gear_act = actions[INF_HEAD_GEAR]; + if (gear_act >= 1) s->total_gear_switches++; + if (gear_act >= 1 && gear_act <= 3) { + /* 1=mage, 2=tbow, 3=bp */ + InfWeaponSet new_set = (InfWeaponSet)(gear_act - 1); + s->weapon_set = new_set; + s->armor_tank = 0; + GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[new_set], gs); + } else if (gear_act == 4) { + /* tank overlay: justiciar head/body/legs on current loadout */ + s->armor_tank = 1; + s->player.equipped[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + s->player.equipped[GEAR_SLOT_BODY] = INF_TANK_BODY; + s->player.equipped[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + } + + /* auto-detect gear switch from direct inventory equip (human mode). + gui_inv_click mutates p->equipped directly, bypassing the action head. + detect weapon mismatch and sync weapon_set + full loadout. */ + { + uint8_t current_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + if (current_weapon != INF_LOADOUTS[s->weapon_set][GEAR_SLOT_WEAPON]) { + for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { + if (INF_LOADOUTS[g][GEAR_SLOT_WEAPON] == current_weapon) { + s->weapon_set = (InfWeaponSet)g; + GearSet gs = (g == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[g], gs); + break; + } + } + } + } + + /* spell choice for mage attacks — normalize to ENCOUNTER_SPELL_*. + human sends ATTACK_ICE=2 / ATTACK_BLOOD=3, RL sends 0=blood / 1=ice. */ + /* spell: 0=no change, 1=blood barrage, 2=ice barrage */ + int spell_act = actions[INF_HEAD_SPELL]; + if (spell_act == 2) + s->spell_choice = ENCOUNTER_SPELL_ICE; + else if (spell_act == 1) + s->spell_choice = ENCOUNTER_SPELL_BLOOD; + + /* special energy regen: 10 energy every 50 ticks (30 seconds) */ + encounter_tick_spec_regen(&s->player, 0); + + /* 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); + } + + /* 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); + } + + /* 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); + } 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); + } 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_MAX_NPCS) { + int npc_idx = target - 1; + if (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); + has_new_target = 1; + /* tagging: redirect NPC aggro from shield/zuk to player */ + if (s->npcs[npc_idx].aggro_target != -1) { + s->npcs[npc_idx].aggro_target = -1; + s->npcs[npc_idx].stun_timer = 2; /* 2-tick delay on aggro switch */ + } + } + } + /* 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 = &s->loadout_stats[s->weapon_set]; + 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); + } + + /* 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 = &s->loadout_stats[s->weapon_set]; + + /* range + LOS check: must have line of sight through pillars */ + int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size); + + if (encounter_player_can_attack(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size, + ls->attack_range, s->los_blockers, s->los_blocker_count)) { + /* compute hit delay for projectile flight */ + int hit_delay; + if (ls->style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(target_dist, 1); + else if (s->weapon_set == INF_GEAR_BP) + hit_delay = encounter_blowpipe_hit_delay(target_dist, 1); + else + hit_delay = encounter_ranged_hit_delay(target_dist, 1); + + int total_dmg = 0; + + if (s->weapon_set == INF_GEAR_MAGE) { + /* barrage spells: 3x3 AoE via shared osrs_barrage_resolve. + ice barrage: freeze on hit (including 0 dmg), not on splash. + blood barrage: heal 25% of total AoE damage (applied when hits land). */ + int mage_att_roll = ls->eff_level * (ls->attack_bonus + 64); + + /* build target array: primary target first, then all other active NPCs */ + BarrageTarget btargets[INF_MAX_NPCS + 1]; + int bt_count = 0; + { + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = target_npc->x, .y = target_npc->y, + .def_level = ns->def_level, .magic_def_bonus = ns->magic_def_bonus, + .npc_idx = s->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; + const InfNPCStats* ns2 = &INF_NPC_STATS[s->npcs[i].type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = s->npcs[i].x, .y = s->npcs[i].y, + .def_level = ns2->def_level, .magic_def_bonus = ns2->magic_def_bonus, + .npc_idx = i, + .frozen_ticks = &s->npcs[i].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + + /* resolve barrage: accuracy/damage rolls + instant freeze for ice. + freeze is applied by the shared function at cast time. */ + BarrageResult br = osrs_barrage_resolve( + btargets, bt_count, mage_att_roll, ls->max_hit, + &s->rng_state, s->spell_choice); + total_dmg = br.total_damage; + + /* queue pending hits for delayed damage */ + for (int i = 0; i < bt_count; i++) { + if (!btargets[i].active || !btargets[i].hit) continue; + int nidx = btargets[i].npc_idx; + EncounterPendingHit* ph = &s->npcs[nidx].pending_hit; + ph->active = 1; + ph->damage = btargets[i].damage; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_MAGIC; + ph->check_prayer = 0; + ph->spell_type = s->spell_choice; + } + + } else if (s->weapon_set == INF_GEAR_TBOW) { + /* tbow: single target, scale by target magic level. + ls->max_hit is BASE max hit before tbow scaling. */ + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int tbow_m = ns->magic_level > ns->magic_def_bonus + ? ns->magic_level : ns->magic_def_bonus; + if (tbow_m > 250) tbow_m = 250; + float acc_mult = osrs_tbow_acc_mult(tbow_m); + float dmg_mult = osrs_tbow_dmg_mult(tbow_m); + int att_roll = (int)(ls->eff_level * (ls->attack_bonus + 64) * acc_mult); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + int max_hit = (int)(ls->max_hit * dmg_mult); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + } + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = total_dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + + } else if (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 (s->weapon_set == INF_GEAR_MAGE) { + /* 0=none, 1=ice, 2=blood */ + s->player.magic_type_this_tick = (s->spell_choice == ENCOUNTER_SPELL_ICE) ? 1 : 2; + } + } + } + } +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s) { + /* accumulate diagnostic stats BEFORE terminal check so the killing + blow's damage is counted in total_damage_received */ + s->total_damage_dealt += s->damage_dealt_this_tick; + s->total_damage_received += s->damage_received_this_tick; + + if (s->episode_over) + return (s->winner == 0) ? 1.0f : 0.0f; + + float r = 0.0f; + + /* survival: per-tick bonus for staying alive */ + if (s->wave >= 68) + r += 0.001f; + + /* shield positioning: strong signal for the core Zuk mechanic. + this is THE thing we need the agent to learn first. */ + if (s->behind_shield_this_tick) + r += 0.005f; + + if (s->damage_dealt_this_tick > 0.0f) + r += 0.01f * s->damage_dealt_this_tick; + + return r; +} + +/* ======================================================================== */ +/* step */ +/* ======================================================================== */ + +static void inf_step(EncounterState* state, const int* actions) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) return; + + /* clear per-tick state */ + s->reward = 0.0f; + s->damage_dealt_this_tick = 0.0f; + s->damage_received_this_tick = 0.0f; + s->prayer_correct_this_tick = 0; + s->tick_styles_fired = 0; + s->tick_attacks_fired = 0; + s->wave_completed_this_tick = 0; + s->pillar_lost_this_tick = -1; + s->player_attacked_this_tick = 0; + s->brewed_this_tick = 0; + s->blood_heal_this_tick = 0; + 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_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_occupancy(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; + int landed = encounter_resolve_npc_pending_hit( + &s->npcs[i].pending_hit, + &s->npcs[i].hp, &s->npcs[i].hit_landed_this_tick, &s->npcs[i].hit_damage, + &s->npcs[i].frozen_ticks, &blood_heal_acc, &s->damage_dealt_this_tick); + if (landed) { + s->npcs[i].hit_spell_type = spell; + inf_apply_npc_death(s, i); + } + } + if (blood_heal_acc > 0) { + int healed = blood_heal_acc / 4; + s->player.current_hitpoints += healed; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + s->blood_heal_this_tick = healed; + } + } + + encounter_resolve_player_pending_hits( + s->player_pending_hits, &s->player_pending_hit_count, + &s->player, s->player.prayer, + &s->damage_received_this_tick, &s->prayer_correct_this_tick); + inf_resolve_pending_sparks(s); + + /* player actions */ + inf_tick_player(s, actions); + + /* 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].type != INF_NPC_NIBBLER) + s->total_npc_attacks++; + } + /* multi-style analysis: count off-prayer hits that were unavoidable because + a different style was correctly prayed on the same tick. popcount of + tick_styles_fired tells us how many distinct styles fired. if 2+, the + off-prayer hits from non-prayed styles are "unavoidable" (can only pray one). */ + if (s->tick_attacks_fired > 0) { + int styles = s->tick_styles_fired; + int n_styles = ((styles >> 0) & 1) + ((styles >> 1) & 1) + ((styles >> 2) & 1); + if (n_styles >= 2 && s->prayer_correct_this_tick > 0) { + /* we prayed correctly against at least one style, but other styles + also fired — those off-prayer hits were unavoidable */ + int off_prayer = s->tick_attacks_fired - s->prayer_correct_this_tick; + s->total_unavoidable_off += off_prayer; + } + } + if (s->ticks_without_action > 0) s->total_idle_ticks++; + s->total_brews_used += s->brewed_this_tick; + s->total_blood_healed += s->blood_heal_this_tick; + + /* 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]++; + } + + 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 */ + return; + } + + if (spawn_wave_now) { + s->wave = s->wave_spawn_target; + inf_spawn_wave(s); + inf_rebuild_occupancy(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++; + if (s->wave + 1 >= INF_NUM_WAVES) { + s->episode_over = 1; + s->winner = 0; + s->reward = 1.0f; /* player won */ + } 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; + } +} + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +/* obs layout: 49 player + 12 pillar + 33*32 NPC + 5*8 pending hits = 1157 */ +#define INF_PLAYER_OBS_SIZE 49 +#define INF_FEATURES_PER_NPC 33 +#define INF_FEATURES_PER_HIT 5 +#define INF_NUM_OBS (INF_PLAYER_OBS_SIZE + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS + INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) + +/* max hit per NPC type, normalized by mager max (70). for prayer priority obs. */ +static const float INF_NPC_MAX_HIT_NORM[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 0.0f, + [INF_NPC_BAT] = 19.0f / 70.0f, + [INF_NPC_BLOB] = 29.0f / 70.0f, + [INF_NPC_BLOB_MELEE] = 18.0f / 70.0f, + [INF_NPC_BLOB_RANGE] = 18.0f / 70.0f, + [INF_NPC_BLOB_MAGE] = 25.0f / 70.0f, + [INF_NPC_MELEER] = 49.0f / 70.0f, + [INF_NPC_RANGER] = 46.0f / 70.0f, + [INF_NPC_MAGER] = 70.0f / 70.0f, + [INF_NPC_JAD] = 113.0f / 70.0f, + [INF_NPC_ZUK] = 148.0f / 70.0f, + [INF_NPC_HEALER_JAD] = 13.0f / 70.0f, + [INF_NPC_HEALER_ZUK] = 24.0f / 70.0f, + [INF_NPC_ZUK_SHIELD] = 0.0f, +}; + +static void inf_write_obs(EncounterState* state, float* obs) { + InfernoState* s = (InfernoState*)state; + memset(obs, 0, INF_NUM_OBS * sizeof(float)); + int i = 0; + int px = s->player.x, py = s->player.y; + + /* player state (26 features) */ + obs[i++] = (float)s->player.current_hitpoints / 99.0f; + obs[i++] = (float)(px - INF_ARENA_MIN_X) / (float)INF_ARENA_WIDTH; /* 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; + obs[i++] = (float)s->player.brew_doses / 32.0f; + obs[i++] = (float)s->player.restore_doses / 40.0f; + obs[i++] = (float)s->player.current_prayer / 99.0f; + obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; + /* tick normalization: Zuk-only (~300 ticks) vs full runs (~18000 ticks) */ + obs[i++] = (s->start_wave >= 68) ? (float)s->tick / 500.0f + : (float)s->tick / (float)INF_MAX_TICKS; + obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; + obs[i++] = s->armor_tank ? 1.0f : 0.0f; + obs[i++] = (float)s->player.bastion_doses / 4.0f; + obs[i++] = (float)s->player.stamina_doses / 4.0f; + obs[i++] = (s->stamina_active_ticks > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->player.potion_timer / 3.0f; + obs[i++] = (float)s->player.attack_timer / 8.0f; + /* new: combat stats, target, weapon range, dead mob pool */ + obs[i++] = (float)s->player.current_defence / 99.0f; + obs[i++] = (float)s->player.current_ranged / 99.0f; + obs[i++] = (float)s->player.current_magic / 99.0f; + obs[i++] = osrs_interaction_active(&s->interaction) ? 1.0f : 0.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_range / 15.0f; + obs[i++] = (float)s->dead_mob_count / (float)INF_MAX_DEAD_MOBS; + /* gear stats: current loadout combat performance */ + obs[i++] = (float)s->loadout_stats[s->weapon_set].max_hit / 80.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_speed / 6.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_stab / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_magic / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_ranged / 300.0f; + obs[i++] = (float)s->player.special_energy / 100.0f; + + /* prayer-critical: distilled from NPC array so the agent doesn't have to + scan 32 slots to figure out what to pray. these directly answer: + "what style should I pray?" and "how urgent is it?" */ + { + int min_timer = 999; + int min_style = 0; /* style of the NPC with lowest timer */ + int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (!npc->active || npc->death_ticks > 0) continue; + const InfNPCStats* st = &INF_NPC_STATS[npc->type]; + if (st->attack_range <= 1 && npc->type != INF_NPC_MELEER) continue; /* skip nibblers */ + /* only count NPCs that can actually attack: has LOS, in range, not frozen/stunned */ + if (npc->frozen_ticks > 0 || npc->stun_timer > 0) continue; + if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc->size); + if (dist == 0 || dist > st->attack_range) continue; + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + if (npc->attack_timer < min_timer) { + min_timer = npc->attack_timer; + min_style = style; + } + if (npc->attack_timer <= 2) { + if (style == ATTACK_STYLE_MELEE) has_melee_2 = 1; + if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; + if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; + } + } + int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; + /* ticks until next enemy attack (0 = firing this tick, 1 = imminent) */ + obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; + /* style of most imminent attacker (one-hot) */ + obs[i++] = (min_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + /* how many distinct styles fire within 2 ticks (0=safe, 1=single pray, 2+=conflict) */ + obs[i++] = (float)conflict_count / 3.0f; + } + + /* 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) { + /* shield direction: +1 east, -1 west, 0 frozen */ + obs[i++] = (s->zuk.shield_freeze > 0) ? 0.0f : (float)s->zuk.shield_dir; + /* shield freeze ticks remaining / 5 */ + obs[i++] = (float)s->zuk.shield_freeze / 5.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; + 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; + 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; + } + + /* NPCs: INF_FEATURES_PER_NPC (33) features each, up to INF_MAX_NPCS */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (npc->active && npc->death_ticks == 0) { + obs[i++] = 1.0f; + /* type one-hot (14 features) */ + for (int t = 0; t < INF_NUM_NPC_TYPES; t++) + obs[i++] = ((int)npc->type == t) ? 1.0f : 0.0f; + obs[i++] = (float)npc->hp / (float)npc->max_hp; + /* relative position to player */ + obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; + obs[i++] = (float)npc->attack_timer / 10.0f; + /* attack style: for jad, use jad_attack_style (the actual per-attack style) + since npc->attack_style stays at the default forever */ + { + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + obs[i++] = (style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + } + obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; + obs[i++] = (float)npc->frozen_ticks / 32.0f; + obs[i++] = INF_NPC_MAX_HIT_NORM[npc->type]; + /* blob scan state (3-feature one-hot: magic / ranged / other) */ + if (npc->type == INF_NPC_BLOB && 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; + } + obs[i++] = (float)INF_NPC_STATS[npc->type].attack_range / 100.0f; + obs[i++] = (float)INF_NPC_STATS[npc->type].magic_def_bonus / 350.0f; + /* barrage AoE count: unique NPCs in 3x3 area via occupancy grid */ + { + int aoe_count = 0; + uint32_t seen = 0; /* bitmask of NPC indices already counted */ + int cx = npc->x - INF_ARENA_MIN_X; + int cy = npc->y - INF_ARENA_MIN_Y; + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + int gx = cx + dx, gy = cy + dy; + if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) { + uint8_t occ = s->npc_occupancy[gx][gy]; + if (occ != 0) { + int oidx = (int)(occ - 1); + if (oidx != n && !(seen & (1u << oidx))) { + seen |= (1u << oidx); + aoe_count++; + } + } + } + } + } + obs[i++] = (float)aoe_count / 8.0f; + } + /* NPC aggro target: 1.0 if targeting player, 0.0 if targeting another NPC + (pillar/shield/Zuk). tells agent which NPCs need tagging off pillars/shield. */ + obs[i++] = (npc->aggro_target < 0) ? 1.0f : 0.0f; + /* is this NPC the player's current attack target? */ + obs[i++] = (osrs_interaction_active(&s->interaction) && + s->interaction.target_slot == n) ? 1.0f : 0.0f; + } else { + for (int j = 0; j < INF_FEATURES_PER_NPC; j++) obs[i++] = 0.0f; + } + } + + /* assert NPC section wrote exactly the right number of features. + if this fires, INF_FEATURES_PER_NPC doesn't match the actual feature count. */ + { + int expected_npc_end = INF_PLAYER_OBS_SIZE + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS; + if (i != expected_npc_end) { + fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d " + "(INF_FEATURES_PER_NPC=%d, actual=%d per slot)\n", + i, expected_npc_end, INF_FEATURES_PER_NPC, + (i - INF_PLAYER_OBS_SIZE - 12) / INF_MAX_NPCS); + abort(); + } + } + + /* pending hits on player (INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) */ + for (int h = 0; h < ENCOUNTER_MAX_PENDING_HITS; h++) { + if (h < s->player_pending_hit_count) { + EncounterPendingHit* ph = &s->player_pending_hits[h]; + obs[i++] = 1.0f; /* active */ + obs[i++] = (ph->attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (ph->attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (float)ph->ticks_remaining / 10.0f; + obs[i++] = (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; + } + } + + /* sanity: verify we wrote exactly INF_NUM_OBS features */ + if (i != INF_NUM_OBS) { + fprintf(stderr, "BUG: inf_write_obs wrote %d features, expected %d\n", i, INF_NUM_OBS); + abort(); + } +} + +static void inf_write_mask(EncounterState* state, float* mask) { + InfernoState* s = (InfernoState*)state; + int offset = 0; + + /* HEAD_MOVE (25): idle always valid, walk/run valid if target tile reachable */ + mask[offset++] = 1.0f; /* idle always valid */ + for (int d = 1; d < ENCOUNTER_MOVE_ACTIONS; d++) { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[d]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[d]; + mask[offset++] = (inf_in_arena(nx, ny) && !inf_blocked_by_pillar(s, nx, ny, 1)) + ? 1.0f : 0.0f; + } + + /* HEAD_PRAYER (5): 0=no change (always valid), 1-4=switch (mask out current) */ + mask[offset++] = 1.0f; /* no change — always valid */ + mask[offset++] = (s->player.prayer != PRAYER_NONE) ? 1.0f : 0.0f; + mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + mask[offset++] = (s->player.prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + + /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive (not dying) */ + mask[offset++] = 1.0f; /* no target */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + mask[offset++] = (s->npcs[n].active && s->npcs[n].death_ticks == 0) ? 1.0f : 0.0f; + } + + /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ + mask[offset++] = 1.0f; /* no_switch always valid */ + mask[offset++] = (s->weapon_set != INF_GEAR_MAGE || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_TBOW || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_BP || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = 1.0f; /* tank toggle always allowed */ + + /* HEAD_EAT (2): none, brew */ + mask[offset++] = 1.0f; /* none always valid */ + mask[offset++] = (s->player.brew_doses > 0 && + s->player.potion_timer == 0 && + s->player.current_hitpoints < s->player.base_hitpoints) + ? 1.0f : 0.0f; + + /* HEAD_POTION (4): none, restore, bastion, stamina */ + mask[offset++] = 1.0f; /* none always valid */ + /* restore: unmask if any stat is drained or prayer is low enough to not waste. + "stats drained" = any combat stat below base 99. */ + { + int pray_missing = s->player.base_prayer - s->player.current_prayer; + int stats_drained = s->player.current_attack < 99 || s->player.current_strength < 99 || + s->player.current_defence < 99 || s->player.current_ranged < 99 || + s->player.current_magic < 99; + int pray_worth = pray_missing >= (INF_RESTORE_AMOUNT + 1) / 2; + mask[offset++] = (s->player.restore_doses > 0 && + s->player.potion_timer == 0 && + (stats_drained || pray_worth)) + ? 1.0f : 0.0f; + } + /* bastion: only worth drinking at 99-105 ranged (drained = restore first, >105 = still boosted) */ + mask[offset++] = (s->player.bastion_doses > 0 && s->player.potion_timer == 0 && + s->player.current_ranged >= 99 && s->player.current_ranged <= 105) + ? 1.0f : 0.0f; + /* stamina: mask if no doses, timer active, or already active */ + mask[offset++] = (s->player.stamina_doses > 0 && + s->player.potion_timer == 0 && + s->stamina_active_ticks == 0) + ? 1.0f : 0.0f; + + /* HEAD_SPELL (3): no_change, blood_barrage, ice_barrage. + noop always valid. blood masked at full HP. both spells masked when not in mage gear. */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE && + s->player.current_hitpoints < s->player.base_hitpoints) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + + /* HEAD_SPEC (2): no_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; +} + +/* ======================================================================== */ +/* query functions */ +/* ======================================================================== */ + +static float inf_get_reward(EncounterState* state) { + return ((InfernoState*)state)->reward; +} + +static int inf_is_terminal(EncounterState* state) { + return ((InfernoState*)state)->episode_over; +} + +static int inf_get_entity_count(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + int count = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) + if (s->npcs[i].active) count++; + return count; +} + +static void* inf_get_entity(EncounterState* state, int index) { + InfernoState* s = (InfernoState*)state; + /* only index 0 (player) returns a valid Player*. + * NPC indices can't return Player* since InfNPC is a different struct. + * GUI/human input code must NULL-check. */ + if (index == 0) return &s->player; + return NULL; +} + +/* render entity population */ +static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + InfernoState* s = (InfernoState*)state; + int n = 0; + + /* GUI reads combat_potion_doses/ranged_potion_doses generically; + inferno uses bastion/stamina, so map them for the GUI display */ + s->player.combat_potion_doses = s->player.bastion_doses; + s->player.ranged_potion_doses = s->player.stamina_doses; + { + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + s->player.gui_max_hit = ls->max_hit; + s->player.gui_attack_speed = ls->attack_speed; + s->player.gui_attack_range = ls->attack_range; + s->player.gui_strength_bonus = ls->strength_bonus; + } + + /* index 0: the player */ + if (n < max_entities) { + render_entity_from_player(&s->player, &out[n++]); + } + + /* active NPCs: manually fill since InfNPC is not a Player */ + for (int i = 0; i < INF_MAX_NPCS && n < max_entities; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + + RenderEntity* re = &out[n++]; + memset(re, 0, sizeof(RenderEntity)); + memset(re->equipped, ITEM_NONE, NUM_GEAR_SLOTS); + re->entity_type = ENTITY_NPC; + re->npc_def_id = INF_NPC_DEF_IDS[npc->type]; + re->npc_slot = i; + /* 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->attacked_this_tick && nm && nm->attack_anim != 65535) { + re->npc_anim_id = (int)nm->attack_anim; + } else { + /* walk/idle handled by secondary track in render_client_tick. + setting walk as primary causes stall (interleave_count==0) + which freezes movement and creates tile-to-tile teleporting. */ + re->npc_anim_id = -1; + } + } + re->x = npc->x; + re->y = npc->y; + /* nibblers: set dest to pillar center so renderer faces them toward + the pillar instead of the player when idle/attacking */ + if (npc->type == INF_NPC_NIBBLER) { + int tp = s->nibbler_target_pillar; + if (tp >= 0 && tp < INF_NUM_PILLARS && s->pillars[tp].active) { + re->dest_x = s->pillars[tp].x + INF_PILLAR_SIZE / 2; + re->dest_y = s->pillars[tp].y + INF_PILLAR_SIZE / 2; + } else { + re->dest_x = npc->x; + re->dest_y = npc->y; + } + } else 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 : 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 */ + if (strcmp(key, "start_wave") == 0) s->start_wave = (value > 0) ? value - 1 : 0; + else if (strcmp(key, "seed") == 0) s->rng_state = (uint32_t)value; + else if (strcmp(key, "world_offset_x") == 0) s->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) s->world_offset_y = value; + else if (strcmp(key, "player_dest_x") == 0) s->player_dest_x = value; + else if (strcmp(key, "player_dest_y") == 0) s->player_dest_y = value; +} + +static void inf_put_float(EncounterState* state, const char* key, float value) { + (void)state; (void)key; (void)value; +} + +static void inf_put_ptr(EncounterState* state, const char* key, void* value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "collision_map") == 0) s->collision_map = (const CollisionMap*)value; +} + +static int inf_get_tick(EncounterState* state) { + return ((InfernoState*)state)->tick; +} + +static int inf_get_winner(EncounterState* state) { + return ((InfernoState*)state)->winner; +} + +static void* inf_get_log(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.wave += (float)s->wave; + s->log.prayer_correct += (float)s->total_prayer_correct; + s->log.prayer_total += (float)s->total_npc_attacks; + s->log.idle_ticks += (float)s->total_idle_ticks; + s->log.brews_used += (float)s->total_brews_used; + s->log.blood_healed += (float)s->total_blood_healed; + s->log.n += 1.0f; + s->log.npc_kills += (float)s->total_npc_kills; + s->log.gear_switches += (float)s->total_gear_switches; + s->log.current_ranged += (float)s->player.current_ranged; + s->log.current_magic += (float)s->player.current_magic; + } + return &s->log; +} + +/* ======================================================================== */ +/* render post-tick: populate overlay projectiles for renderer */ +/* ======================================================================== */ + + +static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + InfernoState* s = (InfernoState*)state; + ov->projectile_count = 0; + + /* NPC attack projectiles — per-NPC-type flight parameters */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active || !npc->attacked_this_tick) continue; + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) break; + + /* nibblers attack pillars, not worth showing as projectile */ + if (npc->type == INF_NPC_NIBBLER) continue; + + /* blob scan animation (no projectile) — only emit on the actual fire tick. + blob_scanned_prayer >= 0 means scan just happened, -1 means fire. */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) continue; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + int actual_style = stats->default_style; + + /* blob uses per-attack style from prayer reading (ranged vs magic) */ + if (npc->type == INF_NPC_BLOB) + actual_style = npc->attack_style; + + /* jad uses its per-attack random style */ + if (npc->type == INF_NPC_JAD) + actual_style = npc->jad_attack_style; + + /* zuk is typeless — show as magic for visual purposes */ + if (npc->type == INF_NPC_ZUK) + actual_style = ATTACK_STYLE_MAGIC; + + /* 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; + + /* 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_447_MODEL; + break; + case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; /* same model as zuk fireball, differentiated by arc */ + default: break; + } + + /* NPC-specific flight overrides */ + switch (npc->type) { + case INF_NPC_MAGER: + duration += 60; /* visual delay ~2 ticks */ + break; + case INF_NPC_RANGER: + /* SDK: reduceDelay=-2 (adds 2 ticks to hit), visualDelayTicks=3 + (projectile invisible for first 3 ticks). net visual effect: + +2 ticks to flight - 3 ticks hidden = -1 tick visual duration. */ + duration += 60 - 90; /* +2 ticks hit delay, -3 ticks visual delay */ + if (duration < 30) duration = 30; /* minimum 1 game tick visible */ + break; + case INF_NPC_JAD: + if (actual_style == ATTACK_STYLE_MAGIC) { + arc = 1.0f; /* arcing magic projectile */ + } + /* InfernoTrainer JAD_PROJECTILE_DELAY=3: projectile invisible + for first 3 ticks, shorter visible flight. */ + duration -= 3 * 30; + if (duration < 30) duration = 30; + 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; + } + + 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); + + /* 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 = 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_1375_MODEL); + 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; + + uint32_t player_proj_model = 0; + if (s->weapon_set == INF_GEAR_MAGE) { + p_duration = encounter_magic_hit_delay(p_dist, 1) * 30; + p_arc = 0.0f; + /* barrage: no projectile model (effect system handles it) */ + } else if (s->weapon_set == INF_GEAR_TBOW) { + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 1.0f; + player_proj_model = 3136; /* rune arrow (GFX 15) — dragon arrow visually similar */ + } else { + /* blowpipe */ + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 0.5f; + player_proj_model = 26379; /* dragon dart */ + } + + /* barrage has no in-flight projectile in OSRS (only hit splash) */ + if (player_proj_model > 0) { + encounter_emit_projectile(ov, + s->player.x, s->player.y, target->x, target->y, + p_style, s->player_attack_dmg, + p_duration, p_start_h, p_end_h, 16, p_arc, p_tracks, 1, target_size, player_proj_model); + } + } + } +} + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void* inf_get_player_for_input(void* state, int idx) { + InfernoState* s = (InfernoState*)state; + return (idx == 0) ? (void*)&s->player : NULL; +} + +static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, INF_HEAD_MOVE, inf_get_player_for_input, state); + encounter_translate_prayer(hi, actions, INF_HEAD_PRAYER); + encounter_translate_target(hi, actions, INF_HEAD_TARGET); + + /* gear switch */ + if (hi->pending_gear > 0) actions[INF_HEAD_GEAR] = hi->pending_gear; + + /* eat: brew */ + if (hi->pending_food || hi->pending_potion == POTION_BREW) + actions[INF_HEAD_EAT] = 1; + + /* potions: restore=1, bastion=2, stamina=3 */ + if (hi->pending_potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; + + /* spell: 0=no change, 1=blood, 2=ice */ + if (hi->pending_spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; + else if (hi->pending_spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + + /* spec */ + if (hi->pending_spec) actions[INF_HEAD_SPEC] = 1; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_INFERNO = { + .name = "inferno", + .obs_size = INF_NUM_OBS, + .num_action_heads = INF_NUM_ACTION_HEADS, + .action_head_dims = INF_ACTION_DIMS, + .mask_size = INF_ACTION_MASK_SIZE, + + .create = inf_create, + .destroy = inf_destroy, + .reset = inf_reset, + .step = inf_step, + + .write_obs = inf_write_obs, + .write_mask = inf_write_mask, + .get_reward = inf_get_reward, + .is_terminal = inf_is_terminal, + + .get_entity_count = inf_get_entity_count, + .get_entity = inf_get_entity, + .fill_render_entities = inf_fill_render_entities, + + .put_int = inf_put_int, + .put_float = inf_put_float, + .put_ptr = inf_put_ptr, + + .arena_base_x = INF_ARENA_MIN_X, + .arena_base_y = INF_ARENA_MIN_Y, + .arena_width = INF_ARENA_WIDTH, + .arena_height = INF_ARENA_HEIGHT, + + .render_post_tick = inf_render_post_tick, + .get_log = inf_get_log, + .get_tick = inf_get_tick, + .get_winner = inf_get_winner, + + .translate_human_input = inf_translate_human_input, + .head_move = INF_HEAD_MOVE, + .head_prayer = INF_HEAD_PRAYER, + .head_target = INF_HEAD_TARGET, +}; + +__attribute__((constructor)) +static void inf_register(void) { + encounter_register(&ENCOUNTER_INFERNO); +} + +#endif /* ENCOUNTER_INFERNO_H */ diff --git a/src/osrs/encounters/encounter_nh_pvp.h b/src/osrs/encounters/encounter_nh_pvp.h new file mode 100644 index 0000000000..3302e12c6a --- /dev/null +++ b/src/osrs/encounters/encounter_nh_pvp.h @@ -0,0 +1,229 @@ +/** + * @file encounter_nh_pvp.h + * @brief NH (No Honor) PvP encounter — the original 1v1 LMS-style fight. + * + * Wraps the existing osrs_pvp_api.h (pvp_init/pvp_reset/pvp_step) as an + * EncounterDef implementation. This is the first encounter and serves as + * the reference for how to add new encounters. + * + * Entity layout: 2 players (agent + opponent). + * Obs: 334 features. Actions: 7 heads [9,13,6,2,5,2,2]. Mask: 39. + */ + +#ifndef ENCOUNTER_NH_PVP_H +#define ENCOUNTER_NH_PVP_H + +#include "../osrs_encounter.h" +#include "../osrs_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 +}; + +/* ======================================================================== */ +/* encounter state: just wraps OsrsEnv */ +/* ======================================================================== */ + +typedef struct { + OsrsEnv env; +} NhPvpState; + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* nh_pvp_create(void) { + NhPvpState* s = (NhPvpState*)calloc(1, sizeof(NhPvpState)); + pvp_init(&s->env); + /* pvp_init sets internal buf pointers for game logic (observations, actions, etc.). + also wire the ocean pointers to internal buffers so pvp_step can write obs/rewards + without needing the PufferLib binding. */ + s->env.ocean_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); +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static void nh_pvp_write_obs(EncounterState* state, float* obs_out) { + NhPvpState* s = (NhPvpState*)state; + /* observations are already computed by pvp_step into _obs_buf. + copy agent 0's observations (SLOT_NUM_OBSERVATIONS floats). */ + memcpy(obs_out, s->env._obs_buf, SLOT_NUM_OBSERVATIONS * sizeof(float)); +} + +static void nh_pvp_write_mask(EncounterState* state, float* mask_out) { + NhPvpState* s = (NhPvpState*)state; + /* masks are in _masks_buf, ACTION_MASK_SIZE bytes for agent 0. + convert to float for the encounter interface. */ + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + mask_out[i] = (float)s->env._masks_buf[i]; + } +} + +static float nh_pvp_get_reward(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env._rews_buf[0]; +} + +static int nh_pvp_is_terminal(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.episode_over; +} + +/* ======================================================================== */ +/* entity access */ +/* ======================================================================== */ + +static int nh_pvp_get_entity_count(EncounterState* state) { + (void)state; + return NUM_AGENTS; /* always 2 for NH PvP */ +} + +static void* nh_pvp_get_entity(EncounterState* state, int index) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.players[index]; +} + +/* ======================================================================== */ +/* render entity population */ +/* ======================================================================== */ + +static void nh_pvp_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + NhPvpState* s = (NhPvpState*)state; + int n = NUM_AGENTS < max_entities ? NUM_AGENTS : max_entities; + for (int i = 0; i < n; i++) { + render_entity_from_player(&s->env.players[i], &out[i]); + } + *count = n; +} + +/* ======================================================================== */ +/* config */ +/* ======================================================================== */ + +static void nh_pvp_put_int(EncounterState* state, const char* key, int value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "opponent_type") == 0) { + s->env.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; + } +} + +/* ======================================================================== */ +/* logging and state queries */ +/* ======================================================================== */ + +static void* nh_pvp_get_log(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.log; +} + +static int nh_pvp_get_tick(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.tick; +} + +static int nh_pvp_get_winner(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.winner; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_NH_PVP = { + .name = "nh_pvp", + .obs_size = SLOT_NUM_OBSERVATIONS, + .num_action_heads = NUM_ACTION_HEADS, + .action_head_dims = NH_PVP_ACTION_DIMS, + .mask_size = ACTION_MASK_SIZE, + + .create = nh_pvp_create, + .destroy = nh_pvp_destroy, + .reset = nh_pvp_reset, + .step = nh_pvp_step, + + .write_obs = nh_pvp_write_obs, + .write_mask = nh_pvp_write_mask, + .get_reward = nh_pvp_get_reward, + .is_terminal = nh_pvp_is_terminal, + + .get_entity_count = nh_pvp_get_entity_count, + .get_entity = nh_pvp_get_entity, + .fill_render_entities = nh_pvp_fill_render_entities, + + .put_int = nh_pvp_put_int, + .put_float = nh_pvp_put_float, + .put_ptr = nh_pvp_put_ptr, + + .render_post_tick = NULL, /* NH PvP uses existing render_post_tick for now */ + .get_log = nh_pvp_get_log, + .get_tick = nh_pvp_get_tick, + .get_winner = nh_pvp_get_winner, + + /* NH PvP uses its own human_to_pvp_actions translator via the PvP code path. */ + .translate_human_input = NULL, + .head_move = -1, + .head_prayer = -1, + .head_target = -1, +}; + +/* auto-register on include */ +__attribute__((constructor)) +static void nh_pvp_register(void) { + encounter_register(&ENCOUNTER_NH_PVP); +} + +#endif /* ENCOUNTER_NH_PVP_H */ diff --git a/src/osrs/encounters/encounter_zulrah.h b/src/osrs/encounters/encounter_zulrah.h new file mode 100644 index 0000000000..3e5d7843b2 --- /dev/null +++ b/src/osrs/encounters/encounter_zulrah.h @@ -0,0 +1,2274 @@ +/** + * @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_consumables.h" +#include "../osrs_damage.h" +#include "../osrs_collision.h" +#include "../osrs_monsters_generated.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define ZUL_ARENA_SIZE 28 +#define ZUL_NPC_SIZE 5 + +/* player platform bounds (walkable tiles) — centered on the shrine area */ +#define ZUL_PLATFORM_MIN 5 +#define ZUL_PLATFORM_MAX 22 + +/* 4 zulrah positions (relative coords on 28x28 grid). + mapped from OSRS world coords via RuneLite plugin ZulrahLocation.java, + anchored to NORTH=(2266,3073). base offset: (2254, 3060). */ +#define ZUL_POS_NORTH 0 /* RuneLite: NORTH / "middle" */ +#define ZUL_POS_SOUTH 1 +#define ZUL_POS_EAST 2 +#define ZUL_POS_WEST 3 +#define ZUL_NUM_POSITIONS 4 + +static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { + { 10, 12 }, /* NORTH: center/north */ + { 10, 1 }, /* SOUTH: bottom edge */ + { 20, 10 }, /* EAST: right edge */ + { 0, 10 }, /* WEST: left edge */ +}; + +/* player starting position — shrine entry (2267,3068) per RuneLite StandLocation */ +#define ZUL_PLAYER_START_X 11 +#define ZUL_PLAYER_START_Y 7 + +/* zulrah combat stats 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 + +/* ======================================================================== */ +/* observation and action space */ +/* ======================================================================== */ + +#define ZUL_NUM_OBS 81 +#define ZUL_NUM_ACTION_HEADS 6 + +#define ZUL_MOVE_DIM ENCOUNTER_MOVE_ACTIONS +#define ZUL_ATTACK_DIM 3 +#define ZUL_PRAYER_DIM ENCOUNTER_PRAYER_DIM +#define ZUL_FOOD_DIM 3 /* none, shark, karambwan */ +#define ZUL_POTION_DIM 3 /* none, restore, antivenom */ +#define ZUL_SPEC_DIM 2 + +#define ZUL_ACTION_MASK_SIZE (ZUL_MOVE_DIM + ZUL_ATTACK_DIM + ZUL_PRAYER_DIM + \ + ZUL_FOOD_DIM + ZUL_POTION_DIM + ZUL_SPEC_DIM) + +#define ZUL_HEAD_MOVE 0 +#define ZUL_HEAD_ATTACK 1 +#define ZUL_HEAD_PRAYER 2 +#define ZUL_HEAD_FOOD 3 +#define ZUL_HEAD_POTION 4 +#define ZUL_HEAD_SPEC 5 + +#define ZUL_MOVE_STAY 0 +#define ZUL_ATK_NONE 0 +#define ZUL_ATK_MAGE 1 +#define ZUL_ATK_RANGE 2 + +/* ======================================================================== */ +/* enums */ +/* ======================================================================== */ + +typedef enum { + ZUL_FORM_GREEN = 0, /* 2042: serpentine, ranged */ + ZUL_FORM_RED, /* 2043: magma, melee */ + ZUL_FORM_BLUE, /* 2044: tanzanite, magic+ranged */ +} ZulrahForm; + +/* 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; + +/* ======================================================================== */ +/* rotation data: action types for phase sequences */ +/* ======================================================================== */ + +typedef enum { + ZA_END = 0, /* sentinel — end of action list */ + ZA_RANGED, /* green form ranged attacks */ + ZA_MAGIC_RANGED, /* blue form random magic/ranged (magic more frequent) */ + ZA_MELEE, /* red form melee (stare + tail whip) */ + ZA_JAD_RM, /* jad: alternating, starting with ranged */ + ZA_JAD_MR, /* jad: alternating, starting with magic */ + ZA_CLOUDS, /* venom cloud barrages */ + ZA_SNAKELINGS, /* snakeling orbs */ + ZA_SNAKECLOUD_ALT, /* alternating: snakeling, cloud, snakeling, cloud... */ + ZA_CLOUDSNAKE_ALT, /* alternating: cloud, snakeling, cloud, snakeling... */ +} ZulActionType; + +typedef struct { + uint8_t type; /* ZulActionType */ + uint8_t count; +} ZulAction; + +#define ZUL_MAX_PHASE_ACTIONS 6 + +typedef struct { + uint8_t position; /* ZUL_POS_NORTH etc. */ + uint8_t form; /* ZUL_FORM_GREEN etc. */ + uint8_t stand; /* ZUL_STAND_* — safe tile for this phase */ + uint8_t stall; /* ZUL_STAND_* — stall tile (or ZUL_STAND_NONE) */ + uint8_t phase_ticks; /* total ticks at this position (from RuneLite plugin phaseTicks) */ + ZulAction actions[ZUL_MAX_PHASE_ACTIONS]; +} ZulRotationPhase; + +#define ZUL_MAX_ROT_PHASES 13 +#define ZUL_NUM_ROTATIONS 4 + +/* stand locations converted from RuneLite plugin StandLocation.java. + plugin uses OSRS local coords (128 units/tile). conversion: + grid_x = local_x/128 - 38, grid_y = local_y/128 - 44 + (derived from NORTH zulrah center (6720,7616) → grid (12,13)) */ +/* 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 arrays */ +/* ======================================================================== */ + +static const int ZUL_ACTION_HEAD_DIMS[ZUL_NUM_ACTION_HEADS] = { + ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, + ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM, +}; + +/* movement uses shared encounter_move_to_target + ENCOUNTER_MOVE_TARGET_DX/DY from osrs_encounter.h */ + +/* 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; +} + +/* ======================================================================== */ +/* structs */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int active; + int ticks_remaining; +} ZulrahCloud; + +/* cloud projectile in flight — becomes a ZulrahCloud when delay reaches 0 */ +#define ZUL_MAX_PENDING_CLOUDS 16 +typedef struct { + int x, y; + int delay; /* ticks until cloud spawns (0 = inactive) */ +} ZulrahPendingCloud; + +typedef struct { + Player entity; + int active; + int attack_timer; + int is_magic; /* 1=magic attacks, 0=melee attacks (random at spawn) */ + int lifespan; /* ticks until auto-death */ +} ZulrahSnakeling; + +typedef struct { + /* entities */ + Player player; + Player zulrah; + + /* rotation tracking */ + int rotation_index; /* which of 4 rotations (0-3) */ + int phase_index; /* current phase within rotation (0-based) */ + + /* phase action execution */ + int action_index; /* which action in current phase's action list */ + int action_progress; /* how many of the current action's count completed */ + int action_timer; /* ticks until next action fires */ + int jad_is_magic_next; /* for jad phase: 1 if next attack is magic */ + + /* zulrah state */ + ZulrahForm current_form; + int zulrah_visible; + int zulrah_attacking; /* currently in an attacking phase (not spawning/diving) */ + + /* melee state: stare at tile, then whip */ + int melee_target_x, melee_target_y; + int melee_pending; + int melee_stare_timer; + + /* phase timing: phaseTicks covers surface + actions + dive. + phase_timer counts down each tick. surface_timer delays actions at start. */ + int phase_timer; + int surface_timer; /* ticks of surface animation before actions start */ + int is_diving; /* set when phase_timer <= ZUL_DIVE_ANIM_TICKS */ + + /* player stun (from melee hit) */ + int player_stunned_ticks; + + /* hazards */ + ZulrahCloud clouds[ZUL_MAX_CLOUDS]; + ZulrahPendingCloud pending_clouds[ZUL_MAX_PENDING_CLOUDS]; + ZulrahSnakeling snakelings[ZUL_MAX_SNAKELINGS]; + + /* player combat */ + ZulrahGearStyle player_gear; + 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; + + /* eye of ayak soul rend: cumulative magic defence drain on zulrah. + * carries over between forms (magic defence is a stat, not a level). */ + int magic_def_drain; + + /* confliction gauntlets: primed after a magic miss, next magic attack + * rolls accuracy twice (like osmumten's fang). cleared on next magic attack. */ + int confliction_primed; + + /* thrall (arceuus greater ghost): auto-attacks zulrah every 4 ticks, + * always hits 0-3, ignores armour. auto-resummons after expiry + cooldown. */ + int thrall_active; + int thrall_attack_timer; + int thrall_duration_remaining; + int thrall_cooldown; + + /* collision */ + void* collision_map; /* CollisionMap* for walkability checks */ + int world_offset_x; /* local (0,0) = world (offset_x, offset_y) */ + int world_offset_y; + + /* episode */ + int tick; + int episode_over; + int winner; + uint32_t rng_state; + + /* reward tracking */ + float reward; + float episode_return; /* running sum of reward across all ticks */ + float damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_blocked_this_tick; + float total_damage_dealt; + float total_damage_received; + 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 */ + +/* ======================================================================== */ +/* helpers */ +/* ======================================================================== */ + +static inline int zul_on_platform_bounds(int x, int y) { + return x >= ZUL_PLATFORM_MIN && x <= ZUL_PLATFORM_MAX && + y >= ZUL_PLATFORM_MIN && y <= ZUL_PLATFORM_MAX; +} + +/* check if local tile (x,y) is walkable via collision map, fallback to bbox */ +static inline int zul_on_platform(ZulrahState* s, int x, int y) { + if (!s->collision_map) return zul_on_platform_bounds(x, y); + int wx = x + s->world_offset_x; + int wy = y + s->world_offset_y; + return collision_tile_walkable((const CollisionMap*)s->collision_map, 0, wx, wy); +} + +/* BFS pathfinding uses shared encounter_pathfind from osrs_encounter.h */ +#define zul_pathfind(s, sx, sy, dx, dy) \ + encounter_pathfind((const CollisionMap*)(s)->collision_map, \ + (s)->world_offset_x, (s)->world_offset_y, (sx), (sy), (dx), (dy), NULL, NULL) + +/* walkability callback for encounter_move_toward_dest */ +static int zul_tile_walkable(void* ctx, int x, int y) { + return zul_on_platform((ZulrahState*)ctx, x, y); +} + +/* cloud overlap: player (1x1) inside cloud (3x3) */ +static inline int zul_player_in_cloud(int cx, int cy, int px, int py) { + return px >= cx && px < cx + ZUL_CLOUD_SIZE && + py >= cy && py < cy + ZUL_CLOUD_SIZE; +} + + +static int zul_form_npc_id(ZulrahForm f) { + return 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; +} + +/* ======================================================================== */ +/* damage application */ +/* ======================================================================== */ + +/** Apply damage to the player. If attacker is non-NULL and player has a recoil + ring equipped, reflects floor(damage * 0.1) + 1 back to the attacker. + Pass NULL for environmental damage (clouds, venom) where recoil doesn't apply. */ +static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle style, + Player* attacker) { + if (damage <= 0) return; + encounter_damage_player(&s->player, damage, &s->damage_received_this_tick); + s->total_damage_received += damage; + s->player.hit_style = style; + + /* ring of recoil / ring of suffering (i) */ + int has_recoil = osrs_has_recoil_ring(s->player.equipped); + if (attacker && has_recoil && s->player.recoil_charges > 0) { + int recoil = damage / 10 + 1; + if (recoil > s->player.recoil_charges) { + recoil = s->player.recoil_charges; + } + encounter_damage_player(attacker, recoil, NULL); + + if (s->player.equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + s->player.recoil_charges -= recoil; + if (s->player.recoil_charges <= 0) { + s->player.recoil_charges = 0; + s->player.equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } +} + +/* venom from ranged/magic attacks — 25% chance per hit (wiki/deob confirmed). + applied even through prayer (unless miss). starts venom counter if not already venomed. */ +static void zul_try_envenom(ZulrahState* s) { + if (s->venom_counter > 0) return; /* already venomed */ + if (s->antivenom_timer > 0) return; /* anti-venom active */ + if (encounter_rand_int(&s->rng_state, 4) != 0) return; /* 25% chance */ + s->venom_counter = 1; + s->venom_timer = ZUL_VENOM_INTERVAL; +} + +/* ======================================================================== */ +/* NPC accuracy roll */ +/* ======================================================================== */ + +/* OSRS accuracy formula: if att > def: 1 - (def+2)/(2*(att+1)), else att/(2*(def+1)) */ +/* hit chance: use shared OSRS accuracy formula from osrs_combat.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 = (s->player_gear == ZUL_GEAR_MAGE) + ? &s->mage_stats : &s->range_stats; + /* 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; +} + +/* ======================================================================== */ +/* zulrah attack dispatch */ +/* ======================================================================== */ + +/* record a visual attack event for projectile rendering */ +static void zul_record_attack(ZulrahState* s, int src_x, int src_y, + int dst_x, int dst_y, int style, int damage) { + s->zulrah.npc_anim_id = ZULRAH_ANIM_ATTACK; + if (s->attack_event_count >= 8) return; + int i = s->attack_event_count++; + s->attack_events[i].src_x = src_x; + s->attack_events[i].src_y = src_y; + s->attack_events[i].dst_x = dst_x; + s->attack_events[i].dst_y = dst_y; + s->attack_events[i].style = style; + s->attack_events[i].damage = damage; +} + +/* ranged attack (green form, or blue form ranged variant). + unlike magic, ranged CAN miss (accuracy roll required). + wiki: "ranged and magic attacks will envenom the player unless they miss, + even if blocked by a protection prayer." so venom only on hit. */ +/* 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; +} + +/* ======================================================================== */ +/* player attacks zulrah */ +/* ======================================================================== */ + +/* 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 int zul_player_attack_hits(ZulrahState* s, int is_mage) { + const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; + int att_roll = osrs_player_att_roll(ls->eff_level, ls->attack_bonus); + /* 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; + + /* confliction gauntlets: double accuracy roll on primed magic attacks (tier 2 only). + * primed = previous magic attack missed. eye of ayak is one-handed so effect applies. */ + if (is_mage && s->confliction_primed && s->gear_tier == 2) { + s->confliction_primed = 0; + return encounter_rand_float(&s->rng_state) < 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; + + int gear_ok = (is_mage && s->player_gear == ZUL_GEAR_MAGE) || + (!is_mage && s->player_gear == ZUL_GEAR_RANGE); + const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; + s->player.attack_timer = is_mage ? 4 : ls->attack_speed; + if (!gear_ok) return; + + int max_hit = ls->max_hit; + int dmg = 0; + int hit = zul_player_attack_hits(s, is_mage); + if (hit) { + dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; + /* sang staff passive (tier 1 mage): 1/6 chance to heal 50% of damage dealt */ + if (is_mage && s->gear_tier == 1 && dmg > 0 && encounter_rand_int(&s->rng_state, 6) == 0) { + int heal = dmg / 2; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } + /* confliction gauntlets: prime on magic miss, clear on magic hit */ + if (is_mage && s->gear_tier == 2) { + s->confliction_primed = !hit; + } + s->player.just_attacked = 1; + s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + + /* visual: hit splat + HP bar on Zulrah */ + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = dmg; + s->zulrah.hit_was_successful = (dmg > 0); +} + +/* special attack: 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; + + /* determine weapon and stats from current gear */ + int is_mage = (s->player_gear == ZUL_GEAR_MAGE); + const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; + const uint8_t* loadout = is_mage + ? ZUL_MAGE_LOADOUT[s->gear_tier] + : ZUL_RANGE_LOADOUT[s->gear_tier]; + int weapon = loadout[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); +} + +/* ======================================================================== */ +/* snakelings */ +/* ======================================================================== */ + +/* pick a walkable spawn position for a snakeling, falling back to player's tile */ +static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { + /* try predefined positions in random order */ + int order[ZUL_NUM_SNAKELING_POSITIONS]; + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) order[i] = i; + encounter_shuffle(order, ZUL_NUM_SNAKELING_POSITIONS, &s->rng_state); + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) { + int px = ZUL_SNAKELING_POSITIONS[order[i]][0]; + int py = ZUL_SNAKELING_POSITIONS[order[i]][1]; + if (zul_on_platform(s, px, py) && + !(px == s->player.x && py == s->player.y)) { + *ox = px; *oy = py; return; + } + } + /* fallback: spawn near player */ + *ox = s->player.x; + *oy = s->player.y; +} + +static void zul_spawn_snakeling(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) continue; + ZulrahSnakeling* sn = &s->snakelings[i]; + memset(sn, 0, sizeof(ZulrahSnakeling)); + sn->active = 1; + sn->entity.entity_type = ENTITY_NPC; + sn->entity.npc_size = 1; + sn->entity.npc_visible = 1; + sn->is_magic = encounter_rand_int(&s->rng_state, 2); + sn->entity.npc_def_id = sn->is_magic ? 2046 : 2045; + sn->entity.npc_anim_id = SNAKELING_ANIM_IDLE; + zul_pick_snakeling_pos(s, &sn->entity.x, &sn->entity.y); + sn->entity.current_hitpoints = ZUL_SNAKELING_HP; + sn->entity.base_hitpoints = ZUL_SNAKELING_HP; + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->lifespan = ZUL_SNAKELING_LIFESPAN; + + /* emit spawn orb projectile event (style=4) */ + if (s->attack_event_count < 8) { + int ei = s->attack_event_count++; + s->attack_events[ei].src_x = s->zulrah.x; + s->attack_events[ei].src_y = s->zulrah.y; + s->attack_events[ei].dst_x = sn->entity.x; + s->attack_events[ei].dst_y = sn->entity.y; + s->attack_events[ei].style = 4; /* snakeling spawn orb */ + s->attack_events[ei].damage = 0; + } + return; + } +} + +static void zul_snakeling_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + ZulrahSnakeling* sn = &s->snakelings[i]; + if (!sn->active) continue; + + /* lifespan: die after ~40 seconds */ + sn->lifespan--; + if (sn->lifespan <= 0) { sn->active = 0; continue; } + + /* move toward player — stop when within attack range (Chebyshev ≤ 1). + NPCs use greedy single-step movement, not full BFS. */ + int adx = abs_int(sn->entity.x - s->player.x); + int ady = abs_int(sn->entity.y - s->player.y); + int in_range = (adx <= 1 && ady <= 1); + int moved = 0; + if (!in_range) { + PathResult pr = zul_pathfind(s, sn->entity.x, sn->entity.y, + s->player.x, s->player.y); + if (pr.found && (pr.next_dx != 0 || pr.next_dy != 0)) { + int nx = sn->entity.x + pr.next_dx; + int ny = sn->entity.y + pr.next_dy; + if (zul_on_platform(s, nx, ny)) { + sn->entity.x = nx; sn->entity.y = ny; moved = 1; + } + } + } + sn->entity.npc_anim_id = moved ? SNAKELING_ANIM_WALK : SNAKELING_ANIM_IDLE; + + /* attack — recheck range after movement */ + if (sn->attack_timer > 0) { sn->attack_timer--; continue; } + adx = abs_int(sn->entity.x - s->player.x); + ady = abs_int(sn->entity.y - s->player.y); + if (adx > 1 || ady > 1) continue; + + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->entity.npc_anim_id = sn->is_magic ? SNAKELING_ANIM_MAGIC : SNAKELING_ANIM_MELEE; + AttackStyle sn_style = sn->is_magic ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_MELEE; + if (encounter_prayer_correct_for_style(s->player.prayer, sn_style)) { + s->prayer_blocked_this_tick = 1; continue; + } + int sn_max = sn->is_magic ? 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; + } + } +} + +/* ======================================================================== */ +/* clouds */ +/* ======================================================================== */ + +/* forward: needed by zul_spawn_cloud to get current phase's safe tiles */ +static const ZulRotationPhase* zul_current_phase(ZulrahState* s) { + return &ZUL_ROTATIONS[s->rotation_index][s->phase_index]; +} + +/* check if a 3x3 cloud area starting at (x,y) is fully within walkable arena. + all 9 tiles must be walkable so the cloud doesn't extend outside the platform. */ +static int zul_cloud_fits(ZulrahState* s, int x, int y) { + for (int dx = 0; dx < ZUL_CLOUD_SIZE; dx++) { + for (int dy = 0; dy < ZUL_CLOUD_SIZE; dy++) { + if (!zul_on_platform(s, x + dx, y + dy)) return 0; + } + } + return 1; +} + +/* pick a valid cloud position: walkable 3x3, not safe, not overlapping. + * 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); + } + } +} + +/* ======================================================================== */ +/* venom */ +/* ======================================================================== */ + +static void zul_venom_tick(ZulrahState* s) { + /* antivenom timer ticks down */ + if (s->antivenom_timer > 0) s->antivenom_timer--; + + if (s->venom_counter == 0) return; + if (s->antivenom_timer > 0) return; /* immune while antivenom active */ + 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; +} + +/* ======================================================================== */ +/* thrall: arceuus greater ghost */ +/* ======================================================================== */ + +static void zul_thrall_tick(ZulrahState* s) { + if (!s->thrall_active) { + /* resummon after cooldown */ + if (s->thrall_cooldown > 0) { s->thrall_cooldown--; return; } + s->thrall_active = 1; + s->thrall_duration_remaining = ZUL_THRALL_DURATION; + s->thrall_attack_timer = 1; /* attacks on next tick */ + return; + } + + s->thrall_duration_remaining--; + if (s->thrall_duration_remaining <= 0) { + /* despawn + cooldown before resummon */ + s->thrall_active = 0; + s->thrall_cooldown = ZUL_THRALL_COOLDOWN; + return; + } + + /* attack: always hits, ignores armour, only when zulrah is targetable */ + if (s->thrall_attack_timer > 0) { s->thrall_attack_timer--; return; } + s->thrall_attack_timer = ZUL_THRALL_SPEED; + + if (!s->zulrah_visible || s->is_diving) return; + + int dmg = encounter_rand_int(&s->rng_state, ZUL_THRALL_MAX_HIT + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; +} + +/* ======================================================================== */ +/* phase machine: execute current action in rotation table */ +/* ======================================================================== */ + +/* fire one instance of the current action (attack/cloud/snakeling) */ +static void zul_fire_action(ZulrahState* s, ZulActionType type) { + switch (type) { + case ZA_RANGED: zul_attack_ranged(s); break; + case ZA_MAGIC_RANGED: zul_attack_magic_ranged(s); break; + case ZA_MELEE: zul_melee_start(s); break; + case ZA_JAD_RM: + case ZA_JAD_MR: zul_attack_jad(s); break; + case ZA_CLOUDS: zul_spawn_cloud(s); break; + case ZA_SNAKELINGS: zul_spawn_snakeling(s); break; + case ZA_SNAKECLOUD_ALT: + if (s->action_progress % 2 == 0) zul_spawn_snakeling(s); + else zul_spawn_cloud(s); + break; + case ZA_CLOUDSNAKE_ALT: + if (s->action_progress % 2 == 0) zul_spawn_cloud(s); + else zul_spawn_snakeling(s); + break; + case ZA_END: break; + } +} + +/* get ticks between fires for an action type */ +static int zul_action_interval(ZulActionType type) { + switch (type) { + case ZA_RANGED: + case ZA_MAGIC_RANGED: + case ZA_JAD_RM: + case ZA_JAD_MR: return 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); + } +} + +/* ======================================================================== */ +/* player action processing */ +/* ======================================================================== */ + +static void zul_process_movement(ZulrahState* s) { + if (s->player_dest_x < 0 || s->player_dest_y < 0) return; + if (s->player_stunned_ticks > 0) return; + + /* shared BFS click-to-move: runs (2 steps) when dest > 1 tile away */ + encounter_move_toward_dest(&s->player, &s->player_dest_x, &s->player_dest_y, + (const CollisionMap*)s->collision_map, s->world_offset_x, s->world_offset_y, + zul_tile_walkable, s, NULL, NULL, + 0, 0, 0, 0); +} + +static void zul_process_prayer(ZulrahState* s, int p) { + encounter_apply_prayer_action(&s->player.prayer, p); +} + +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++; + } +} + + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +static void zul_write_obs(EncounterState* state, float* obs) { + ZulrahState* s = (ZulrahState*)state; + memset(obs, 0, ZUL_NUM_OBS * sizeof(float)); + int i = 0; + + /* player (0-15) */ + obs[i++] = (float)s->player.current_hitpoints / s->player.base_hitpoints; + obs[i++] = (float)s->player.current_prayer / s->player.base_prayer; + /* 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; + 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; +} + +/* ======================================================================== */ +/* action masks */ +/* ======================================================================== */ + +static void zul_write_mask(EncounterState* state, float* mask) { + ZulrahState* s = (ZulrahState*)state; + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) mask[i] = 1.0f; + int off = 0; + + /* movement: 25-action system (idle + 8 walk + 16 run) */ + for (int m = 0; m < ZUL_MOVE_DIM; m++) { + if (m > 0) { + if (s->player_stunned_ticks > 0) { mask[off] = 0.0f; } + else { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[m]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[m]; + if (!zul_on_platform(s, nx, ny)) mask[off] = 0.0f; + } + } + off++; + } + /* attack — can't attack while Zulrah is hidden or diving */ + for (int a = 0; a < ZUL_ATTACK_DIM; a++) { + if (a > 0 && (!s->zulrah_visible || s->is_diving || s->player.attack_timer > 0 || s->player_stunned_ticks > 0)) + mask[off] = 0.0f; + off++; + } + /* prayer: 0=no_change (always valid), 1=off (always valid), + 2-4=melee/ranged/magic (require prayer points) */ + for (int p = 0; p < ZUL_PRAYER_DIM; p++) { + if (p >= ENCOUNTER_PRAYER_MELEE && s->player.current_prayer <= 0) + mask[off] = 0.0f; + off++; + } + /* food (none=0, shark=1, karambwan=2) */ + off++; /* none always valid */ + /* shark: masked if no food, food timer active, or would overheal (HP > 79) */ + if (s->player.food_count <= 0 || s->player.food_timer > 0 || + s->player.current_hitpoints > s->player.base_hitpoints - 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++; +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +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; +} + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* zul_create(void) { + return (EncounterState*)calloc(1, sizeof(ZulrahState)); +} + +static void zul_destroy(EncounterState* state) { free(state); } + +static void zul_reset(EncounterState* state, uint32_t seed) { + ZulrahState* s = (ZulrahState*)state; + Log saved_log = s->log; + void* saved_cmap = s->collision_map; + int saved_wx = s->world_offset_x; + int saved_wy = s->world_offset_y; + int saved_tier = s->gear_tier; + uint32_t saved_rng = s->rng_state; + memset(s, 0, sizeof(ZulrahState)); + s->log = saved_log; + s->collision_map = saved_cmap; + s->world_offset_x = saved_wx; + s->world_offset_y = saved_wy; + s->gear_tier = saved_tier; + s->rng_state = encounter_resolve_seed(saved_rng, seed); + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + memset(s->player.equipped, ITEM_NONE, NUM_GEAR_SLOTS); + s->player.base_hitpoints = ZUL_PLAYER_HP; + s->player.current_hitpoints = ZUL_PLAYER_HP; + s->player.base_prayer = ZUL_PLAYER_PRAYER; + s->player.current_prayer = ZUL_PLAYER_PRAYER; + s->player.x = ZUL_PLAYER_START_X; + s->player.y = ZUL_PLAYER_START_Y; + s->player.food_count = ZUL_PLAYER_FOOD; + s->player.karambwan_count = ZUL_PLAYER_KARAMBWAN; + s->player.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 */ + EncounterPrayer mage_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_AUGURY : ENCOUNTER_PRAYER_NONE; + EncounterPrayer range_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_RIGOUR : ENCOUNTER_PRAYER_NONE; + encounter_compute_loadout_stats(ZUL_MAGE_LOADOUT[s->gear_tier], ATTACK_STYLE_MAGIC, + mage_prayer, 99, 0, 30, &s->mage_stats); + encounter_compute_loadout_stats(ZUL_RANGE_LOADOUT[s->gear_tier], ATTACK_STYLE_RANGED, + range_prayer, 99, 0, 0, &s->range_stats); + int r = s->player.equipped[GEAR_SLOT_RING]; + s->player.recoil_charges = + (r == ITEM_RING_OF_RECOIL || r == ITEM_RING_OF_SUFFERING_RI) ? RECOIL_MAX_CHARGES : 0; + + /* zulrah */ + s->zulrah.entity_type = ENTITY_NPC; + s->zulrah.npc_def_id = 2042; + s->zulrah.npc_size = ZUL_NPC_SIZE; + s->zulrah.npc_anim_id = ZULRAH_ANIM_IDLE; + s->zulrah.base_hitpoints = 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]); + + /* 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 ((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 (shared OSRS formula) */ + encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, 0, + &s->player.prayer_drain_counter, encounter_prayer_drain_effect(s->player.prayer)); + + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + if (s->tick >= ZUL_MAX_TICKS) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + s->reward = zul_compute_reward(s); + s->episode_return += s->reward; +} + +/* ======================================================================== */ +/* heuristic policy (for visual debug + sanity checks) */ +/* ======================================================================== */ + +static void zul_heuristic_actions(ZulrahState* s, int* actions) { + /* zero all heads */ + for (int i = 0; i < ZUL_NUM_ACTION_HEADS; i++) actions[i] = 0; + + int hp = s->player.current_hitpoints; + + /* prayer: match form. GREEN=ranged, BLUE=magic, RED=melee */ + if (s->zulrah_visible && !s->is_diving) { + switch (s->current_form) { + case ZUL_FORM_GREEN: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_RANGED; break; + case ZUL_FORM_BLUE: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MAGIC; break; + case ZUL_FORM_RED: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MELEE; break; + } + } + + /* antivenom on first tick or when timer about to expire */ + if (s->player.potion_timer <= 0 && s->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; + } + } +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static float zul_get_reward(EncounterState* state) { + return ((ZulrahState*)state)->reward; +} +static int zul_is_terminal(EncounterState* state) { + return ((ZulrahState*)state)->episode_over; +} + +/* entity access */ +static int zul_get_entity_count(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + int n = 2; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) + if (s->snakelings[i].active) n++; + return n; +} +static void* zul_get_entity(EncounterState* state, int index) { + ZulrahState* s = (ZulrahState*)state; + if (index == 0) return &s->player; + if (index == 1) return &s->zulrah; + int si = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) { + if (si + 2 == index) return &s->snakelings[i].entity; + si++; + } + } + return &s->player; +} + +/* render entity population */ +static void zul_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + ZulrahState* s = (ZulrahState*)state; + int n = 0; + if (n < max_entities) render_entity_from_player(&s->player, &out[n++]); + if (n < max_entities) render_entity_from_player(&s->zulrah, &out[n++]); + for (int i = 0; i < ZUL_MAX_SNAKELINGS && n < max_entities; i++) { + if (s->snakelings[i].active) { + render_entity_from_player(&s->snakelings[i].entity, &out[n++]); + /* 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; s->player_dest_explicit = 1; } + else if (strcmp(key, "player_dest_y") == 0) { s->player_dest_y = value; s->player_dest_explicit = 1; } +} +static void zul_put_float(EncounterState* st, const char* k, float v) { (void)st;(void)k;(void)v; } +static void zul_put_ptr(EncounterState* st, const char* k, void* v) { + ZulrahState* s = (ZulrahState*)st; + if (strcmp(k, "collision_map") == 0) s->collision_map = v; +} + +/* logging */ +static void* zul_get_log(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.n += 1.0f; + } + return &s->log; +} +static int zul_get_tick(EncounterState* state) { return ((ZulrahState*)state)->tick; } + +/* render overlay: expose clouds and Zulrah state to the renderer */ +static void zul_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + ZulrahState* s = (ZulrahState*)state; + + /* 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); + } else { + /* ranged/magic attack: tracks player, zulrah height → player height */ + uint32_t zul_proj_model = (s->attack_events[i].style == 0) + ? GFX_RANGED_PROJ_MODEL : GFX_MAGIC_PROJ_MODEL; + encounter_emit_projectile(ov, + s->attack_events[i].src_x, s->attack_events[i].src_y, + s->attack_events[i].dst_x, s->attack_events[i].dst_y, + s->attack_events[i].style, s->attack_events[i].damage, + 35, 480, 64, 16, 0.0f, 1, ZUL_NPC_SIZE, 1, zul_proj_model); + } + } + for (int i = 0; i < s->cloud_event_count && ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES; i++) { + encounter_emit_projectile(ov, + s->cloud_events[i].src_x, s->cloud_events[i].src_y, + s->cloud_events[i].dst_x, s->cloud_events[i].dst_y, + 3, 0, /* style=cloud, damage=0 */ + /* duration from flight_ticks * 30, high arc start, ground end, + curve=10, arc_height=3.0 (high sinusoidal), no tracking, src_size=5 */ + s->cloud_events[i].flight_ticks * 30, 200, 0, 10, 3.0f, 0, ZUL_NPC_SIZE, 1, + GFX_CLOUD_PROJ_MODEL); + } +} +static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, ZUL_HEAD_MOVE, + (void*(*)(void*,int))zul_get_entity, state); + encounter_translate_prayer(hi, actions, ZUL_HEAD_PRAYER); + + /* attack style: mage or range */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE || hi->pending_spell == ATTACK_BLOOD) + actions[ZUL_HEAD_ATTACK] = 1; /* mage */ + else + actions[ZUL_HEAD_ATTACK] = 2; /* range */ + } + + /* food: shark on food head */ + if (hi->pending_food) actions[ZUL_HEAD_FOOD] = 1; + + /* potions: brew→food head, restore→potion 1, antivenom→potion 2 */ + if (hi->pending_potion == POTION_BREW) actions[ZUL_HEAD_FOOD] = 1; + else if (hi->pending_potion == POTION_RESTORE) actions[ZUL_HEAD_POTION] = 1; + else if (hi->pending_potion == POTION_ANTIVENOM) actions[ZUL_HEAD_POTION] = 2; + + /* spec */ + if (hi->pending_spec) actions[ZUL_HEAD_SPEC] = 1; + + (void)state; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_ZULRAH = { + .name = "zulrah", + .obs_size = ZUL_NUM_OBS, + .num_action_heads = ZUL_NUM_ACTION_HEADS, + .action_head_dims = ZUL_ACTION_HEAD_DIMS, + .mask_size = ZUL_ACTION_MASK_SIZE, + .create = zul_create, + .destroy = zul_destroy, + .reset = zul_reset, + .step = zul_step, + .write_obs = zul_write_obs, + .write_mask = zul_write_mask, + .get_reward = zul_get_reward, + .is_terminal = zul_is_terminal, + .get_entity_count = zul_get_entity_count, + .get_entity = zul_get_entity, + .fill_render_entities = zul_fill_render_entities, + .put_int = zul_put_int, + .put_float = zul_put_float, + .put_ptr = zul_put_ptr, + .arena_base_x = 0, + .arena_base_y = 0, + .arena_width = ZUL_ARENA_SIZE, + .arena_height = ZUL_ARENA_SIZE, + .render_post_tick = zul_render_post_tick, + .get_log = zul_get_log, + .get_tick = zul_get_tick, + .get_winner = zul_get_winner, + + .translate_human_input = zul_translate_human_input, + .head_move = ZUL_HEAD_MOVE, + .head_prayer = ZUL_HEAD_PRAYER, + .head_target = -1, +}; + +__attribute__((constructor)) +static void zul_register(void) { + encounter_register(&ENCOUNTER_ZULRAH); +} + +#endif /* ENCOUNTER_ZULRAH_H */ diff --git a/src/osrs/osrs_anim.h b/src/osrs/osrs_anim.h new file mode 100644 index 0000000000..4c1972eb45 --- /dev/null +++ b/src/osrs/osrs_anim.h @@ -0,0 +1,681 @@ +/** + * @fileoverview OSRS animation runtime — loads .anims binary, applies vertex-group + * transforms to model base geometry, re-expands into raylib mesh for rendering. + * + * OSRS animations use vertex-group-based transforms (not bones). Each vertex has a + * skin label (group index). FrameBase defines transform slots with types + label arrays. + * Each frame provides per-slot {dx,dy,dz} values. Transform types: + * 0 = origin (compute centroid of referenced vertex groups → set pivot) + * 1 = translate (add dx/dy/dz to all vertices in referenced groups) + * 2 = rotate (euler Z-X-Y around pivot, raw*8 → 2048-entry sine table) + * 3 = scale (relative to pivot, 128 = 1.0x identity) + * 5 = alpha (face transparency, not used in our viewer) + * + * Binary format (.anims) produced by scripts/export_animations.py: + * header: uint32 magic ("ANIM"), uint16 framebase_count, uint16 sequence_count + * framebases section, sequences section with inlined frame data. + */ + +#ifndef OSRS_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 + +/* ======================================================================== */ +/* sine/cosine table (matches OSRS Rasterizer3D, fixed-point scale 65536) */ +/* ======================================================================== */ + +static int anim_sine[ANIM_SINE_COUNT]; +static int anim_cosine[ANIM_SINE_COUNT]; +static int anim_trig_initialized = 0; + +static void anim_init_trig(void) { + if (anim_trig_initialized) return; + for (int i = 0; i < ANIM_SINE_COUNT; i++) { + double angle = (double)i * (2.0 * 3.14159265358979323846 / ANIM_SINE_COUNT); + anim_sine[i] = (int)(65536.0 * sin(angle)); + anim_cosine[i] = (int)(65536.0 * cos(angle)); + } + anim_trig_initialized = 1; +} + +/* ======================================================================== */ +/* data structures */ +/* ======================================================================== */ + +typedef struct { + uint16_t base_id; + uint8_t slot_count; + uint8_t* types; /* [slot_count] transform type per slot */ + uint8_t* map_lengths; /* [slot_count] label count per slot */ + uint8_t** frame_maps; /* [slot_count][map_lengths[i]] label indices */ +} AnimFrameBase; + +typedef struct { + uint8_t slot_index; + int16_t dx, dy, dz; +} AnimTransform; + +typedef struct { + uint16_t framebase_id; + uint8_t transform_count; + AnimTransform* transforms; +} AnimFrameData; + +typedef struct { + uint16_t delay; /* game ticks (600ms each) */ + AnimFrameData frame; +} AnimSequenceFrame; + +typedef struct { + uint16_t seq_id; + uint16_t frame_count; + uint8_t interleave_count; + uint8_t* interleave_order; + int8_t walk_flag; /* -1=default (no stall), 0=stall movement during anim */ + AnimSequenceFrame* frames; +} AnimSequence; + +typedef struct { + AnimFrameBase* bases; + int base_count; + uint16_t* base_ids; /* for lookup by id */ + + AnimSequence* sequences; + int seq_count; +} AnimCache; + +/* per-model animation working state */ +typedef struct { + /* transformed vertex positions (working copy of base_vertices) */ + int16_t* verts; /* [base_vert_count * 3] */ + int vert_count; + + /* vertex group lookup: groups[label] = { vertex indices } */ + int** groups; /* [ANIM_MAX_LABELS] arrays of vertex indices */ + int* group_counts; /* [ANIM_MAX_LABELS] count per group */ +} AnimModelState; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static uint8_t anim_read_u8(const uint8_t** p) { + uint8_t v = **p; (*p)++; + return v; +} + +static uint16_t anim_read_u16(const uint8_t** p) { + uint16_t v = (uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8); + *p += 2; + return v; +} + +static int16_t anim_read_i16(const uint8_t** p) { + int16_t v = (int16_t)((uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8)); + *p += 2; + return v; +} + +static uint32_t anim_read_u32(const uint8_t** p) { + uint32_t v = (uint32_t)((*p)[0]) + | ((uint32_t)((*p)[1]) << 8) + | ((uint32_t)((*p)[2]) << 16) + | ((uint32_t)((*p)[3]) << 24); + *p += 4; + return v; +} + +static AnimCache* anim_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "anim_cache_load: cannot open %s\n", path); + return NULL; + } + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + uint8_t* buf = (uint8_t*)malloc(size); + fread(buf, 1, size, f); + fclose(f); + + const uint8_t* p = buf; + + uint32_t magic = anim_read_u32(&p); + if (magic != ANIM_MAGIC) { + fprintf(stderr, "anim_cache_load: bad magic 0x%08X\n", magic); + free(buf); + return NULL; + } + + AnimCache* cache = (AnimCache*)calloc(1, sizeof(AnimCache)); + cache->base_count = anim_read_u16(&p); + cache->seq_count = anim_read_u16(&p); + + /* load framebases */ + cache->bases = (AnimFrameBase*)calloc(cache->base_count, sizeof(AnimFrameBase)); + cache->base_ids = (uint16_t*)malloc(cache->base_count * sizeof(uint16_t)); + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + fb->base_id = anim_read_u16(&p); + cache->base_ids[i] = fb->base_id; + fb->slot_count = anim_read_u8(&p); + + fb->types = (uint8_t*)malloc(fb->slot_count); + for (int s = 0; s < fb->slot_count; s++) { + fb->types[s] = anim_read_u8(&p); + } + + fb->map_lengths = (uint8_t*)malloc(fb->slot_count); + fb->frame_maps = (uint8_t**)malloc(fb->slot_count * sizeof(uint8_t*)); + for (int s = 0; s < fb->slot_count; s++) { + uint8_t ml = anim_read_u8(&p); + fb->map_lengths[s] = ml; + fb->frame_maps[s] = (uint8_t*)malloc(ml); + for (int j = 0; j < ml; j++) { + fb->frame_maps[s][j] = anim_read_u8(&p); + } + } + } + + /* load sequences */ + cache->sequences = (AnimSequence*)calloc(cache->seq_count, sizeof(AnimSequence)); + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + seq->seq_id = anim_read_u16(&p); + seq->frame_count = anim_read_u16(&p); + + seq->interleave_count = anim_read_u8(&p); + if (seq->interleave_count > 0) { + seq->interleave_order = (uint8_t*)malloc(seq->interleave_count); + for (int j = 0; j < seq->interleave_count; j++) { + seq->interleave_order[j] = anim_read_u8(&p); + } + } + + seq->walk_flag = (int8_t)anim_read_u8(&p); + + seq->frames = (AnimSequenceFrame*)calloc(seq->frame_count, sizeof(AnimSequenceFrame)); + for (int fi = 0; fi < seq->frame_count; fi++) { + AnimSequenceFrame* sf = &seq->frames[fi]; + sf->delay = anim_read_u16(&p); + sf->frame.framebase_id = anim_read_u16(&p); + sf->frame.transform_count = anim_read_u8(&p); + + if (sf->frame.transform_count > 0) { + sf->frame.transforms = (AnimTransform*)malloc( + sf->frame.transform_count * sizeof(AnimTransform)); + for (int t = 0; t < sf->frame.transform_count; t++) { + sf->frame.transforms[t].slot_index = anim_read_u8(&p); + sf->frame.transforms[t].dx = anim_read_i16(&p); + sf->frame.transforms[t].dy = anim_read_i16(&p); + sf->frame.transforms[t].dz = anim_read_i16(&p); + } + } + } + } + + free(buf); + anim_init_trig(); + + fprintf(stderr, "anim_cache_load: loaded %d framebases, %d sequences from %s\n", + cache->base_count, cache->seq_count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +static AnimSequence* anim_get_sequence(AnimCache* cache, uint16_t seq_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->seq_count; i++) { + if (cache->sequences[i].seq_id == seq_id) { + return &cache->sequences[i]; + } + } + return NULL; +} + +static AnimFrameBase* anim_get_framebase(AnimCache* cache, uint16_t base_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->base_count; i++) { + if (cache->bases[i].base_id == base_id) { + return &cache->bases[i]; + } + } + return NULL; +} + +/* ======================================================================== */ +/* per-model animation state */ +/* ======================================================================== */ + +static AnimModelState* anim_model_state_create( + const uint8_t* vertex_skins, + int base_vert_count +) { + AnimModelState* state = (AnimModelState*)calloc(1, sizeof(AnimModelState)); + state->vert_count = base_vert_count; + state->verts = (int16_t*)calloc(base_vert_count * 3, sizeof(int16_t)); + + /* build vertex group lookup from skin labels */ + state->groups = (int**)calloc(ANIM_MAX_LABELS, sizeof(int*)); + state->group_counts = (int*)calloc(ANIM_MAX_LABELS, sizeof(int)); + + /* first pass: count vertices per label */ + int label_counts[ANIM_MAX_LABELS] = {0}; + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + label_counts[label]++; + } + + /* allocate per-label arrays */ + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + if (label_counts[l] > 0) { + state->groups[l] = (int*)malloc(label_counts[l] * sizeof(int)); + state->group_counts[l] = 0; + } + } + + /* second pass: fill vertex indices */ + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + state->groups[label][state->group_counts[label]++] = v; + } + + return state; +} + +static void anim_model_state_free(AnimModelState* state) { + if (!state) return; + free(state->verts); + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + free(state->groups[l]); + } + free(state->groups); + free(state->group_counts); + free(state); +} + +/* ======================================================================== */ +/* transform application (mirrors OSRS Model.transform) */ +/* ======================================================================== */ + +static void anim_apply_frame( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* frame, + const AnimFrameBase* fb +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* pivot point for rotate/scale */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + + for (int t = 0; t < frame->transform_count; t++) { + uint8_t slot_idx = frame->transforms[t].slot_index; + if (slot_idx >= fb->slot_count) continue; + + int type = fb->types[slot_idx]; + int dx = frame->transforms[t].dx; + int dy = frame->transforms[t].dy; + int dz = frame->transforms[t].dz; + + uint8_t map_len = fb->map_lengths[slot_idx]; + const uint8_t* labels = fb->frame_maps[slot_idx]; + + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0; + int sum_x = 0, sum_y = 0, sum_z = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sum_x += state->verts[v * 3]; + sum_y += state->verts[v * 3 + 1]; + sum_z += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + pivot_x = sum_x / count + dx; + pivot_y = sum_y / count + dy; + pivot_z = sum_z / count + dz; + } + } else if (type == 1) { + /* translate: add dx/dy/dz to all vertices in referenced groups */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + /* rotate: euler Z-X-Y around pivot. + * raw value * 8 → index into 2048-entry sine table. + * rotation order: Z first, then X, then Y. */ + int ax = (dx & 0xFF) * 8; + int ay = (dy & 0xFF) * 8; + int az = (dz & 0xFF) * 8; + + int sin_x = anim_sine[ax & 2047]; + int cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047]; + int cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047]; + int cos_z = anim_cosine[az & 2047]; + + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + /* Z rotation */ + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + + /* X rotation */ + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + + /* Y rotation */ + rx = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_y) >> 16; + vx = rx; vz = rz; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } else if (type == 3) { + /* scale: relative to pivot, 128 = 1.0x identity */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + vx = (vx * dx) / 128; + vy = (vy * dy) / 128; + vz = (vz * dz) / 128; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } + /* type 5 (alpha) skipped — we don't use face transparency in the viewer */ + } +} + +/* ======================================================================== */ +/* two-track interleaved animation (matches OSRS Model.applyAnimationFrames) */ +/* ======================================================================== */ + +/** + * Apply a single transform slot to the vertex state (extracted from anim_apply_frame + * to allow per-slot interleave filtering). + * + * pivot_x/y/z are read/written through pointers — they persist across slots + * within a pass, exactly like the reference's transformTempX/Y/Z. + */ +static void anim_apply_single_transform( + AnimModelState* state, + int type, const uint8_t* labels, uint8_t map_len, + int dx, int dy, int dz, + int* pivot_x, int* pivot_y, int* pivot_z +) { + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0, sx = 0, sy = 0, sz = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sx += state->verts[v * 3]; + sy += state->verts[v * 3 + 1]; + sz += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + *pivot_x = sx / count + dx; + *pivot_y = sy / count + dy; + *pivot_z = sz / count + dz; + } + } else if (type == 1) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + int ax = (dx & 0xFF) * 8, ay = (dy & 0xFF) * 8, az = (dz & 0xFF) * 8; + int sin_x = anim_sine[ax & 2047], cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047], cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047], cos_z = anim_cosine[az & 2047]; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + rx = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_y) >> 16; + state->verts[v * 3] = (int16_t)(rx + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)(rz + *pivot_z); + } + } + } else if (type == 3) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + state->verts[v * 3] = (int16_t)((vx * dx) / 128 + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)((vy * dy) / 128 + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)((vz * dz) / 128 + *pivot_z); + } + } + } +} + +/** + * Apply two animation frames with body-part interleaving. + * + * Mirrors OSRS Model.applyAnimationFrames(): + * - interleave_order lists framebase SLOT INDICES owned by SECONDARY (walk) + * - Pass 1: apply primary transforms for slots NOT in interleave_order + * - Pass 2: apply secondary transforms for slots IN interleave_order + * - Type-0 (pivot) transforms always execute in both passes + * + * CRITICAL: interleave_order contains framebase SLOT INDICES, not vertex labels! + * The reference code (Model.java:1322-1343) walks both the frame's slot list and + * the interleave_order simultaneously, comparing slot indices directly. + * + * Both passes operate on the same vertex state with independent pivot tracking, + * exactly as the reference does with transformTempX/Y/Z reset between passes. + */ +static void anim_apply_frame_interleaved( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* build boolean mask: interleave_order lists SLOT INDICES the SECONDARY owns. + index by slot index (0-244 for our 245-slot framebase), NOT vertex labels. */ + uint8_t secondary_slot[256]; + memset(secondary_slot, 0, sizeof(secondary_slot)); + for (int i = 0; i < interleave_count; i++) { + secondary_slot[interleave_order[i]] = 1; + } + + /* pass 1: primary frame — apply transforms for slots NOT in interleave_order. + * type-0 (pivot) always executes regardless of ownership. + * matches reference: if (k1 != i1 || class18.types[k1] == 0) */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + for (int t = 0; t < primary_frame->transform_count; t++) { + uint8_t slot_idx = primary_frame->transforms[t].slot_index; + if (slot_idx >= primary_fb->slot_count) continue; + + int type = primary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (!in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + primary_fb->frame_maps[slot_idx], + primary_fb->map_lengths[slot_idx], + primary_frame->transforms[t].dx, + primary_frame->transforms[t].dy, + primary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } + + /* pass 2: secondary frame — apply transforms for slots IN interleave_order. + * type-0 (pivot) always executes. + * matches reference: if (i2 == i1 || class18.types[i2] == 0) */ + pivot_x = 0; pivot_y = 0; pivot_z = 0; + for (int t = 0; t < secondary_frame->transform_count; t++) { + uint8_t slot_idx = secondary_frame->transforms[t].slot_index; + if (slot_idx >= secondary_fb->slot_count) continue; + + int type = secondary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + secondary_fb->frame_maps[slot_idx], + secondary_fb->map_lengths[slot_idx], + secondary_frame->transforms[t].dx, + secondary_frame->transforms[t].dy, + secondary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } +} + +/* ======================================================================== */ +/* mesh re-expansion (apply animated base verts → expanded rendering verts) */ +/* ======================================================================== */ + +/** + * Re-expand animated base vertices into the raylib mesh's expanded vertex buffer. + * This mirrors expand_model from the Python exporter but in-place, using + * face_indices to map from base to expanded vertices. + * + * The mesh has face_count*3 expanded vertices. Each triplet (i*3, i*3+1, i*3+2) + * corresponds to face_indices[i*3], face_indices[i*3+1], face_indices[i*3+2] + * pointing into base_vertices. + * + * OSRS Y is negated for rendering (negative-up → positive-up). + */ +static void anim_update_mesh( + float* mesh_vertices, + const AnimModelState* state, + const uint16_t* face_indices, + int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int a = face_indices[fi * 3]; + int b = face_indices[fi * 3 + 1]; + int c = face_indices[fi * 3 + 2]; + + int vi = fi * 9; /* 3 verts * 3 coords */ + mesh_vertices[vi] = (float)state->verts[a * 3]; + mesh_vertices[vi + 1] = (float)(-state->verts[a * 3 + 1]); /* negate Y */ + mesh_vertices[vi + 2] = (float)state->verts[a * 3 + 2]; + + mesh_vertices[vi + 3] = (float)state->verts[b * 3]; + mesh_vertices[vi + 4] = (float)(-state->verts[b * 3 + 1]); + mesh_vertices[vi + 5] = (float)state->verts[b * 3 + 2]; + + mesh_vertices[vi + 6] = (float)state->verts[c * 3]; + mesh_vertices[vi + 7] = (float)(-state->verts[c * 3 + 1]); + mesh_vertices[vi + 8] = (float)state->verts[c * 3 + 2]; + } +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +static void anim_cache_free(AnimCache* cache) { + if (!cache) return; + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + free(fb->types); + free(fb->map_lengths); + for (int s = 0; s < fb->slot_count; s++) { + free(fb->frame_maps[s]); + } + free(fb->frame_maps); + } + free(cache->bases); + free(cache->base_ids); + + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + free(seq->interleave_order); + for (int fi = 0; fi < seq->frame_count; fi++) { + free(seq->frames[fi].frame.transforms); + } + free(seq->frames); + } + free(cache->sequences); + free(cache); +} + +#endif /* OSRS_ANIM_H */ diff --git a/src/osrs/osrs_bolt_procs.h b/src/osrs/osrs_bolt_procs.h new file mode 100644 index 0000000000..4a0fda64b8 --- /dev/null +++ b/src/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/src/osrs/osrs_collision.h b/src/osrs/osrs_collision.h new file mode 100644 index 0000000000..f04a9d6991 --- /dev/null +++ b/src/osrs/osrs_collision.h @@ -0,0 +1,586 @@ +/** + * @file osrs_collision.h + * @brief Tile collision flag system for OSRS world simulation + * + * Based on OSRS collision system (TraversalConstants + TraversalMap). + * Implements the OSRS collision flag bitmask system: + * - Per-tile int flags storing wall directions, blocked state, impenetrability + * - Region-based storage (64x64 tiles, 4 height planes per region) + * - Directional traversal checks (N/S/E/W + diagonals) + * - Hash-map region manager with lazy allocation + * + * When collision_map is NULL, all traversal checks return true (backwards + * compatible with the flat arena). + */ + +#ifndef OSRS_COLLISION_H +#define OSRS_COLLISION_H + +#include +#include +#include + +/* ========================================================================= + * COLLISION FLAG CONSTANTS (from TraversalConstants.java) + * ========================================================================= */ + +#define COLLISION_NONE 0x000000 +#define COLLISION_WALL_NORTH_WEST 0x000001 +#define COLLISION_WALL_NORTH 0x000002 +#define COLLISION_WALL_NORTH_EAST 0x000004 +#define COLLISION_WALL_EAST 0x000008 +#define COLLISION_WALL_SOUTH_EAST 0x000010 +#define COLLISION_WALL_SOUTH 0x000020 +#define COLLISION_WALL_SOUTH_WEST 0x000040 +#define COLLISION_WALL_WEST 0x000080 + +#define COLLISION_IMPENETRABLE_WALL_NORTH_WEST 0x000200 +#define COLLISION_IMPENETRABLE_WALL_NORTH 0x000400 +#define COLLISION_IMPENETRABLE_WALL_NORTH_EAST 0x000800 +#define COLLISION_IMPENETRABLE_WALL_EAST 0x001000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_EAST 0x002000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH 0x004000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_WEST 0x008000 +#define COLLISION_IMPENETRABLE_WALL_WEST 0x010000 + +#define COLLISION_IMPENETRABLE_BLOCKED 0x020000 +#define COLLISION_BRIDGE 0x040000 +#define COLLISION_BLOCKED 0x200000 + +/* ========================================================================= + * REGION DATA STRUCTURE + * ========================================================================= */ + +#define REGION_SIZE 64 +#define REGION_HEIGHT_LEVELS 4 + +/** A single 64x64 OSRS region with 4 height planes of collision flags. */ +typedef struct { + int flags[REGION_HEIGHT_LEVELS][REGION_SIZE][REGION_SIZE]; +} CollisionRegion; + +/* ========================================================================= + * REGION MAP (hash map of regions keyed by region hash) + * ========================================================================= */ + +#define REGION_MAP_CAPACITY 256 /* power of 2, enough for wilderness + surroundings */ + +typedef struct { + int key; /* region hash: (regionX << 8) | regionY, or -1 if empty */ + CollisionRegion* region; +} RegionMapEntry; + +typedef struct { + RegionMapEntry entries[REGION_MAP_CAPACITY]; + int count; +} CollisionMap; + +/* ========================================================================= + * COORDINATE HELPERS + * ========================================================================= */ + +/** Compute region hash from global tile coordinates. */ +static inline int collision_region_hash(int x, int y) { + return ((x >> 6) << 8) | (y >> 6); +} + +/** Extract local coordinate within a 64x64 region. */ +static inline int collision_local(int coord) { + return coord & 0x3F; +} + +/* ========================================================================= + * REGION MAP OPERATIONS + * ========================================================================= */ + +/** Initialize a collision map (all slots empty). */ +static inline void collision_map_init(CollisionMap* map) { + map->count = 0; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + map->entries[i].key = -1; + map->entries[i].region = NULL; + } +} + +/** Allocate and initialize a new collision map. */ +static inline CollisionMap* collision_map_create(void) { + CollisionMap* map = (CollisionMap*)malloc(sizeof(CollisionMap)); + collision_map_init(map); + return map; +} + +/** Look up a region by hash. Returns NULL if not present. */ +static inline CollisionRegion* collision_map_get(const CollisionMap* map, int key) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + return map->entries[slot].region; + } + if (map->entries[slot].key == -1) { + return NULL; + } + } + return NULL; +} + +/** Insert a region into the map. Overwrites if key exists. */ +static inline void collision_map_put(CollisionMap* map, int key, CollisionRegion* region) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + map->entries[slot].region = region; + return; + } + if (map->entries[slot].key == -1) { + map->entries[slot].key = key; + map->entries[slot].region = region; + map->count++; + return; + } + } + /* map full — shouldn't happen with 256 capacity for wilderness */ + fprintf(stderr, "collision_map_put: map full (capacity %d)\n", REGION_MAP_CAPACITY); +} + +/** Get or lazily create a region for the given global tile coordinates. */ +static inline CollisionRegion* collision_map_get_or_create(CollisionMap* map, int x, int y) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) { + region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + collision_map_put(map, key, region); + } + return region; +} + +/** Free all regions and the map itself. */ +static inline void collision_map_free(CollisionMap* map) { + if (map == NULL) return; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].region != NULL) { + free(map->entries[i].region); + } + } + free(map); +} + +/* ========================================================================= + * FLAG READ/WRITE + * ========================================================================= */ + +/** Get collision flags for a global tile coordinate. Returns 0 if region not loaded. */ +static inline int collision_get_flags(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return COLLISION_NONE; + int key = collision_region_hash(x, y); + const CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return COLLISION_NONE; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + return region->flags[h][lx][ly]; +} + +/** Set (OR) collision flags on a global tile coordinate. */ +static inline void collision_set_flag(CollisionMap* map, int height, int x, int y, int flag) { + CollisionRegion* region = collision_map_get_or_create(map, x, y); + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] |= flag; +} + +/** Unset (clear) collision flags on a global tile coordinate. */ +static inline void collision_unset_flag(CollisionMap* map, int height, int x, int y, int flag) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] &= ~flag; +} + +/** Mark a tile as fully blocked (terrain). */ +static inline void collision_mark_blocked(CollisionMap* map, int height, int x, int y) { + collision_set_flag(map, height, x, y, COLLISION_BLOCKED); +} + +/** Mark a multi-tile occupant (game object) as blocked + optionally impenetrable. */ +static inline void collision_mark_occupant(CollisionMap* map, int height, int x, int y, + int width, int length, int impenetrable) { + int flag = COLLISION_BLOCKED; + if (impenetrable) flag |= COLLISION_IMPENETRABLE_BLOCKED; + for (int xi = x; xi < x + width; xi++) { + for (int yi = y; yi < y + length; yi++) { + collision_set_flag(map, height, xi, yi, flag); + } + } +} + +/* ========================================================================= + * TRAVERSAL CHECKS (ported from TraversalMap.java) + * + * Each check tests the DESTINATION tile for incoming wall flags + BLOCKED. + * For diagonals: also checks the two cardinal intermediate tiles. + * + * All functions take a CollisionMap* which may be NULL (= all traversable). + * Height is always 0 for PvP (single plane). The height param is kept for + * future multi-plane support. + * ========================================================================= */ + +/** Check if flag bits are INACTIVE (none set) on a tile. */ +static inline int collision_is_inactive(const CollisionMap* map, int height, int x, int y, int flag) { + return (collision_get_flags(map, height, x, y) & flag) == 0; +} + +static inline int collision_traversable_north(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + /* diagonal tile: no SW wall, not blocked */ + /* east tile: no west wall, not blocked */ + /* north tile: no south wall, not blocked */ + return collision_is_inactive(map, height, x + 1, y + 1, + COLLISION_WALL_WEST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y + 1, + COLLISION_WALL_EAST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y - 1, + COLLISION_WALL_WEST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y - 1, + COLLISION_WALL_EAST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +/** + * Check if a tile is walkable (not blocked, no incoming walls from any direction). + * Simple check — just tests the BLOCKED flag on the tile itself. + */ +static inline int collision_tile_walkable(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return (collision_get_flags(map, height, x, y) & COLLISION_BLOCKED) == 0; +} + +/** + * Check directional traversability given dx/dy step direction. + * dx and dy should each be -1, 0, or 1. + */ +static inline int collision_traversable_step(const CollisionMap* map, int height, + int x, int y, int dx, int dy) { + if (map == NULL) return 1; + + if (dx == 0 && dy == 1) return collision_traversable_north(map, height, x, y); + if (dx == 0 && dy == -1) return collision_traversable_south(map, height, x, y); + if (dx == 1 && dy == 0) return collision_traversable_east(map, height, x, y); + if (dx == -1 && dy == 0) return collision_traversable_west(map, height, x, y); + if (dx == 1 && dy == 1) return collision_traversable_north_east(map, height, x, y); + if (dx == -1 && dy == 1) return collision_traversable_north_west(map, height, x, y); + if (dx == 1 && dy == -1) return collision_traversable_south_east(map, height, x, y); + if (dx == -1 && dy == -1) return collision_traversable_south_west(map, height, x, y); + + /* dx == 0 && dy == 0: no movement */ + return 1; +} + +/* ========================================================================= + * BINARY COLLISION MAP I/O + * + * Format: + * 4 bytes: magic "CMAP" + * 4 bytes: version (1) + * 4 bytes: region_count + * For each region: + * 4 bytes: region_hash (key) + * REGION_HEIGHT_LEVELS * REGION_SIZE * REGION_SIZE * 4 bytes: flags + * ========================================================================= */ + +#define COLLISION_MAP_MAGIC 0x50414D43 /* "CMAP" in little-endian */ +#define COLLISION_MAP_VERSION 1 + +/** Load collision map from a binary file. Returns NULL on failure. */ +static inline CollisionMap* collision_map_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (f == NULL) { + fprintf(stderr, "collision_map_load: cannot open %s\n", path); + return NULL; + } + + uint32_t magic, version, region_count; + if (fread(&magic, 4, 1, f) != 1 || magic != COLLISION_MAP_MAGIC) { + fprintf(stderr, "collision_map_load: bad magic in %s\n", path); + fclose(f); + return NULL; + } + if (fread(&version, 4, 1, f) != 1 || version != COLLISION_MAP_VERSION) { + fprintf(stderr, "collision_map_load: unsupported version %u in %s\n", version, path); + fclose(f); + return NULL; + } + if (fread(®ion_count, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated header in %s\n", path); + fclose(f); + return NULL; + } + + CollisionMap* map = collision_map_create(); + + for (uint32_t i = 0; i < region_count; i++) { + int32_t key; + if (fread(&key, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated at region %u in %s\n", i, path); + collision_map_free(map); + fclose(f); + return NULL; + } + + CollisionRegion* region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + size_t flags_size = sizeof(region->flags); + if (fread(region->flags, 1, flags_size, f) != flags_size) { + fprintf(stderr, "collision_map_load: truncated flags at region %u in %s\n", i, path); + free(region); + collision_map_free(map); + fclose(f); + return NULL; + } + + collision_map_put(map, key, region); + } + + fclose(f); + return map; +} + +/** Save collision map to a binary file. Returns 0 on success, -1 on failure. */ +static inline int collision_map_save(const CollisionMap* map, const char* path) { + FILE* f = fopen(path, "wb"); + if (f == NULL) return -1; + + uint32_t magic = COLLISION_MAP_MAGIC; + uint32_t version = COLLISION_MAP_VERSION; + uint32_t region_count = (uint32_t)map->count; + + fwrite(&magic, 4, 1, f); + fwrite(&version, 4, 1, f); + fwrite(®ion_count, 4, 1, f); + + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].key == -1) continue; + int32_t key = map->entries[i].key; + fwrite(&key, 4, 1, f); + fwrite(map->entries[i].region->flags, 1, sizeof(map->entries[i].region->flags), f); + } + + fclose(f); + return 0; +} + +/* ========================================================================= + * LINE OF SIGHT — fixed-point ray tracing with directional masks + * + * used by inferno pillars, zulrah safespots, and any future encounter + * that needs projectile blocking around obstacles. + * + * algorithm: Bresenham-style ray trace in Q16 fixed-point from tile center. + * each blocker has a directional bitmask indicating which sides block sight. + * FULL_MASK blocks from all directions. + * + * reference: osrs-sdk LineOfSight.ts + * ========================================================================= */ + +#define LOS_FULL_MASK 0x20000 +#define LOS_EAST_MASK 0x01000 +#define LOS_WEST_MASK 0x10000 +#define LOS_NORTH_MASK 0x00400 +#define LOS_SOUTH_MASK 0x04000 + +/* an entity that blocks line of sight. pillars, walls, etc. + * the los_mask indicates which directions are blocked. */ +typedef struct { + int x, y; /* top-left tile of the blocker */ + int size; /* NxN footprint */ + uint32_t los_mask; /* bitmask: which directions block LOS */ +} LOSBlocker; + +/* check if point (px,py) overlaps any blocker, return its mask or 0 */ +static uint32_t los_check_tile(const LOSBlocker* blockers, int count, + int px, int py) { + for (int i = 0; i < count; i++) { + const LOSBlocker* b = &blockers[i]; + if (px >= b->x && px < b->x + b->size && + py >= b->y && py < b->y + b->size) { + return b->los_mask; + } + } + return 0; +} + +/* AABB overlap check for two entities */ +static int los_aabb_overlap(int x1, int y1, int s1, int x2, int y2, int s2) { + return !(x1 >= x2 + s2 || x1 + s1 <= x2 || y1 >= y2 + s2 || y1 + s1 <= y2); +} + +/* fixed-point Q16 ray trace. returns 1 if clear LOS, 0 if blocked. + * traces from (x1,y1) to (x2,y2). src_size is the source entity size (1 for player). + * range is max tile distance (-1 = unlimited). */ +static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int x1, int y1, int x2, int y2, + int src_size, int range) { + int dx = x2 - x1; + int dy = y2 - y1; + + /* reject if either endpoint is inside a blocker */ + if (los_check_tile(blockers, blocker_count, x1, y1)) return 0; + if (los_check_tile(blockers, blocker_count, x2, y2)) return 0; + + /* self-overlap check */ + if (los_aabb_overlap(x1, y1, src_size, x2, y2, 1)) return 0; + + /* range check */ + if (range > 0) { + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + if (adx > range || ady > range) return 0; + } + + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + + if (adx > ady) { + /* x-dominant ray */ + int x_tile = x1; + int y_fp = (y1 << 16) + 0x8000; + int slope = (adx > 0) ? ((dy << 16) / adx) : 0; + int x_inc = (dx > 0) ? 1 : -1; + uint32_t x_mask = (dx > 0) ? (LOS_WEST_MASK | LOS_FULL_MASK) + : (LOS_EAST_MASK | LOS_FULL_MASK); + uint32_t y_mask = (dy < 0) ? (LOS_NORTH_MASK | LOS_FULL_MASK) + : (LOS_SOUTH_MASK | LOS_FULL_MASK); + if (dy < 0) y_fp -= 1; + + while (x_tile != x2) { + x_tile += x_inc; + int y_tile = y_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & x_mask) + return 0; + y_fp += slope; + int new_y = y_fp >> 16; + if (new_y != y_tile) { + if (los_check_tile(blockers, blocker_count, x_tile, new_y) & y_mask) + return 0; + } + } + } else if (ady > 0) { + /* y-dominant ray */ + int y_tile = y1; + int x_fp = (x1 << 16) + 0x8000; + int slope = (ady > 0) ? ((dx << 16) / ady) : 0; + int y_inc = (dy > 0) ? 1 : -1; + uint32_t y_mask = (dy > 0) ? (LOS_SOUTH_MASK | LOS_FULL_MASK) + : (LOS_NORTH_MASK | LOS_FULL_MASK); + uint32_t x_mask = (dx < 0) ? (LOS_EAST_MASK | LOS_FULL_MASK) + : (LOS_WEST_MASK | LOS_FULL_MASK); + if (dx < 0) x_fp -= 1; + + while (y_tile != y2) { + y_tile += y_inc; + int x_tile = x_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & y_mask) + return 0; + x_fp += slope; + int new_x = x_fp >> 16; + if (new_x != x_tile) { + if (los_check_tile(blockers, blocker_count, new_x, y_tile) & x_mask) + return 0; + } + } + } + /* else dx==0 && dy==0: same tile, always has LOS */ + + return 1; +} + +/* NPC LOS: for size>1 NPCs, check from target's closest point back to NPC. + * npc is at (nx,ny) SW corner with npc_size. target is at (tx,ty) size 1. + * for melee (range==1): pure cardinal adjacency — no ray-trace, no pillar check. + * ref: osrs-sdk LineOfSight.ts:88-89 (translated to our SW-anchor, Y-up coords). */ +static int npc_has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int nx, int ny, int npc_size, + int tx, int ty, int range) { + /* melee range: player must be on a cardinal side of the NPC bounding box + (north/south/east/west edge tiles). diagonal corners do NOT count. + NPC occupies [nx, nx+s-1] x [ny, ny+s-1]. cardinal-adjacent tiles: + north: y = ny+s, x in [nx, nx+s-1] + south: y = ny-1, x in [nx, nx+s-1] + east: x = nx+s, y in [ny, ny+s-1] + west: x = nx-1, y in [ny, ny+s-1] */ + if (range == 1) { + if (los_check_tile(blockers, blocker_count, tx, ty)) return 0; + if (los_aabb_overlap(nx, ny, npc_size, tx, ty, 1)) return 0; + int dx = tx - nx; + int dy = ty - ny; + return (dx >= 0 && dx < npc_size && (dy == npc_size || dy == -1)) || + (dy >= 0 && dy < npc_size && (dx == npc_size || dx == -1)); + } + + /* ranged/magic: find closest point on NPC footprint, ray-trace */ + int cx = tx; + if (cx < nx) cx = nx; + if (cx >= nx + npc_size) cx = nx + npc_size - 1; + int cy = ty; + if (cy < ny) cy = ny; + if (cy >= ny + npc_size) cy = ny + npc_size - 1; + + return has_line_of_sight(blockers, blocker_count, tx, ty, cx, cy, 1, range); +} + +#endif /* OSRS_COLLISION_H */ diff --git a/src/osrs/osrs_combat.h b/src/osrs/osrs_combat.h new file mode 100644 index 0000000000..f23bd1d31e --- /dev/null +++ b/src/osrs/osrs_combat.h @@ -0,0 +1,456 @@ +/** + * @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)); +} + +/* twisted bow accuracy multiplier. + target_magic = min(max(npc_magic_level, npc_magic_attack_bonus), 250). + formula from RuneLite TwistedBow._accuracyMultiplier. */ +static inline float osrs_tbow_acc_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + /* ref: osrs-sdk TwistedBow.ts _accuracyMultiplier + linear term uses 3*magic, quadratic uses 3*magic/10 */ + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (140.0f + (lin - 10.0f) / 100.0f - (quad - 100.0f) * (quad - 100.0f) / 100.0f) / 100.0f; + if (mult > 1.4f) mult = 1.4f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* twisted bow damage multiplier. + same input as accuracy multiplier. + ref: osrs-sdk TwistedBow.ts _damageMultiplier */ +static inline float osrs_tbow_dmg_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (250.0f + (lin - 14.0f) / 100.0f - (quad - 140.0f) * (quad - 140.0f) / 100.0f) / 100.0f; + if (mult > 2.5f) mult = 2.5f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* ======================================================================== */ +/* shared encounter RNG (xorshift32) */ +/* ======================================================================== */ + +/* all encounters should use these instead of reimplementing. + state must be non-zero. */ +static inline uint32_t encounter_xorshift(uint32_t* state) { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + return *state; +} + +static inline int encounter_rand_int(uint32_t* rng_state, int max) { + if (max <= 0) return 0; + return (int)(encounter_xorshift(rng_state) % (unsigned)max); +} + +static inline float encounter_rand_float(uint32_t* rng_state) { + return (float)(encounter_xorshift(rng_state) & 0xFFFF) / 65536.0f; +} + +/* ======================================================================== */ +/* barrage AoE (3x3) */ +/* ======================================================================== */ + +#define BARRAGE_MAX_HITS 9 +#define BARRAGE_FREEZE_TICKS 32 + +/* per-target info for barrage AoE. caller fills in the target array, + osrs_barrage_resolve does accuracy/damage rolls and writes results back. */ +typedef struct { + int active; /* in: 1 if this target slot is valid */ + int x, y; /* in: NPC SW corner tile position */ + int def_level; /* in: NPC defence level */ + int magic_def_bonus; /* in: NPC magic defence bonus */ + int npc_idx; /* in: index into caller's NPC array (for callbacks) */ + int* frozen_ticks; /* in: pointer to NPC's frozen_ticks (NULL = no freeze tracking) */ + int hit; /* out: 1 = accuracy passed, 0 = splashed */ + int damage; /* out: damage rolled (0 if splashed) */ +} BarrageTarget; + +/* result from a barrage cast */ +typedef struct { + int total_damage; /* sum of all damage across AoE */ + int num_hits; /* number of targets that were rolled against */ + int num_successful; /* number that passed accuracy (hit=1) */ +} BarrageResult; + +/* resolve a barrage spell against a primary target + 3x3 AoE. + - targets[0] is the primary target (always rolled first) + - targets[1..max_targets-1] are potential AoE targets (only those within + 1 tile of primary are rolled against) + - att_roll: pre-computed attacker magic roll (eff_level * (bonus + 64)) + - max_hit: barrage spell max hit + - rng_state: pointer to RNG state for rolls + - max_targets: size of targets array + + the function sets hit/damage on each target. if spell_type is ICE and + a target's frozen_ticks pointer is set, freeze is applied immediately + at cast time (ref: osrs-sdk IceBarrageSpell.ts). caller is responsible + for queueing damage as pending hits with appropriate delay. + + returns aggregate result for reward/heal calculations. */ +static inline BarrageResult osrs_barrage_resolve( + BarrageTarget* targets, int max_targets, + int att_roll, int max_hit, uint32_t* rng_state, + int spell_type +) { + BarrageResult result = { 0, 0, 0 }; + + if (max_targets < 1 || !targets[0].active) return result; + + /* primary target (index 0) always gets rolled */ + int px = targets[0].x, py = targets[0].y; + { + int def_roll = (targets[0].def_level + 8) * (targets[0].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[0].hit = encounter_rand_float(rng_state) < chance; + targets[0].damage = targets[0].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[0].damage; + result.num_hits++; + if (targets[0].hit) { + result.num_successful++; + /* ice barrage: freeze immediately at cast time */ + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[0].frozen_ticks) + *targets[0].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + /* AoE: roll against all other active targets within 1 tile of primary */ + for (int i = 1; i < max_targets && result.num_hits < BARRAGE_MAX_HITS; i++) { + if (!targets[i].active) continue; + int dx = targets[i].x - px; + int dy = targets[i].y - py; + if (dx < -1 || dx > 1 || dy < -1 || dy > 1) continue; + + int def_roll = (targets[i].def_level + 8) * (targets[i].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[i].hit = encounter_rand_float(rng_state) < chance; + targets[i].damage = targets[i].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[i].damage; + result.num_hits++; + if (targets[i].hit) { + result.num_successful++; + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[i].frozen_ticks) + *targets[i].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + return result; +} + +/* ======================================================================== */ +/* NPC combat formulas (from InfernoTrainer/osrs-sdk) */ +/* ======================================================================== */ + +/* NPC melee max hit: floor((str + 8) * (melee_str_bonus + 64) + 320) / 640) */ +static inline int osrs_npc_melee_max_hit(int str_level, int melee_str_bonus) { + return ((str_level + 8) * (melee_str_bonus + 64) + 320) / 640; +} + +/* NPC ranged max hit: floor(0.5 + (range + 8) * (ranged_str_bonus + 64) / 640) */ +static inline int osrs_npc_ranged_max_hit(int range_level, int ranged_str_bonus) { + return (int)(0.5 + (double)(range_level + 8) * (ranged_str_bonus + 64) / 640.0); +} + +/* NPC magic max hit: floor(base_spell_dmg * magic_dmg_pct / 100). + magic_dmg_pct=100 means 1.0x multiplier, 175 means 1.75x. */ +static inline int osrs_npc_magic_max_hit(int base_spell_dmg, int magic_dmg_pct) { + return base_spell_dmg * magic_dmg_pct / 100; +} + +/* NPC attack roll: (att_level + 9) * (att_bonus + 64). + NPCs don't have prayer or void bonuses — just level + invisible +9. */ +static inline int osrs_npc_attack_roll(int att_level, int att_bonus) { + return (att_level + 9) * (att_bonus + 64); +} + +/* player defence roll against NPC attack. + OSRS formula: eff_def = level + stance_bonus + 8. players don't have the + hidden +1 that NPCs get (that's why NPC attack roll uses +9). + our sim doesn't model stance bonuses, so stance_bonus = 0. + vs melee/ranged: (def_level + 8) * (def_bonus + 64). + vs magic: (floor(magic_level * 0.7 + def_level * 0.3) + 8) * (def_bonus + 64). + ref: osrs-sdk MeleeWeapon.ts:164, OSRS wiki combat formulas. */ +static inline int osrs_player_def_roll_vs_npc( + int def_level, int magic_level, int def_bonus, int attack_style +) { + int eff_def; + if (attack_style == 3) { /* ATTACK_STYLE_MAGIC = 3 */ + eff_def = (int)(magic_level * 0.7 + def_level * 0.3) + 8; + } else { + eff_def = def_level + 8; + } + return eff_def * (def_bonus + 64); +} + +/* 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 */); +} + +/* ======================================================================== */ +/* hit delay formulas (matching PvP + InfernoTrainer SDK) */ +/* ======================================================================== */ + +/* magic hit delay: floor((1 + distance) / 3) + 1, +1 if attacker is player */ +static inline int encounter_magic_hit_delay(int distance, int is_player) { + return (1 + distance) / 3 + 1 + (is_player ? 1 : 0); +} + +/* ranged hit delay: floor((3 + distance) / 6) + 1, +1 if attacker is player */ +static inline int encounter_ranged_hit_delay(int distance, int is_player) { + return (3 + distance) / 6 + 1 + (is_player ? 1 : 0); +} + +/* blowpipe hit delay: floor(distance / 6) + 1, +1 if attacker is player. + blowpipe overrides the generic ranged formula — faster at longer range. + ref: InfernoTrainer Blowpipe.ts:56-58. */ +static inline int encounter_blowpipe_hit_delay(int distance, int is_player) { + return distance / 6 + 1 + (is_player ? 1 : 0); +} + +/* 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; +} + +/* 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/src/osrs/osrs_consumables.h b/src/osrs/osrs_consumables.h new file mode 100644 index 0000000000..46dca1619a --- /dev/null +++ b/src/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/src/osrs/osrs_damage.h b/src/osrs/osrs_damage.h new file mode 100644 index 0000000000..1aaaa40918 --- /dev/null +++ b/src/osrs/osrs_damage.h @@ -0,0 +1,113 @@ +/** + * @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" + +/* ======================================================================== */ +/* damage pipeline result */ +/* ======================================================================== */ + +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 */ +} DamageResult; + +/* 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 +) { + DamageResult r = {0, 0, 0, 0, 0}; + + /* 1. prayer reduction */ + int prayer_correct = encounter_prayer_correct_for_style(target_prayer, attack_style); + r.prayer_blocked = prayer_correct; + r.final_damage = osrs_prayer_reduce_damage(raw_damage, target_prayer, attack_style, is_pvp); + + /* 2. vengeance: 75% of post-prayer damage reflected to attacker. + ref: osrs_pvp_combat.h:607-618 */ + if (target_veng_active && r.final_damage > 0) { + r.veng_damage = (int)(r.final_damage * 0.75f); + } + + /* 3. recoil: floor(damage * 0.1) + 1 reflected to attacker. + charge tracking is caller's responsibility (ring of recoil has 40 charges, + ring of suffering (i) has infinite). + ref: osrs_pvp_combat.h:621-645, encounter_zulrah.h:660-675 */ + if (target_has_recoil && r.final_damage > 0) { + r.recoil_damage = r.final_damage / 10 + 1; + } + + /* 4. smite: floor(damage / 4) drained from target prayer. + ref: osrs_pvp_combat.h:688-691 */ + if (attacker_smite_active && r.final_damage > 0) { + r.smite_drain = r.final_damage / 4; + } + + return r; +} + +/* ======================================================================== */ +/* helpers */ +/* ======================================================================== */ + +/* 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/src/osrs/osrs_encounter.h b/src/osrs/osrs_encounter.h new file mode 100644 index 0000000000..d8afbf72df --- /dev/null +++ b/src/osrs/osrs_encounter.h @@ -0,0 +1,1463 @@ +/** + * @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: + * ENCOUNTER_PRAYER_* canonical 5-value prayer action encoding + * encounter_apply_prayer_action() apply prayer action to OverheadPrayer state + * + * movement: + * ENCOUNTER_MOVE_TARGET_DX/DY[25] direction tables (idle + 8 walk + 16 run) + * encounter_move_to_target() player movement: walk 1 tile or run 2 + * encounter_move_toward_dest() BFS click-to-move toward destination + * encounter_pathfind() shared BFS pathfind wrapper + * + * NPC pathfinding: + * encounter_npc_step_out_from_under() shuffle NPC off player tile (OSRS overlap rule) + * encounter_npc_step_toward() greedy size-aware chase step (diagonal > x > y) + * + * damage: + * encounter_damage_player() apply damage to player (HP, clamp, splat, tracker) + * encounter_damage_npc() apply damage to NPC (HP, splat flags) + * + * per-tick flags: + * encounter_clear_tick_flags() reset animation/event flags each tick + * + * gear switching: + * encounter_apply_loadout() memcpy loadout + set gear state + * encounter_populate_inventory() dedup items from multiple loadouts for GUI + * + * combat stats: + * EncounterLoadoutStats derived stats (att bonus, max hit, eff level...) + * EncounterPrayer prayer multiplier enum + * encounter_compute_loadout_stats() derive all stats from ITEM_DATABASE + loadout + * + * hit delays: + * EncounterPendingHit queued damage with tick countdown + * + * ALSO SEE: + * osrs_combat.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_human_input_types.h" + +/* opaque encounter state — each encounter defines its own struct */ +typedef struct EncounterState EncounterState; + +/* ======================================================================== */ +/* shared pending hit system for delayed projectile damage */ +/* ======================================================================== */ + +#define ENCOUNTER_MAX_PENDING_HITS 8 + +/* spell types for barrage freeze/heal effects on pending hits */ +#define ENCOUNTER_SPELL_NONE 0 +#define ENCOUNTER_SPELL_ICE 1 /* ice barrage: freeze on hit */ +#define ENCOUNTER_SPELL_BLOOD 2 /* blood barrage: heal 25% of AoE damage */ + +typedef struct { + int active; + int damage; + int ticks_remaining; /* countdown to landing */ + int attack_style; /* ATTACK_STYLE_* for prayer check at land time */ + int check_prayer; /* 1 = re-check prayer when hit lands (jad) */ + int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ +} EncounterPendingHit; + +/* visual overlay data: shared between encounter and renderer. + encounter's render_post_tick populates this, renderer reads it. */ +#define ENCOUNTER_MAX_OVERLAY_TILES 16 +#define ENCOUNTER_MAX_OVERLAY_ADDS 4 +#define ENCOUNTER_MAX_OVERLAY_PROJECTILES 8 + +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 src_size; /* source entity size for center offset (0 = use boss_size) */ + int dst_size; /* target entity size for center offset (1 = player) */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ + } projectiles[ENCOUNTER_MAX_OVERLAY_PROJECTILES]; + int projectile_count; + + /* melee targeting: shows which tile Zulrah is staring at */ + int melee_target_active; + int melee_target_x, melee_target_y; +} EncounterOverlay; + +/* map AttackStyle enum to overlay projectile style index. + used by encounter_emit_projectile and render overlay systems. */ +static inline int encounter_attack_style_to_proj_style(int attack_style) { + switch (attack_style) { + case ATTACK_STYLE_RANGED: return 0; + case ATTACK_STYLE_MAGIC: return 1; + case ATTACK_STYLE_MELEE: return 2; + default: return 0; + } +} + +/* populate an overlay projectile slot with flight parameters. + encounters should call this instead of filling fields manually. */ +static inline int encounter_emit_projectile( + EncounterOverlay* ov, + int src_x, int src_y, int dst_x, int dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, int src_size, int dst_size, + uint32_t model_id +) { + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) return -1; + int i = ov->projectile_count++; + ov->projectiles[i].active = 1; + ov->projectiles[i].src_x = src_x; + ov->projectiles[i].src_y = src_y; + ov->projectiles[i].dst_x = dst_x; + ov->projectiles[i].dst_y = dst_y; + ov->projectiles[i].style = style; + ov->projectiles[i].damage = damage; + ov->projectiles[i].duration_ticks = duration_ticks; + ov->projectiles[i].start_h = start_h; + ov->projectiles[i].end_h = end_h; + ov->projectiles[i].curve = curve; + ov->projectiles[i].arc_height = arc_height; + ov->projectiles[i].start_delay = 0; + ov->projectiles[i].tracks_target = tracks_target; + ov->projectiles[i].src_size = src_size; + ov->projectiles[i].dst_size = dst_size; + ov->projectiles[i].model_id = model_id; + return i; +} + +/* ======================================================================== */ +/* render entity: shared abstraction for renderer (value type, not pointer) */ +/* ======================================================================== */ + +typedef struct { + EntityType entity_type; + int npc_def_id; + int npc_visible; + int npc_size; + int npc_anim_id; + int x, y; + int dest_x, dest_y; + int current_hitpoints, base_hitpoints; + int special_energy; + OverheadPrayer prayer; + GearSet visible_gear; + int frozen_ticks; + int veng_active; + int is_running; + AttackStyle attack_style_this_tick; + int magic_type_this_tick; + int hit_landed_this_tick; + int hit_damage; + int hit_was_successful; + int hit_spell_type; /* ENCOUNTER_SPELL_* for barrage impact effects on NPCs */ + int cast_veng_this_tick; + int ate_food_this_tick; + int ate_karambwan_this_tick; + int used_special_this_tick; + uint8_t equipped[NUM_GEAR_SLOTS]; + int npc_slot; /* source slot index in encounter's NPC array; -1 for player */ + int attack_target_entity_idx; /* render entity index of attack target, -1 = none */ +} RenderEntity; + +/** Fill a RenderEntity from a Player struct. */ +static inline void render_entity_from_player(const Player* p, RenderEntity* out) { + out->entity_type = p->entity_type; + out->npc_def_id = p->npc_def_id; + out->npc_visible = p->npc_visible; + out->npc_size = p->npc_size; + out->npc_anim_id = p->npc_anim_id; + out->x = p->x; + out->y = p->y; + out->dest_x = p->dest_x; + out->dest_y = p->dest_y; + out->current_hitpoints = p->current_hitpoints; + out->base_hitpoints = p->base_hitpoints; + out->special_energy = p->special_energy; + out->prayer = p->prayer; + out->visible_gear = p->visible_gear; + out->frozen_ticks = p->frozen_ticks; + out->veng_active = p->veng_active; + out->is_running = p->is_running; + out->attack_style_this_tick = p->attack_style_this_tick; + out->magic_type_this_tick = p->magic_type_this_tick; + out->hit_landed_this_tick = p->hit_landed_this_tick; + out->hit_damage = p->hit_damage; + out->hit_was_successful = p->hit_was_successful; + out->cast_veng_this_tick = p->cast_veng_this_tick; + out->ate_food_this_tick = p->ate_food_this_tick; + out->ate_karambwan_this_tick = p->ate_karambwan_this_tick; + out->used_special_this_tick = p->used_special_this_tick; + memcpy(out->equipped, p->equipped, NUM_GEAR_SLOTS); + out->npc_slot = -1; /* player, not an NPC */ + out->attack_target_entity_idx = -1; +} + +/** Resolve attack_target_entity_idx for entity 0 (player) by matching npc_slot. + call after fill_render_entities populates the entity array. any encounter with + NPC targeting should call this so the renderer faces the correct target. */ +static inline void encounter_resolve_attack_target( + RenderEntity* entities, int count, int target_npc_slot +) { + entities[0].attack_target_entity_idx = -1; + if (target_npc_slot < 0) return; + for (int i = 1; i < count; i++) { + if (entities[i].npc_slot == target_npc_slot) { + entities[0].attack_target_entity_idx = i; + return; + } + } +} + +/* ======================================================================== */ +/* canonical prayer action encoding */ +/* ======================================================================== */ + +/* all encounters MUST use this encoding for the prayer action head. + 0 = no change (prayer persists from previous tick) + 1 = turn off prayer (PRAYER_NONE) + 2 = protect melee + 3 = protect ranged + 4 = protect magic + action dim = 5 for any encounter using this encoding. */ +#define ENCOUNTER_PRAYER_NO_CHANGE 0 +#define ENCOUNTER_PRAYER_OFF 1 +#define ENCOUNTER_PRAYER_MELEE 2 +#define ENCOUNTER_PRAYER_RANGED 3 +#define ENCOUNTER_PRAYER_MAGIC 4 +#define ENCOUNTER_PRAYER_DIM 5 + +/* apply a prayer action to the active prayer state. 0=no change. */ +static inline void encounter_apply_prayer_action(OverheadPrayer* prayer, int action) { + switch (action) { + case ENCOUNTER_PRAYER_NO_CHANGE: break; + case ENCOUNTER_PRAYER_OFF: *prayer = PRAYER_NONE; break; + case ENCOUNTER_PRAYER_MELEE: *prayer = PRAYER_PROTECT_MELEE; break; + case ENCOUNTER_PRAYER_RANGED: *prayer = PRAYER_PROTECT_RANGED; break; + case ENCOUNTER_PRAYER_MAGIC: *prayer = PRAYER_PROTECT_MAGIC; break; + } +} + +/* ======================================================================== */ +/* shared movement: 25-action system (idle + 8 walk + 16 run) */ +/* ======================================================================== */ + +/* 25 movement actions: idle(0), walk(1-8), run(9-24) */ +#define ENCOUNTER_MOVE_ACTIONS 25 + +/* target offsets: (dx, dy) relative to player position */ +static const int ENCOUNTER_MOVE_TARGET_DX[25] = { + 0, /* 0: idle */ + -1, -1, -1, 0, 0, 1, 1, 1, /* 1-8: walk (dist 1) */ + -2, -2, -2, -2, -2, /* 9-13: run west edge */ + -1, -1, /* 14-15: run inner */ + 0, 0, /* 16-17: run N/S 2 tiles */ + 1, 1, /* 18-19: run inner */ + 2, 2, 2, 2, 2 /* 20-24: run east edge */ +}; +static const int ENCOUNTER_MOVE_TARGET_DY[25] = { + 0, + -1, 0, 1, -1, 1, -1, 0, 1, + -2, -1, 0, 1, 2, + -2, 2, + -2, 2, + -2, 2, + -2, -1, 0, 1, 2 +}; + +/* callback: returns 1 if tile (x, y) is walkable for the encounter. + ctx is encounter-specific state (InfernoState*, ZulrahState*, etc.) */ +typedef int (*encounter_walkable_fn)(void* ctx, int x, int y); + +/** move player toward target offset via up to 2 greedy steps. + walk actions (dist 1) take 1 step, run actions (dist 2) take up to 2. + sets is_running = 1 if 2 steps were taken. + returns number of tiles moved (0, 1, or 2). */ +static inline int encounter_move_to_target( + Player* p, int target_dx, int target_dy, + encounter_walkable_fn is_walkable, void* ctx +) { + int tx = p->x + target_dx; + int ty = p->y + target_dy; + int dist = abs(target_dx) > abs(target_dy) ? abs(target_dx) : abs(target_dy); + int max_steps = dist; /* 1 for walk, 2 for run */ + int steps = 0; + + for (int step = 0; step < max_steps; step++) { + if (p->x == tx && p->y == ty) break; + /* greedy step toward target */ + int dx = 0, dy = 0; + if (tx > p->x) dx = 1; else if (tx < p->x) dx = -1; + if (ty > p->y) dy = 1; else if (ty < p->y) dy = -1; + + /* try diagonal, x-only, y-only */ + int moved = 0; + if (dx != 0 && dy != 0 && is_walkable(ctx, p->x + dx, p->y + dy)) { + p->x += dx; p->y += dy; moved = 1; + } else if (dx != 0 && is_walkable(ctx, p->x + dx, p->y)) { + p->x += dx; moved = 1; + } else if (dy != 0 && is_walkable(ctx, p->x, p->y + dy)) { + p->y += dy; moved = 1; + } + if (!moved) break; + steps++; + } + + p->is_running = (steps == 2); + p->dest_x = p->x; + p->dest_y = p->y; + return steps; +} + +/* ======================================================================== */ +/* shared BFS click-to-move (human mode + destination-based movement) */ +/* ======================================================================== */ + +/* shared BFS pathfind wrapper — translates local coords to world coords for pathfind_step. + extra_blocked/blocked_ctx: optional callback for dynamic obstacles (pillars etc.). + pass NULL/NULL for encounters with no dynamic obstacles. */ +static inline PathResult encounter_pathfind( + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + int src_x, int src_y, int dst_x, int dst_y, + pathfind_blocked_fn extra_blocked, void* blocked_ctx +) { + 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; +} + +/* ======================================================================== */ +/* shared attack-target chase (auto-walk toward out-of-range target) */ +/* ======================================================================== */ + +/* 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 npc_has_line_of_sight(los_blockers, los_blocker_count, + target_x, target_y, target_size, + player_x, player_y, attack_range); +} + +/* auto-walk toward attack target: handles out-of-range, blocked LOS, and under-NPC. + 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 that have ACTUAL LOS to + the NPC. only tiles where encounter_player_can_attack would return true + are valid candidates. BFS then pathfinds to the nearest one. + ref: osrs-sdk Player.ts "seekingTiles" — filters by LOS, not just pillar overlap. */ + int best_dsq = 999999; + cx = -1; cy = -1; + /* scan cardinal-adjacent tiles (N/S rows + E/W columns of NPC footprint) */ + for (int xx = -1; xx <= target_size; xx++) { + for (int yy = -1; yy <= target_size; yy++) { + /* skip interior tiles (inside NPC footprint) */ + if (xx >= 0 && xx < target_size && yy >= 0 && yy < target_size) continue; + /* skip far corners (only cardinal adjacency matters for melee/range) */ + int px = target_x + xx; + int py = target_y + yy; + if (!is_walkable(ctx, px, py)) continue; + /* check if this tile has actual LOS + range to the NPC */ + if (!encounter_player_can_attack(px, py, target_x, target_y, + target_size, attack_range, los_blockers, los_blocker_count)) + continue; + int ddx = px - p->x, ddy = py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = px; cy = py; } + } + } + /* fallback: no unblocked adjacent tile, path toward closest NPC tile */ + if (cx < 0) { + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + } else { + /* out of range: path toward closest NPC tile */ + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + + int steps = 0; + for (int step = 0; step < 2; step++) { + if (encounter_player_can_attack(p->x, p->y, target_x, target_y, + target_size, attack_range, + los_blockers, los_blocker_count)) + break; + PathResult pr = (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; +} + +/* ======================================================================== */ +/* shared NPC step-out-from-under (OSRS: NPC shuffles off player tile) */ +/* ======================================================================== */ + +/* when an NPC overlaps the player (AABB overlap), it shuffles one tile in a + random cardinal direction. matches osrs-sdk Mob.ts:109-153 behavior: + 50% pick X-axis vs Y-axis, then 50% +1 or -1 on that axis. + returns 1 if the NPC moved, 0 if stuck or no overlap. */ +static inline int encounter_npc_step_out_from_under( + int* npc_x, int* npc_y, int npc_size, + int player_x, int player_y, + encounter_walkable_fn is_walkable, void* ctx, uint32_t* rng +) { + /* AABB overlap check (handles multi-tile NPCs) */ + int overlap = !(*npc_x >= player_x + 1 || *npc_x + npc_size <= player_x || + *npc_y >= player_y + 1 || *npc_y + npc_size <= player_y); + if (!overlap) return 0; + + /* 4 cardinal directions: +x, -x, +y, -y */ + int dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}}; + + /* random start: 50% X-axis first (dirs 0,1) vs Y-axis first (dirs 2,3), + then 50% positive vs negative on that axis */ + int axis = encounter_rand_int(rng, 2); /* 0=X, 1=Y */ + int sign = encounter_rand_int(rng, 2); /* 0=positive, 1=negative */ + int order[4]; + order[0] = axis * 2 + sign; /* primary: chosen axis+sign */ + order[1] = axis * 2 + (1 - sign); /* secondary: chosen axis, other sign */ + order[2] = (1 - axis) * 2 + sign; /* tertiary: other axis, same sign */ + order[3] = (1 - axis) * 2 + (1 - sign); /* last: other axis, other sign */ + + for (int i = 0; i < 4; i++) { + int nx = *npc_x + dirs[order[i]][0]; + int ny = *npc_y + dirs[order[i]][1]; + /* InfernoTrainer Mob.ts:128-142: 1-tile shuffle per tick, validated + via normal edge-tile movement system. for size>1 NPCs, full escape + takes multiple ticks. anchor walkability matches InfernoTrainer's + canTileBePathedTo check on the leading edge. */ + if (is_walkable(ctx, nx, ny)) { + *npc_x = nx; + *npc_y = ny; + return 1; + } + } + return 0; +} + +/* ======================================================================== */ +/* shared NPC greedy pathfinding */ +/* ======================================================================== */ + +/* callback: returns 1 if tile (x, y) is blocked for an NPC of given size */ +typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); + +/** check if the leading edge tiles are clear for an NPC moving in direction (dx, dy). + for size>1 NPCs, OSRS checks the tiles along the leading edge that the NPC + sweeps through — not the full destination footprint. for diagonal moves, each + edge strip extends by 1 tile to cover the corner. + ref: InfernoTrainer Mob.ts:229-270 getXMovementTiles/getYMovementTiles. + is_blocked is called with size=1 for each individual edge tile. */ +static inline int encounter_npc_x_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dx == 0) return 1; + int ex = (dx == 1) ? x + size : x - 1; + int y_start = (dy == -1) ? y - 1 : y; + int y_end = (dy == 1) ? y + size : y + size - 1; + for (int ey = y_start; ey <= y_end; ey++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +static inline int encounter_npc_y_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dy == 0) return 1; + int ey = (dy == 1) ? y + size : y - 1; + int x_start = (dx == -1) ? x - 1 : x; + int x_end = (dx == 1) ? x + size : x + size - 1; + for (int ex = x_start; ex <= x_end; ex++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +/** greedy NPC step toward target. tries diagonal first, then x-only, then y-only. + this is the current generic NPC chase policy used by the ocean envs. + + for size>1 NPCs, validates movement by checking EDGE TILES the NPC sweeps + through, not just the destination footprint. for diagonal moves, both the + x-edge and y-edge must be clear (each extended by 1 tile for the corner). + ref: InfernoTrainer Mob.ts:160-270 movementStep + getX/YMovementTiles. + + corner safespot: if diagonal would land NPC on player, cancel Y component. + ref: InfernoTrainer Mob.ts:143-146. + + returns 1 if moved, 0 if blocked or already at target. */ +static inline int encounter_npc_step_toward( + int* x, int* y, int tx, int ty, int npc_size, + int target_size, int attack_range, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + int dist = encounter_entity_footprint_distance(*x, *y, npc_size, + tx, ty, target_size); + if (dist >= 1 && dist <= attack_range) return 0; + + int size = npc_size; + int dx = 0, dy = 0; + if (tx > *x) dx = 1; + else if (tx < *x) dx = -1; + if (ty > *y) dy = 1; + else if (ty < *y) dy = -1; + if (dx == 0 && dy == 0) return 0; + + /* corner safespot cancellation: if a diagonal step would overlap the target, + cancel the Y component and take X-only. */ + if (dx != 0 && dy != 0) { + int nx = *x + dx, ny = *y + dy; + if (encounter_entity_footprints_overlap(nx, ny, size, tx, ty, target_size)) { + dy = 0; + } + } + + /* size-1 NPCs: simple destination check (edge tiles = destination tile) */ + if (size <= 1) { + if (dx != 0 && dy != 0 && !is_blocked(ctx, *x + dx, *y + dy, 1)) { + *x += dx; *y += dy; return 1; + } + if (dx != 0 && !is_blocked(ctx, *x + dx, *y, 1)) { + *x += dx; return 1; + } + if (dy != 0 && !is_blocked(ctx, *x, *y + dy, 1)) { + *y += dy; return 1; + } + return 0; + } + + /* size>1 NPCs: edge-tile validation per InfernoTrainer. + diagonal: both x-edge AND y-edge must be clear (each extended by 1 for corner). + cardinal: just the leading edge (size tiles). */ + if (dx != 0 && dy != 0) { + int x_clear = encounter_npc_x_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + int y_clear = encounter_npc_y_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + if (x_clear && y_clear) { + *x += dx; *y += dy; return 1; + } + /* diagonal failed — fall through to try cardinal with dy=0 edge strips */ + } + /* x-only: check leading x-edge (size tiles, no diagonal extension) */ + if (dx != 0 && encounter_npc_x_edge_clear(*x, *y, size, dx, 0, is_blocked, ctx)) { + *x += dx; return 1; + } + /* y-only: check leading y-edge (size tiles, no diagonal extension) */ + if (dy != 0 && encounter_npc_y_edge_clear(*x, *y, size, 0, dy, is_blocked, ctx)) { + *y += dy; return 1; + } + return 0; +} + +/* ======================================================================== */ +/* shared damage application helpers */ +/* */ +/* ENCOUNTERS: use these instead of manually subtracting HP, clamping, */ +/* and setting hit splat flags. prevents bugs from forgetting a step. */ +/* ======================================================================== */ + +/** apply damage to a player. updates HP (clamped to 0), sets hit splat flags, + and accumulates damage into a per-tick tracker (for reward calculation). + damage_tracker can be NULL if not needed. + always sets hit_landed_this_tick so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_player( + Player* p, int damage, float* damage_tracker +) { + if (damage > 0) { + p->current_hitpoints -= damage; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + if (damage_tracker) *damage_tracker += (float)damage; + } + p->hit_landed_this_tick = 1; + p->hit_damage = damage > 0 ? damage : 0; +} + +/** apply damage to an NPC-like entity via raw field pointers. + works with any struct that has hp/hit_landed/hit_damage int fields. + always sets hit_landed so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_npc( + int* hp, int* hit_landed, int* hit_damage, int damage +) { + if (damage > 0) { + *hp -= damage; + } + *hit_landed = 1; + *hit_damage = damage > 0 ? damage : 0; +} + +/* ======================================================================== */ +/* shared NPC pending hit resolution (barrage freeze + blood heal) */ +/* ======================================================================== */ + +/** resolve a single NPC's pending hit. tick down, apply damage when it lands. + ice barrage: sets *frozen_ticks = BARRAGE_FREEZE_TICKS on hit. + blood barrage: accumulates landed damage into *blood_heal_acc for 25% heal. + returns 1 if hit landed this call, 0 otherwise. */ +static inline int encounter_resolve_npc_pending_hit( + EncounterPendingHit* ph, + int* npc_hp, int* hit_landed, int* hit_damage, + int* frozen_ticks, int* blood_heal_acc, float* damage_dealt_acc +) { + (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 +) { + for (int i = 0; i < *hit_count; i++) { + hits[i].ticks_remaining--; + if (hits[i].ticks_remaining <= 0) { + int dmg = hits[i].damage; + if (hits[i].check_prayer) { + if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { + dmg = 0; + if (prayer_correct_count) (*prayer_correct_count)++; + } + } + encounter_damage_player(player, dmg, damage_received_acc); + hits[i] = hits[--(*hit_count)]; + i--; + } + } +} + +/* ======================================================================== */ +/* shared per-tick flag clearing for encounters */ +/* ======================================================================== */ + +/** clear all per-tick animation/event flags on a player. + call at the start of each encounter tick, then set flags as events happen. + the renderer reads these once per frame via RenderEntity. */ +static inline void encounter_clear_tick_flags(Player* p) { + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->hit_landed_this_tick = 0; + p->hit_damage = 0; + p->hit_was_successful = 0; + p->cast_veng_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->used_special_this_tick = 0; +} + +/* ======================================================================== */ +/* shared reset helpers */ +/* ======================================================================== */ + +/** resolve RNG seed for encounter reset. priority: explicit seed > saved state > default. + all encounters MUST use this to ensure consistent RNG initialization. */ +static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t explicit_seed) { + uint32_t rng = 12345; + if (saved_rng != 0) rng = saved_rng; + if (explicit_seed != 0) rng = explicit_seed; + return rng; +} + +/* ======================================================================== */ +/* shared prayer drain */ +/* */ +/* ENCOUNTERS: call encounter_drain_prayer() each tick to drain prayer */ +/* points at the correct OSRS rate. all encounters with overhead prayers */ +/* MUST use this — do not hand-roll prayer drain logic. */ +/* */ +/* OSRS drain formula: each prayer has a "drain effect" value. */ +/* drain rate = 1 point per floor((18 + floor(bonus/4)) / drain_effect) */ +/* seconds. the drain counter increments each tick; when it reaches the */ +/* threshold, 1 prayer point is drained and the counter resets. */ +/* */ +/* protection prayers (melee/ranged/magic): drain_effect = 12 */ +/* rigour: drain_effect = 24, augury: drain_effect = 24 */ +/* ======================================================================== */ + +/** drain effect values for overhead prayers. + from the OSRS prayer table — higher values drain faster. + used by both PvE encounters and PvP. */ +static inline int encounter_prayer_drain_effect(OverheadPrayer prayer) { + switch (prayer) { + case PRAYER_PROTECT_MELEE: return 12; + case PRAYER_PROTECT_RANGED: return 12; + case PRAYER_PROTECT_MAGIC: return 12; + case PRAYER_SMITE: return 12; + case PRAYER_REDEMPTION: return 6; + default: return 0; + } +} + +/** drain prayer points at the correct OSRS rate. call once per game tick. + drain_effect: total drain from all active prayers (overhead + offensive). + callers compute this by summing encounter_prayer_drain_effect() for overhead + and any offensive prayer drain (piety=24, rigour=24, augury=24, low=6). + prayer_bonus: player's total prayer equipment bonus (typically 0-30). + drain_counter: persistent state, must be zero-initialized. uses the osrs-sdk + incrementing approach (PrayerController.ts:50-53). + deactivates overhead prayer when points reach 0. caller is responsible for + deactivating offensive prayers if applicable (PvP). */ +static inline void encounter_drain_prayer( + int* current_prayer, OverheadPrayer* active_prayer, + int prayer_bonus, int* drain_counter, int drain_effect +) { + if (*active_prayer == PRAYER_NONE || drain_effect <= 0) return; + + /* OSRS prayer drain: counter increments by drain_effect each tick. + when counter >= drain_resistance, a prayer point drains. + ref: osrs-sdk PrayerController.ts:50-53, RuneLite PrayerPlugin.java:387. */ + int drain_resistance = 60 + prayer_bonus * 2; + *drain_counter += drain_effect; + while (*drain_counter > drain_resistance) { + (*current_prayer)--; + *drain_counter -= drain_resistance; + if (*current_prayer <= 0) { + *current_prayer = 0; + *drain_counter = 0; + *active_prayer = PRAYER_NONE; + break; + } + } +} + +/* ======================================================================== */ +/* shared loadout stat computation */ +/* */ +/* ENCOUNTERS: do NOT manually compute attack bonuses, max hits, or */ +/* effective levels. call encounter_compute_loadout_stats() with a loadout */ +/* array and it derives everything from ITEM_DATABASE automatically. */ +/* */ +/* available structs/functions: */ +/* EncounterLoadoutStats — computed combat stats for one gear loadout */ +/* EncounterPrayer — prayer enum (NONE, AUGURY, RIGOUR, PIETY) */ +/* encounter_compute_loadout_stats() — derive stats from loadout + prayer */ +/* ======================================================================== */ + +/** combat stats derived from a gear loadout + prayer + style. + computed once at reset, read during combat. + prayer multipliers and style_bonus are stored for dynamic recomputation + when stats change (brew drain, potion boost). */ +typedef struct { + int attack_bonus; /* primary attack bonus for the style */ + int strength_bonus; /* ranged_strength, magic_damage %, or melee_strength */ + int eff_level; /* effective attack level (floor(base*prayer) + style + 8) */ + int max_hit; /* base max hit (before tbow/set bonuses) */ + int attack_speed; /* ticks between attacks */ + int attack_range; /* max chebyshev distance */ + AttackStyle style; + /* defence bonuses from gear */ + int def_stab, def_slash, def_crush, def_magic, def_ranged; + /* stored for dynamic max hit recomputation after brew drain / potion boost */ + float att_prayer_mult; + float str_prayer_mult; + int style_bonus; + int spell_base_damage; +} EncounterLoadoutStats; + +/** overhead prayer multipliers for effective level computation. */ +typedef enum { + ENCOUNTER_PRAYER_NONE = 0, + ENCOUNTER_PRAYER_AUGURY, /* +25% magic attack, +25% magic defence */ + ENCOUNTER_PRAYER_RIGOUR, /* +20% ranged attack, +23% ranged strength */ + ENCOUNTER_PRAYER_PIETY, /* +20% melee attack, +23% melee strength, +25% defence */ +} EncounterPrayer; + +/** derive all combat stats from a loadout array + prayer + style. + sums equipment bonuses from ITEM_DATABASE, applies prayer multiplier, + computes effective level and max hit. + + @param loadout gear array indexed by GEAR_SLOT_* (ITEM_NONE=255 for empty) + @param style ATTACK_STYLE_MAGIC, ATTACK_STYLE_RANGED, or ATTACK_STYLE_MELEE + @param prayer prayer enum for level multiplier + @param base_level base combat level (usually 99) + @param style_bonus +0 for rapid/autocast, +3 for accurate, +1 for controlled + @param spell_base_damage 0 for ranged/melee, 30 for ice/blood barrage + @param out output struct to fill */ +static inline void encounter_compute_loadout_stats( + const uint8_t loadout[NUM_GEAR_SLOTS], + AttackStyle style, + EncounterPrayer prayer, + int base_level, + int style_bonus, + int spell_base_damage, + EncounterLoadoutStats* out +) { + memset(out, 0, sizeof(*out)); + out->style = style; + + /* sum equipment bonuses 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; + out->attack_speed = eb.attack_speed; + out->attack_range = eb.attack_range; + + /* 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 */ + float att_prayer_mult = 1.0f; + float str_prayer_mult = 1.0f; + switch (prayer) { + case ENCOUNTER_PRAYER_AUGURY: + att_prayer_mult = 1.25f; + break; + case ENCOUNTER_PRAYER_RIGOUR: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_PIETY: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_NONE: + break; + } + + /* store for dynamic recomputation after brew drain / potion boost */ + out->att_prayer_mult = att_prayer_mult; + out->str_prayer_mult = str_prayer_mult; + out->style_bonus = style_bonus; + out->spell_base_damage = spell_base_damage; + + /* effective attack level: floor(base * prayer_mult) + style_bonus + 8. + magic uses +9 (OSRS invisible +1 boost) instead of +style_bonus+8. + ref: OSRS wiki "magic effective level = floor(base * prayer) + 9". */ + if (style == ATTACK_STYLE_MAGIC) { + out->eff_level = (int)(base_level * att_prayer_mult) + 9; + } else { + out->eff_level = (int)(base_level * att_prayer_mult) + style_bonus + 8; + } + + /* effective strength level (for max hit): floor(base * str_prayer_mult) + style_bonus + 8 + note: style_bonus for strength is typically 0 for rapid/autocast, +3 for aggressive */ + int eff_str_level = (int)(base_level * str_prayer_mult) + style_bonus + 8; + + /* augury magic damage multiplier: +4% (matches PvP calculate_max_hit). */ + float magic_dmg_prayer_mult = 1.0f; + if (prayer == ENCOUNTER_PRAYER_AUGURY) magic_dmg_prayer_mult = 1.04f; + + /* 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. call after brew drain, super restore, or bastion boost. + current_att_level: the player's current attack/ranged/magic level (for accuracy). + current_str_level: the player's current strength/ranged/magic level (for max hit). + for ranged: both are current_ranged. for melee: att=current_attack, str=current_strength. + for magic: max hit doesn't depend on level (spell base damage), but eff_level does. */ +static inline void encounter_update_loadout_level( + EncounterLoadoutStats* ls, int current_att_level, int current_str_level +) { + /* magic uses +9 invisible boost (matches encounter_compute_loadout_stats) */ + if (ls->style == ATTACK_STYLE_MAGIC) { + ls->eff_level = (int)(current_att_level * ls->att_prayer_mult) + 9; + /* augury +4% magic damage. att_prayer_mult == 1.25 iff augury. */ + float magic_dmg_mult = (ls->att_prayer_mult > 1.24f) ? 1.04f : 1.0f; + 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 * ls->att_prayer_mult) + ls->style_bonus + 8; + int eff_str = (int)(current_str_level * ls->str_prayer_mult) + ls->style_bonus + 8; + ls->max_hit = (int)(0.5 + eff_str * (ls->strength_bonus + 64) / 640.0); + } +} + +/* ======================================================================== */ +/* shared potion stat effects (brew drain, restore, bastion boost) */ +/* */ +/* ENCOUNTERS: call these when the player drinks a potion. they modify the */ +/* player's current combat levels and recompute max hit for affected loadouts.*/ +/* these implement the real OSRS formulas for stat modification. */ +/* */ +/* sara brew: heals HP, boosts def, drains att/str/ranged/magic */ +/* super restore: restores all drained stats toward base (caps at base) */ +/* bastion: boosts ranged above base, boosts def */ +/* ======================================================================== */ + +/** sara brew stat drain. call AFTER healing HP (which is encounter-specific). + drains att/str/ranged/magic by floor(current/10)+2 each (uses CURRENT level). + boosts defence by floor(current_def/5)+2, capped at base + max boost from base. + floors at 0 for drained stats. + ref: OSRS wiki Saradomin brew. */ +static inline void encounter_brew_drain_stats(Player* p) { + int att_drain = p->current_attack / 10 + 2; + int str_drain = p->current_strength / 10 + 2; + int rng_drain = p->current_ranged / 10 + 2; + int mag_drain = p->current_magic / 10 + 2; + int def_boost = p->current_defence / 5 + 2; + + p->current_attack -= att_drain; + if (p->current_attack < 0) p->current_attack = 0; + p->current_strength -= str_drain; + if (p->current_strength < 0) p->current_strength = 0; + p->current_ranged -= rng_drain; + if (p->current_ranged < 0) p->current_ranged = 0; + p->current_magic -= mag_drain; + if (p->current_magic < 0) p->current_magic = 0; + + p->current_defence += def_boost; + int def_cap = p->base_defence + (p->base_defence / 5 + 2); + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** super restore stat recovery. restores all combat stats toward base level. + each dose restores floor(base * 0.25) + 8 per stat. caps at base level. + ref: OSRS wiki Super restore. */ +static inline void encounter_restore_stats(Player* p) { + int restore = 8 + p->base_attack / 4; /* same formula for all stats at 99 base */ + p->current_attack += restore; + if (p->current_attack > p->base_attack) p->current_attack = p->base_attack; + restore = 8 + p->base_strength / 4; + p->current_strength += restore; + if (p->current_strength > p->base_strength) p->current_strength = p->base_strength; + restore = 8 + p->base_defence / 4; + p->current_defence += restore; + if (p->current_defence > p->base_defence) p->current_defence = p->base_defence; + restore = 8 + p->base_ranged / 4; + p->current_ranged += restore; + if (p->current_ranged > p->base_ranged) p->current_ranged = p->base_ranged; + restore = 8 + p->base_magic / 4; + p->current_magic += restore; + if (p->current_magic > p->base_magic) p->current_magic = p->base_magic; +} + +/** bastion potion boost. boosts ranged by floor(base * 0.10) + 4. can exceed base. + also boosts defence by floor(base * 0.15) + 5. can exceed base. + ref: OSRS wiki Bastion potion. */ +static inline void encounter_bastion_boost(Player* p) { + int rng_boost = 4 + p->base_ranged / 10; + int def_boost = 5 + p->base_defence * 15 / 100; + p->current_ranged += rng_boost; + int rng_cap = p->base_ranged + rng_boost; + if (p->current_ranged > rng_cap) p->current_ranged = rng_cap; + p->current_defence += def_boost; + int def_cap = p->base_defence + def_boost; + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** recompute max hit for all loadouts after a stat change. + encounters should call this after brew_drain_stats, restore_stats, or bastion_boost. + ranged loadouts use current_ranged, magic uses current_magic, melee uses + current_attack/current_strength. */ +static inline void encounter_recompute_loadout_max_hits( + EncounterLoadoutStats* loadouts, int num_loadouts, Player* p +) { + for (int i = 0; i < num_loadouts; i++) { + EncounterLoadoutStats* ls = &loadouts[i]; + if (ls->style == ATTACK_STYLE_RANGED) { + encounter_update_loadout_level(ls, p->current_ranged, p->current_ranged); + } else if (ls->style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(ls, p->current_magic, p->current_magic); + } else { + encounter_update_loadout_level(ls, p->current_attack, p->current_strength); + } + } +} + +/* ======================================================================== */ +/* shared special attack energy */ +/* */ +/* ENCOUNTERS: call encounter_tick_spec_regen() every game tick. call */ +/* encounter_use_spec() when the player activates a special attack. */ +/* OSRS: energy 0-100, starts at 100, regens +10 every 50 ticks (30s). */ +/* lightbearer halves regen interval to 25 ticks. */ +/* ======================================================================== */ + +#define SPEC_REGEN_INTERVAL 50 /* ticks between +10% regen (normal) */ +#define SPEC_REGEN_LIGHTBEARER 25 /* with lightbearer equipped */ +#define SPEC_REGEN_AMOUNT 10 /* energy restored per regen tick */ + +/** tick special attack energy regeneration. call once per game tick. + lightbearer: set to 1 if player has lightbearer ring equipped. */ +static inline void encounter_tick_spec_regen(Player* p, int has_lightbearer) { + if (p->special_energy >= 100) { + p->special_regen_ticks = 0; + return; + } + int interval = has_lightbearer ? SPEC_REGEN_LIGHTBEARER : SPEC_REGEN_INTERVAL; + p->special_regen_ticks++; + if (p->special_regen_ticks >= interval) { + p->special_energy += SPEC_REGEN_AMOUNT; + if (p->special_energy > 100) p->special_energy = 100; + p->special_regen_ticks = 0; + } +} + +/** attempt to use special attack energy. returns 1 if successful (enough energy), + 0 if not enough energy. drains on success. */ +static inline int encounter_use_spec(Player* p, int cost) { + if (p->special_energy < cost) return 0; + p->special_energy -= cost; + return 1; +} + +/* ======================================================================== */ +/* shared gear switching helpers for encounters */ +/* ======================================================================== */ + +/** apply a full static loadout to player equipment and set gear state. + used by Zulrah, Inferno, and future boss encounters with fixed loadouts. */ +static inline void encounter_apply_loadout( + Player* p, const uint8_t loadout[NUM_GEAR_SLOTS], GearSet gear_set +) { + memcpy(p->equipped, loadout, NUM_GEAR_SLOTS); + p->current_gear = gear_set; + p->visible_gear = gear_set; +} + +/** populate player inventory from multiple loadouts (deduped per slot). + extra_items is an optional overlay array (e.g. justiciar for tank), NULL to skip. + the GUI reads p->inventory[][] to display available gear switches. */ +static void encounter_populate_inventory( + Player* p, + const uint8_t* const* loadouts, int num_loadouts, + const uint8_t extra_items[NUM_GEAR_SLOTS] +) { + memset(p->inventory, 255 /* ITEM_NONE */, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + int n = 0; + for (int l = 0; l < num_loadouts && n < MAX_ITEMS_PER_SLOT; l++) { + uint8_t item = loadouts[l][s]; + if (item == 255 /* ITEM_NONE */) continue; + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == item) { dup = 1; break; } } + if (dup) continue; + p->inventory[s][n++] = item; + } + if (extra_items && extra_items[s] != 255 /* ITEM_NONE */ && n < MAX_ITEMS_PER_SLOT) { + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == extra_items[s]) { dup = 1; break; } } + if (!dup) p->inventory[s][n++] = extra_items[s]; + } + p->num_items_in_slot[s] = n; + } +} + +/* ======================================================================== */ +/* shared human input translate helpers */ +/* ======================================================================== */ + +/** translate movement: convert absolute tile to 8-directional walk action. + writes to actions[head_move]. head_move < 0 = skip. */ +static inline void encounter_translate_movement(HumanInput* hi, int* actions, + int head_move, + void* (*get_entity)(void*, int), + void* state) { + if (hi->pending_move_x < 0 || hi->pending_move_y < 0 || head_move < 0) return; + Player* player = (Player*)get_entity(state, 0); + if (!player) return; + int dx = hi->pending_move_x - player->x; + int dy = hi->pending_move_y - player->y; + int sx = (dx > 0) ? 1 : (dx < 0) ? -1 : 0; + int sy = (dy > 0) ? 1 : (dy < 0) ? -1 : 0; + static const int DX8[9] = { 0, 0, 1, 1, 1, 0, -1, -1, -1 }; + static const int DY8[9] = { 0, 1, 1, 0, -1, -1, -1, 0, 1 }; + for (int m = 1; m < 9; m++) { + if (DX8[m] == sx && DY8[m] == sy) { + actions[head_move] = m; + break; + } + } +} + +/** translate prayer: 0=no change, 1=off, 2=melee, 3=ranged, 4=magic. + writes to actions[head_prayer]. head_prayer < 0 = skip. */ +static inline void encounter_translate_prayer(HumanInput* hi, int* actions, int head_prayer) { + if (hi->pending_prayer < 0 || head_prayer < 0) return; + switch (hi->pending_prayer) { + case OVERHEAD_NONE: actions[head_prayer] = 1; break; + case OVERHEAD_MELEE: actions[head_prayer] = 2; break; + case OVERHEAD_RANGED: actions[head_prayer] = 3; break; + case OVERHEAD_MAGE: actions[head_prayer] = 4; break; + default: break; + } +} + +/** translate NPC target: 0=none, 1+=NPC index. + writes to actions[head_target]. head_target < 0 = skip. */ +static inline void encounter_translate_target(HumanInput* hi, int* actions, int head_target) { + if (hi->pending_target_idx < 0 || head_target < 0) return; + actions[head_target] = hi->pending_target_idx + 1; +} + +/* ======================================================================== */ +/* encounter definition (vtable) */ +/* ======================================================================== */ + +typedef struct { + const char* name; /* "nh_pvp", "cerberus", "jad", etc. */ + + /* observation/action space dimensions */ + int obs_size; /* raw observation features (before mask) */ + int num_action_heads; + const int* action_head_dims; /* array of per-head dimensions */ + int mask_size; /* sum of action_head_dims */ + + /* lifecycle: create/destroy encounter state */ + EncounterState* (*create)(void); + void (*destroy)(EncounterState* state); + + /* episode lifecycle */ + void (*reset)(EncounterState* state, uint32_t seed); + void (*step)(EncounterState* state, const int* actions); + + /* RL interface */ + void (*write_obs)(EncounterState* state, float* obs_out); + void (*write_mask)(EncounterState* state, float* mask_out); + float (*get_reward)(EncounterState* state); + int (*is_terminal)(EncounterState* state); + + /* entity access for renderer (returns entity count, writes entity pointers). + renderer uses this to draw all entities generically. */ + int (*get_entity_count)(EncounterState* state); + void* (*get_entity)(EncounterState* state, int index); /* returns Player* */ + + /* render entity population: fills array of RenderEntity structs for the renderer. + replaces get_entity casting for rendering. NULL = renderer falls back to get_entity. */ + void (*fill_render_entities)(EncounterState* state, RenderEntity* out, int max_entities, int* count); + + /* encounter-specific config (key-value put/get for binding kwargs) */ + void (*put_int)(EncounterState* state, const char* key, int value); + void (*put_float)(EncounterState* state, const char* key, float value); + void (*put_ptr)(EncounterState* state, const char* key, void* value); + + /* arena bounds for renderer (0 = use FIGHT_AREA_* defaults) */ + int arena_base_x, arena_base_y; + int arena_width, arena_height; + + /* human mode input translation (per-encounter, NULL = no human mode). + translates semantic HumanInput intents to encounter-specific action arrays. + each encounter owns its own mapping since action head layouts differ. */ + void (*translate_human_input)(struct HumanInput* hi, int* actions, EncounterState* state); + + /* action head indices used by shared translate helpers and renderer. + set to -1 if the encounter doesn't have that action head. */ + int head_move; /* movement (walk/run) */ + int head_prayer; /* prayer switching */ + int head_target; /* NPC target selection (index into NPC array) */ + + /* render hooks (optional — NULL if not implemented). + populates visual overlay data for the renderer. */ + void (*render_post_tick)(EncounterState* state, EncounterOverlay* overlay); + + /* logging (returns pointer to encounter's Log struct, or NULL) */ + void* (*get_log)(EncounterState* state); + + /* tick access */ + int (*get_tick)(EncounterState* state); + int (*get_winner)(EncounterState* state); +} EncounterDef; + +/* ======================================================================== */ +/* encounter registry */ +/* ======================================================================== */ + +#define MAX_ENCOUNTERS 32 + +typedef struct { + const EncounterDef* defs[MAX_ENCOUNTERS]; + int count; +} EncounterRegistry; + +/* 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/src/osrs/osrs_env.h b/src/osrs/osrs_env.h new file mode 100644 index 0000000000..266b237210 --- /dev/null +++ b/src/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/src/osrs/osrs_gui.h b/src/osrs/osrs_gui.h new file mode 100644 index 0000000000..9a2d6614ba --- /dev/null +++ b/src/osrs/osrs_gui.h @@ -0,0 +1,1983 @@ +/** + * @fileoverview OSRS-style GUI panel system for the debug viewer. + * + * Renders inventory, equipment, prayer, combat, and spellbook panels + * using real sprites exported from the OSRS cache (index 8). Tab bar + * at the TOP matches the real OSRS fixed-mode client (7 tabs). + * + * Sprite sources (exported by scripts/export_sprites_modern.py): + * - equipment slot backgrounds: sprite IDs 156-165, 170 + * - prayer icons (enabled/disabled): sprite IDs 115-154, 502-509, 945-951, 1420-1425 + * - tab icons: sprite IDs 168, 898, 899, 900, 901, 779, 780 + * - spell icons: sprite IDs 325-336, 375-386, 557, 561, 564, 607, 611, 614 + * - special attack bar: sprite ID 657 + * + * Layout constants derived from OSRS client widget definitions: + * - inventory: 4 columns x 7 rows, 36x32 item sprites + * - equipment: 11 slots in paperdoll layout (interface 387) + * - prayer: 5 columns x 6 rows grid (interface 541) + * - combat: 4 attack style buttons + special bar (interface 593) + * - spellbook: grid layout (interface 218) + */ + +#ifndef OSRS_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" + +/* ======================================================================== */ +/* OSRS color palette (from client widget rendering) */ +/* ======================================================================== */ + +#define GUI_BG_DARK CLITERAL(Color){ 62, 53, 41, 255 } +#define GUI_BG_MEDIUM CLITERAL(Color){ 75, 67, 54, 255 } +#define GUI_BG_SLOT CLITERAL(Color){ 56, 48, 38, 255 } +#define GUI_BG_SLOT_HL CLITERAL(Color){ 90, 80, 60, 255 } +#define GUI_BORDER CLITERAL(Color){ 42, 36, 28, 255 } +#define GUI_BORDER_LT CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TEXT_YELLOW CLITERAL(Color){ 255, 255, 0, 255 } +#define GUI_TEXT_ORANGE CLITERAL(Color){ 255, 152, 31, 255 } +#define GUI_TEXT_WHITE CLITERAL(Color){ 255, 255, 255, 255 } +#define GUI_TEXT_GREEN CLITERAL(Color){ 0, 255, 0, 255 } +#define GUI_TEXT_RED CLITERAL(Color){ 255, 0, 0, 255 } +#define GUI_TEXT_CYAN CLITERAL(Color){ 0, 255, 255, 255 } +#define GUI_TAB_ACTIVE CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TAB_INACTIVE CLITERAL(Color){ 50, 44, 35, 255 } +#define GUI_PRAYER_ON CLITERAL(Color){ 200, 200, 100, 80 } +#define GUI_SPEC_GREEN CLITERAL(Color){ 0, 180, 0, 255 } +#define GUI_SPEC_DARK CLITERAL(Color){ 30, 30, 20, 255 } +#define GUI_HP_GREEN CLITERAL(Color){ 0, 146, 0, 255 } +#define GUI_HP_RED CLITERAL(Color){ 160, 0, 0, 255 } + +/* OSRS text shadow: draw black at (+1,+1) then color on top */ +#define GUI_TEXT_SHADOW CLITERAL(Color){ 0, 0, 0, 255 } + +/* ======================================================================== */ +/* tab system — 7 tabs matching OSRS fixed-mode, drawn at TOP of panel */ +/* ======================================================================== */ + +typedef enum { + GUI_TAB_COMBAT = 0, + GUI_TAB_STATS = 1, /* empty (no content) */ + GUI_TAB_QUESTS = 2, /* empty (no content) */ + GUI_TAB_INVENTORY = 3, + GUI_TAB_EQUIPMENT = 4, + GUI_TAB_PRAYER = 5, + GUI_TAB_SPELLBOOK = 6, + GUI_TAB_COUNT = 7 +} GuiTab; + +/* ======================================================================== */ +/* equipment slot sprite indices (maps GEAR_SLOT_* to sprite array index) */ +/* ======================================================================== */ + +/* slot background sprite IDs from cache index 8: + head=156, cape=157, neck=158, weapon=159, ring=160, + body=161, shield=162, legs=163, hands=164, feet=165, tile=170 */ +#define GUI_NUM_SLOT_SPRITES 12 /* 11 slots + tile background */ + +/* ======================================================================== */ +/* prayer icon indices */ +/* ======================================================================== */ + +/* prayer icons relevant to PvP/PvE. indices into gui prayer sprite arrays. + ordered to match the real OSRS prayer book (5 cols, top-to-bottom). */ +typedef enum { + GUI_PRAY_THICK_SKIN = 0, /* sprite 115 / 135 */ + GUI_PRAY_BURST_STR, /* 116 / 136 */ + GUI_PRAY_CLARITY, /* 117 / 137 */ + GUI_PRAY_SHARP_EYE, /* 118 / 138 */ + GUI_PRAY_MYSTIC_WILL, /* 119 / 139 */ + GUI_PRAY_ROCK_SKIN, /* 120 / 140 */ + GUI_PRAY_SUPERHUMAN, /* 121 / 141 */ + GUI_PRAY_IMPROVED_REFLEX, /* 122 / 142 */ + GUI_PRAY_RAPID_RESTORE, /* 123 / 143 */ + GUI_PRAY_RAPID_HEAL, /* 124 / 144 */ + GUI_PRAY_PROTECT_ITEM, /* 125 / 145 */ + GUI_PRAY_HAWK_EYE, /* 126 / 146 — actually sprite 502/506 */ + GUI_PRAY_PROTECT_MAGIC, /* 127 / 147 */ + GUI_PRAY_PROTECT_MISSILES, /* 128 / 148 */ + GUI_PRAY_PROTECT_MELEE, /* 129 / 149 */ + GUI_PRAY_REDEMPTION, /* 130 / 150 */ + GUI_PRAY_RETRIBUTION, /* 131 / 151 */ + GUI_PRAY_SMITE, /* 132 / 152 */ + GUI_PRAY_CHIVALRY, /* 133 / 153 — actually sprite 945/949 */ + GUI_PRAY_PIETY, /* 134 / 154 — actually sprite 946/950 */ + /* additional prayers with non-contiguous sprite IDs */ + GUI_PRAY_EAGLE_EYE, /* sprite 504 / 508 */ + GUI_PRAY_MYSTIC_MIGHT, /* sprite 505 / 509 */ + GUI_PRAY_PRESERVE, /* sprite 947 / 951 */ + GUI_PRAY_RIGOUR, /* sprite 1420 / 1424 */ + GUI_PRAY_AUGURY, /* sprite 1421 / 1425 */ + GUI_NUM_PRAYERS +} GuiPrayerIdx; + +/* ======================================================================== */ +/* spell icon indices */ +/* ======================================================================== */ + +typedef enum { + GUI_SPELL_ICE_RUSH = 0, /* sprite 325 / 375 */ + GUI_SPELL_ICE_BURST, /* 326 / 376 */ + GUI_SPELL_ICE_BLITZ, /* 327 / 377 */ + GUI_SPELL_ICE_BARRAGE, /* 328 / 378 */ + GUI_SPELL_BLOOD_RUSH, /* 333 / 383 */ + GUI_SPELL_BLOOD_BURST, /* 334 / 384 */ + GUI_SPELL_BLOOD_BLITZ, /* 335 / 385 */ + GUI_SPELL_BLOOD_BARRAGE, /* 336 / 386 */ + GUI_SPELL_VENGEANCE, /* 564 */ + GUI_NUM_SPELLS +} GuiSpellIdx; + +/* ======================================================================== */ +/* inventory slot system — unified grid for equipment + consumables */ +/* ======================================================================== */ + +/* inventory slot types: either an equipment item (ITEM_DATABASE index) or a consumable. + consumables are tracked as counts in Player, not as individual ITEM_DATABASE entries, + so we use dedicated types with known OSRS item IDs for sprite lookup. */ +typedef enum { + INV_SLOT_EMPTY = 0, + INV_SLOT_EQUIPMENT, /* item_db_idx holds ITEM_DATABASE index */ + INV_SLOT_FOOD, /* shark (OSRS ID 385) */ + INV_SLOT_KARAMBWAN, /* cooked karambwan (OSRS ID 3144) */ + INV_SLOT_BREW, /* saradomin brew (OSRS IDs 6685/6687/6689/6691 for 4/3/2/1 dose) */ + INV_SLOT_RESTORE, /* super restore (OSRS IDs 3024/3026/3028/3030) */ + INV_SLOT_COMBAT_POT, /* super combat (OSRS IDs 12695/12697/12699/12701) */ + INV_SLOT_RANGED_POT, /* ranging potion (OSRS IDs 2444/169/171/173) */ + INV_SLOT_ANTIVENOM, /* anti-venom+ (OSRS IDs 12913/12915/12917/12919) */ + INV_SLOT_PRAYER_POT, /* prayer potion (OSRS IDs 2434/139/141/143 for 4/3/2/1 dose) */ +} InvSlotType; + +/* OSRS item IDs for consumable sprites (4-dose shown by default) */ +#define OSRS_ID_SHARK 385 +#define OSRS_ID_KARAMBWAN 3144 +#define OSRS_ID_BREW_4 6685 +#define OSRS_ID_BREW_3 6687 +#define OSRS_ID_BREW_2 6689 +#define OSRS_ID_BREW_1 6691 +#define OSRS_ID_RESTORE_4 3024 +#define OSRS_ID_RESTORE_3 3026 +#define OSRS_ID_RESTORE_2 3028 +#define OSRS_ID_RESTORE_1 3030 +#define OSRS_ID_COMBAT_4 12695 +#define OSRS_ID_COMBAT_3 12697 +#define OSRS_ID_COMBAT_2 12699 +#define OSRS_ID_COMBAT_1 12701 +#define OSRS_ID_RANGED_4 2444 +#define OSRS_ID_RANGED_3 169 +#define OSRS_ID_RANGED_2 171 +#define OSRS_ID_RANGED_1 173 +#define OSRS_ID_ANTIVENOM_4 12913 +#define OSRS_ID_ANTIVENOM_3 12915 +#define OSRS_ID_ANTIVENOM_2 12917 +#define OSRS_ID_ANTIVENOM_1 12919 +#define OSRS_ID_PRAYER_POT_4 2434 +#define OSRS_ID_PRAYER_POT_3 139 +#define OSRS_ID_PRAYER_POT_2 141 +#define OSRS_ID_PRAYER_POT_1 143 + +#define INV_GRID_SLOTS 28 /* 4 columns x 7 rows */ + +typedef struct { + InvSlotType type; + uint8_t item_db_idx; /* ITEM_DATABASE index (for INV_SLOT_EQUIPMENT) */ + int osrs_id; /* OSRS item ID (for sprite lookup, all types) */ +} InvSlot; + +/* click/drag interaction state */ +#define INV_DIM_TICKS 15 /* client ticks (50 Hz) to show dim after click */ +#define INV_DRAG_DEAD_ZONE 5 /* pixels before drag activates */ + +typedef enum { + INV_ACTION_NONE = 0, + INV_ACTION_EQUIP, + INV_ACTION_EAT, + INV_ACTION_DRINK, +} InvAction; + +/* ======================================================================== */ +/* gui state: textures + layout */ +/* ======================================================================== */ + +typedef struct { + GuiTab active_tab; + int panel_x, panel_y; + int panel_w, panel_h; + int tab_h; + int status_bar_h; /* compact HP/prayer/spec bar height */ + + /* multi-entity cycling (G key) */ + int gui_entity_idx; + int gui_entity_count; + + /* encounter state (for boss info display below panel) */ + void* encounter_state; + const void* encounter_def; + + /* textures loaded from exported cache sprites */ + int sprites_loaded; + + /* equipment slot background sprites (indexed by GEAR_SLOT_*) */ + Texture2D slot_sprites[GUI_NUM_SLOT_SPRITES]; + Texture2D slot_tile_bg; /* sprite 170: tile/background */ + + /* tab icons: 7 tabs (combat, stats, quests, inventory, equipment, prayer, spellbook) */ + Texture2D tab_icons[GUI_TAB_COUNT]; + + /* prayer icons: enabled and disabled variants */ + Texture2D prayer_on[GUI_NUM_PRAYERS]; + Texture2D prayer_off[GUI_NUM_PRAYERS]; + + /* spell icons: enabled and disabled variants */ + Texture2D spell_on[GUI_NUM_SPELLS]; + Texture2D spell_off[GUI_NUM_SPELLS]; + + /* special attack bar sprite */ + Texture2D spec_bar; + int spec_bar_loaded; + + /* interface chrome sprites */ + Texture2D side_panel_bg; /* 1031: stone background tile */ + Texture2D tabs_row_bottom; /* 1032: bottom tab row strip */ + Texture2D tabs_row_top; /* 1036: top tab row strip */ + Texture2D tab_stone_sel[5]; /* 1026-1030: selected tab corners + middle */ + Texture2D slanted_tab; /* 952: inactive tab button */ + Texture2D slanted_tab_hover; /* 953: hovered tab button */ + Texture2D slot_tile; /* 170: equipment slot background */ + Texture2D slot_selected; /* 179: equipment slot selected */ + Texture2D orb_frame; /* 1071: minimap orb frame */ + int chrome_loaded; + + /* skill icons for stats tab (25x25 from RuneLite skill_icons) */ + #define GUI_NUM_SKILL_ICONS 7 + Texture2D skill_icons[7]; /* attack, strength, defence, ranged, prayer, magic, hitpoints */ + int skill_icons_loaded; + + /* item sprites: keyed by OSRS item ID (from data/sprites/items/{id}.png) */ + #define GUI_MAX_ITEM_SPRITES 256 + int item_sprite_ids[GUI_MAX_ITEM_SPRITES]; /* OSRS item ID, 0 = empty */ + Texture2D item_sprite_tex[GUI_MAX_ITEM_SPRITES]; /* corresponding texture */ + int item_sprite_count; + + /* inventory grid: 28 slots (4x7). initialized once at reset, then updated + incrementally — items stay in their assigned slots (no compaction on eat). + positions are user-rearrangeable via drag-and-drop. */ + InvSlot inv_grid[INV_GRID_SLOTS]; + int inv_grid_dirty; /* 1 = needs full rebuild from player state */ + + /* previous player state for incremental inventory updates. + compared each tick to detect gear switches and consumable use. */ + uint8_t inv_prev_equipped[NUM_GEAR_SLOTS]; + int inv_prev_food_count; + int inv_prev_karambwan_count; + int inv_prev_brew_doses; + int inv_prev_restore_doses; + int inv_prev_prayer_pot_doses; + int inv_prev_combat_doses; + int inv_prev_ranged_doses; + int inv_prev_antivenom_doses; + + /* human-clicked inventory slot: when a human clicks a consumable, this records + the exact slot so gui_update_inventory removes from that slot instead of the + last one. -1 = no human click pending, use default last-slot removal. */ + int human_clicked_inv_slot; + + /* click dim animation: slot index and countdown (50 Hz client ticks) */ + int inv_dim_slot; /* -1 = none */ + int inv_dim_timer; /* counts down from INV_DIM_TICKS */ + + /* drag state */ + int inv_drag_active; /* 1 = currently dragging */ + int inv_drag_src_slot; /* slot being dragged */ + int inv_drag_start_x; /* mouse position at drag start */ + int inv_drag_start_y; + int inv_drag_mouse_x; /* current mouse position during drag */ + int inv_drag_mouse_y; +} GuiState; + +/* ======================================================================== */ +/* sprite loading (called after InitWindow in render_make_client) */ +/* ======================================================================== */ + +/** Try loading a texture, returns 1 on success. */ +static int gui_try_load(Texture2D* tex, const char* path) { + if (FileExists(path)) { + *tex = LoadTexture(path); + return 1; + } + return 0; +} + +/** Load all GUI sprites from data/sprites/gui/. */ +static void gui_load_sprites(GuiState* gs) { + gs->sprites_loaded = 1; + int ok = 1; + + /* equipment slot backgrounds: sprite IDs mapped to GEAR_SLOT_* order. + GEAR_SLOT: HEAD=0, CAPE=1, NECK=2, AMMO=3, WEAPON=4, SHIELD=5, + BODY=6, LEGS=7, HANDS=8, FEET=9, RING=10 */ + static const char* slot_files[] = { + "data/sprites/gui/slot_head.png", /* GEAR_SLOT_HEAD */ + "data/sprites/gui/slot_cape.png", /* GEAR_SLOT_CAPE */ + "data/sprites/gui/slot_neck.png", /* GEAR_SLOT_NECK */ + "data/sprites/gui/slot_tile.png", /* GEAR_SLOT_AMMO (use tile bg) */ + "data/sprites/gui/slot_weapon.png", /* GEAR_SLOT_WEAPON */ + "data/sprites/gui/slot_shield.png", /* GEAR_SLOT_SHIELD */ + "data/sprites/gui/slot_body.png", /* GEAR_SLOT_BODY */ + "data/sprites/gui/slot_legs.png", /* GEAR_SLOT_LEGS */ + "data/sprites/gui/slot_hands.png", /* GEAR_SLOT_HANDS */ + "data/sprites/gui/slot_feet.png", /* GEAR_SLOT_FEET */ + "data/sprites/gui/slot_ring.png", /* GEAR_SLOT_RING */ + "data/sprites/gui/slot_tile.png", /* spare tile bg */ + }; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) { + ok &= gui_try_load(&gs->slot_sprites[i], slot_files[i]); + } + gui_try_load(&gs->slot_tile_bg, "data/sprites/gui/slot_tile.png"); + + /* tab icons: mapped to GuiTab enum order (7 tabs) */ + static const char* tab_files[] = { + "data/sprites/gui/tab_combat.png", /* GUI_TAB_COMBAT */ + "data/sprites/gui/tab_stats.png", /* GUI_TAB_STATS */ + "data/sprites/gui/tab_quests.png", /* GUI_TAB_QUESTS */ + "data/sprites/gui/tab_inventory.png", /* GUI_TAB_INVENTORY */ + "data/sprites/gui/tab_equipment.png", /* GUI_TAB_EQUIPMENT */ + "data/sprites/gui/tab_prayer.png", /* GUI_TAB_PRAYER */ + "data/sprites/gui/tab_magic.png", /* GUI_TAB_SPELLBOOK */ + }; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + ok &= gui_try_load(&gs->tab_icons[i], tab_files[i]); + } + + /* skill icons for stats tab (OSRS skill_icons from RuneLite resources) */ + static const char* skill_icon_files[] = { + "data/sprites/gui/skill_attack.png", + "data/sprites/gui/skill_strength.png", + "data/sprites/gui/skill_defence.png", + "data/sprites/gui/skill_ranged.png", + "data/sprites/gui/skill_prayer.png", + "data/sprites/gui/skill_magic.png", + "data/sprites/gui/skill_hitpoints.png", + }; + gs->skill_icons_loaded = 1; + for (int i = 0; i < 7; i++) { + gs->skill_icons_loaded &= gui_try_load(&gs->skill_icons[i], skill_icon_files[i]); + } + + /* prayer icons: enabled (base sprite) and disabled (+20 for base range). + base prayers 115-134 (enabled), 135-154 (disabled). + then non-contiguous: 502-509, 945-951, 1420-1425. */ + static const int pray_on_ids[] = { + 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, + 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, + 504, 505, 947, 1420, 1421, + }; + static const int pray_off_ids[] = { + 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, + 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, + 508, 509, 951, 1424, 1425, + }; + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", pray_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", pray_off_ids[i]); + gui_try_load(&gs->prayer_on[i], on_path); + gui_try_load(&gs->prayer_off[i], off_path); + } + + /* spell icons */ + static const int spell_on_ids[] = { + 325, 326, 327, 328, 333, 334, 335, 336, 564, + }; + static const int spell_off_ids[] = { + 375, 376, 377, 378, 383, 384, 385, 386, 614, + }; + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", spell_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", spell_off_ids[i]); + gui_try_load(&gs->spell_on[i], on_path); + gui_try_load(&gs->spell_off[i], off_path); + } + + /* special attack bar */ + gs->spec_bar_loaded = gui_try_load(&gs->spec_bar, "data/sprites/gui/special_attack.png"); + + /* interface chrome */ + gs->chrome_loaded = 1; + gs->chrome_loaded &= gui_try_load(&gs->side_panel_bg, "data/sprites/gui/side_panel_bg.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_bottom, "data/sprites/gui/tabs_row_bottom.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_top, "data/sprites/gui/tabs_row_top.png"); + gui_try_load(&gs->slanted_tab, "data/sprites/gui/slanted_tab.png"); + gui_try_load(&gs->slanted_tab_hover, "data/sprites/gui/slanted_tab_hover.png"); + gui_try_load(&gs->slot_tile, "data/sprites/gui/slot_tile.png"); + gui_try_load(&gs->slot_selected, "data/sprites/gui/slot_selected.png"); + gui_try_load(&gs->orb_frame, "data/sprites/gui/orb_frame.png"); + + static const char* tab_sel_files[] = { + "data/sprites/gui/tab_stone_tl_sel.png", + "data/sprites/gui/tab_stone_tr_sel.png", + "data/sprites/gui/tab_stone_bl_sel.png", + "data/sprites/gui/tab_stone_br_sel.png", + "data/sprites/gui/tab_stone_mid_sel.png", + }; + for (int i = 0; i < 5; i++) gui_try_load(&gs->tab_stone_sel[i], tab_sel_files[i]); + + if (!ok) { + TraceLog(LOG_WARNING, "GUI: some sprites missing from data/sprites/gui/"); + } + + /* load item sprites from data/sprites/items/{item_id}.png */ + gs->item_sprite_count = 0; + for (int i = 0; i < NUM_ITEMS && gs->item_sprite_count < GUI_MAX_ITEM_SPRITES; i++) { + int item_id = ITEM_DATABASE[i].item_id; + if (item_id <= 0) continue; + const char* path = TextFormat("data/sprites/items/%d.png", item_id); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = item_id; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + + /* consumable sprites: not in ITEM_DATABASE, load by OSRS item ID directly */ + static const int consumable_ids[] = { + OSRS_ID_SHARK, OSRS_ID_KARAMBWAN, + OSRS_ID_BREW_4, OSRS_ID_BREW_3, OSRS_ID_BREW_2, OSRS_ID_BREW_1, + OSRS_ID_RESTORE_4, OSRS_ID_RESTORE_3, OSRS_ID_RESTORE_2, OSRS_ID_RESTORE_1, + OSRS_ID_COMBAT_4, OSRS_ID_COMBAT_3, OSRS_ID_COMBAT_2, OSRS_ID_COMBAT_1, + OSRS_ID_RANGED_4, OSRS_ID_RANGED_3, OSRS_ID_RANGED_2, OSRS_ID_RANGED_1, + OSRS_ID_ANTIVENOM_4, OSRS_ID_ANTIVENOM_3, OSRS_ID_ANTIVENOM_2, OSRS_ID_ANTIVENOM_1, + OSRS_ID_PRAYER_POT_4, OSRS_ID_PRAYER_POT_3, OSRS_ID_PRAYER_POT_2, OSRS_ID_PRAYER_POT_1, + }; + for (int i = 0; i < (int)(sizeof(consumable_ids)/sizeof(consumable_ids[0])); i++) { + if (gs->item_sprite_count >= GUI_MAX_ITEM_SPRITES) break; + int cid = consumable_ids[i]; + const char* path = TextFormat("data/sprites/items/%d.png", cid); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = cid; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + TraceLog(LOG_INFO, "GUI: loaded %d item sprites (incl consumables)", gs->item_sprite_count); +} + +/** Look up item sprite texture by item database index. Returns NULL texture (id=0) if not found. */ +static Texture2D gui_get_item_sprite(GuiState* gs, uint8_t item_idx) { + Texture2D empty = { 0 }; + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return empty; + int item_id = ITEM_DATABASE[item_idx].item_id; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == item_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Look up item sprite texture by OSRS item ID directly (for consumables). */ +static Texture2D gui_get_sprite_by_osrs_id(GuiState* gs, int osrs_id) { + Texture2D empty = { 0 }; + if (osrs_id <= 0) return empty; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == osrs_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Unload all GUI textures. */ +static void gui_unload_sprites(GuiState* gs) { + if (!gs->sprites_loaded) return; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) UnloadTexture(gs->slot_sprites[i]); + UnloadTexture(gs->slot_tile_bg); + for (int i = 0; i < GUI_TAB_COUNT; i++) UnloadTexture(gs->tab_icons[i]); + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + UnloadTexture(gs->prayer_on[i]); + UnloadTexture(gs->prayer_off[i]); + } + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + UnloadTexture(gs->spell_on[i]); + UnloadTexture(gs->spell_off[i]); + } + if (gs->spec_bar_loaded) UnloadTexture(gs->spec_bar); + if (gs->chrome_loaded) { + UnloadTexture(gs->side_panel_bg); + UnloadTexture(gs->tabs_row_bottom); + UnloadTexture(gs->tabs_row_top); + for (int i = 0; i < 5; i++) UnloadTexture(gs->tab_stone_sel[i]); + } + if (gs->slanted_tab.id) UnloadTexture(gs->slanted_tab); + if (gs->slanted_tab_hover.id) UnloadTexture(gs->slanted_tab_hover); + if (gs->slot_tile.id) UnloadTexture(gs->slot_tile); + if (gs->slot_selected.id) UnloadTexture(gs->slot_selected); + if (gs->orb_frame.id) UnloadTexture(gs->orb_frame); + for (int i = 0; i < gs->item_sprite_count; i++) UnloadTexture(gs->item_sprite_tex[i]); + gs->item_sprite_count = 0; + gs->sprites_loaded = 0; +} + +/* ======================================================================== */ +/* short item names for slot display */ +/* ======================================================================== */ + +static const char* gui_item_short_name(uint8_t item_idx) { + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return ""; + const char* full = ITEM_DATABASE[item_idx].name; + switch (item_idx) { + case ITEM_HELM_NEITIZNOT: return "Neit helm"; + case ITEM_GOD_CAPE: return "God cape"; + case ITEM_GLORY: return "Glory"; + case ITEM_BLACK_DHIDE_BODY: return "Dhide body"; + case ITEM_MYSTIC_TOP: return "Mystic top"; + case ITEM_RUNE_PLATELEGS: return "Rune legs"; + case ITEM_MYSTIC_BOTTOM: return "Mystic bot"; + case ITEM_WHIP: return "Whip"; + case ITEM_RUNE_CROSSBOW: return "Rune cbow"; + case ITEM_AHRIM_STAFF: return "Ahrim stf"; + case ITEM_DRAGON_DAGGER: return "DDS"; + case ITEM_DRAGON_DEFENDER: return "D defender"; + case ITEM_SPIRIT_SHIELD: return "Spirit sh"; + case ITEM_BARROWS_GLOVES: return "B gloves"; + case ITEM_CLIMBING_BOOTS: return "Climb boot"; + case ITEM_BERSERKER_RING: return "B ring"; + case ITEM_DIAMOND_BOLTS_E: return "D bolts(e)"; + case ITEM_GHRAZI_RAPIER: return "Rapier"; + case ITEM_INQUISITORS_MACE: return "Inq mace"; + case ITEM_STAFF_OF_DEAD: return "SOTD"; + case ITEM_KODAI_WAND: return "Kodai"; + case ITEM_VOLATILE_STAFF: return "Volatile"; + case ITEM_ZURIELS_STAFF: return "Zuriel stf"; + case ITEM_ARMADYL_CROSSBOW: return "ACB"; + case ITEM_ZARYTE_CROSSBOW: return "ZCB"; + case ITEM_DRAGON_CLAWS: return "D claws"; + case ITEM_AGS: return "AGS"; + case ITEM_ANCIENT_GS: return "Anc GS"; + case ITEM_GRANITE_MAUL: return "G maul"; + case ITEM_ELDER_MAUL: return "Elder maul"; + case ITEM_DARK_BOW: return "Dark bow"; + case ITEM_HEAVY_BALLISTA: return "Ballista"; + case ITEM_VESTAS: return "Vesta's"; + case ITEM_VOIDWAKER: return "Voidwaker"; + case ITEM_STATIUS_WARHAMMER: return "SWH"; + case ITEM_MORRIGANS_JAVELIN: return "Morr jav"; + case ITEM_ANCESTRAL_HAT: return "Anc hat"; + case ITEM_ANCESTRAL_TOP: return "Anc top"; + case ITEM_ANCESTRAL_BOTTOM: return "Anc bot"; + case ITEM_AHRIMS_ROBETOP: return "Ahrim top"; + case ITEM_AHRIMS_ROBESKIRT: return "Ahrim skrt"; + case ITEM_KARILS_TOP: return "Karil top"; + case ITEM_BANDOS_TASSETS: return "Tassets"; + case ITEM_BLESSED_SPIRIT_SHIELD: return "BSS"; + case ITEM_FURY: return "Fury"; + case ITEM_OCCULT_NECKLACE: return "Occult"; + case ITEM_INFERNAL_CAPE: return "Infernal"; + case ITEM_ETERNAL_BOOTS: return "Eternal"; + case ITEM_SEERS_RING_I: return "Seers (i)"; + case ITEM_LIGHTBEARER: return "Lightbear"; + case ITEM_MAGES_BOOK: return "Mage book"; + case ITEM_DRAGON_ARROWS: return "D arrows"; + case ITEM_TORAGS_PLATELEGS: return "Torag legs"; + case ITEM_DHAROKS_PLATELEGS: return "DH legs"; + case ITEM_VERACS_PLATESKIRT: return "Verac skrt"; + case ITEM_TORAGS_HELM: return "Torag helm"; + case ITEM_DHAROKS_HELM: return "DH helm"; + case ITEM_VERACS_HELM: return "Verac helm"; + case ITEM_GUTHANS_HELM: return "Guth helm"; + case ITEM_OPAL_DRAGON_BOLTS: return "Opal bolt"; + case ITEM_IMBUED_SARA_CAPE: return "Sara cape"; + case ITEM_EYE_OF_AYAK: return "Eye Ayak"; + case ITEM_ELIDINIS_WARD_F: return "Eld ward"; + case ITEM_CONFLICTION_GAUNTLETS: return "Confl gnt"; + case ITEM_AVERNIC_TREADS: return "Avernic bt"; + case ITEM_RING_OF_SUFFERING_RI: return "Suff (ri)"; + case ITEM_TWISTED_BOW: return "T bow"; + case ITEM_MASORI_MASK_F: return "Masori msk"; + case ITEM_MASORI_BODY_F: return "Masori bod"; + case ITEM_MASORI_CHAPS_F: return "Masori chp"; + case ITEM_NECKLACE_OF_ANGUISH: return "Anguish"; + case ITEM_DIZANAS_QUIVER: return "Dizana qvr"; + case ITEM_ZARYTE_VAMBRACES: return "Zaryte vam"; + case ITEM_TOXIC_BLOWPIPE: return "Blowpipe"; + case ITEM_AHRIMS_HOOD: return "Ahrim hood"; + case ITEM_TORMENTED_BRACELET: return "Tormented"; + case ITEM_SANGUINESTI_STAFF: return "Sang staff"; + case ITEM_INFINITY_BOOTS: return "Inf boots"; + case ITEM_GOD_BLESSING: return "Blessing"; + case ITEM_RING_OF_RECOIL: return "Recoil"; + case ITEM_CRYSTAL_HELM: return "Crystal hm"; + case ITEM_AVAS_ASSEMBLER: return "Assembler"; + case ITEM_CRYSTAL_BODY: return "Crystal bd"; + case ITEM_CRYSTAL_LEGS: return "Crystal lg"; + case ITEM_BOW_OF_FAERDHINEN: return "Fbow"; + case ITEM_BLESSED_DHIDE_BOOTS: return "Bless boot"; + case ITEM_MYSTIC_HAT: return "Mystic hat"; + case ITEM_TRIDENT_OF_SWAMP: return "Trident"; + case ITEM_BOOK_OF_DARKNESS: return "Book dark"; + case ITEM_AMETHYST_ARROW: return "Ameth arw"; + case ITEM_MYSTIC_BOOTS: return "Myst boots"; + case ITEM_BLESSED_COIF: return "Bless coif"; + case ITEM_BLACK_DHIDE_CHAPS: return "Dhide chap"; + case ITEM_MAGIC_SHORTBOW_I: return "MSB (i)"; + case ITEM_AVAS_ACCUMULATOR: return "Accumulate"; + default: return full; + } +} + +/* ======================================================================== */ +/* drawing helpers */ +/* ======================================================================== */ + +/** Draw text with OSRS-style shadow (black at +1,+1, then color). */ +static void gui_text_shadow(const char* text, int x, int y, int size, Color color) { + DrawText(text, x + 1, y + 1, size, GUI_TEXT_SHADOW); + DrawText(text, x, y, size, color); +} + +/** Draw an OSRS-style beveled slot rectangle. */ +static void gui_draw_slot(int x, int y, int w, int h, Color fill) { + DrawRectangle(x, y, w, h, fill); + DrawRectangleLines(x, y, w, h, GUI_BORDER); + DrawLine(x + 1, y + 1, x + w - 2, y + 1, GUI_BORDER_LT); + DrawLine(x + 1, y + 1, x + 1, y + h - 2, GUI_BORDER_LT); +} + +/** Draw texture centered within a box, scaled to fit. */ +static void gui_draw_tex_centered(Texture2D tex, int bx, int by, int bw, int bh) { + if (tex.id == 0) return; + /* scale to fit while maintaining aspect ratio */ + float sx = (float)(bw - 4) / (float)tex.width; + float sy = (float)(bh - 4) / (float)tex.height; + float s = (sx < sy) ? sx : sy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = bx + (bw - dw) / 2; + int dy = by + (bh - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, WHITE); +} + +/** Draw an equipment slot using real OSRS slot tile sprite + item/silhouette sprite. */ +static void gui_draw_equip_slot(GuiState* gs, int x, int y, int w, int h, + int gear_slot, uint8_t item_idx) { + /* draw slot_tile (real OSRS 36x36 stone square) as background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)x, (float)y, (float)w, (float)h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + gui_draw_slot(x, y, w, h, GUI_BG_SLOT); + } + + /* draw item sprite if equipped, else slot silhouette */ + if (item_idx != ITEM_NONE && item_idx < NUM_ITEMS) { + Texture2D item_tex = gui_get_item_sprite(gs, item_idx); + if (item_tex.id != 0) { + gui_draw_tex_centered(item_tex, x, y, w, h); + } else { + const char* name = gui_item_short_name(item_idx); + gui_text_shadow(name, x + 2, y + h / 2 - 4, 7, GUI_TEXT_YELLOW); + } + } else if (gs->sprites_loaded && gear_slot >= 0 && gear_slot < GUI_NUM_SLOT_SPRITES) { + Texture2D bg = gs->slot_sprites[gear_slot]; + if (bg.id != 0) { + gui_draw_tex_centered(bg, x, y, w, h); + } + } +} + +/* ======================================================================== */ +/* status bar — compact HP/prayer/spec display above tab row */ +/* ======================================================================== */ + +static void gui_draw_status_bar(GuiState* gs, Player* p) { + int sx = gs->panel_x + 6; + int sy = gs->panel_y; + int bar_w = gs->panel_w - 12; + int bar_h = 12; + int gap = 2; + + /* HP bar */ + float hp_pct = (p->base_hitpoints > 0) ? + (float)p->current_hitpoints / (float)p->base_hitpoints : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_HP_RED); + DrawRectangle(sx, sy, (int)(bar_w * hp_pct), bar_h, GUI_HP_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("HP %d/%d", p->current_hitpoints, p->base_hitpoints), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* prayer bar */ + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Pray %d/%d", p->current_prayer, p->base_prayer), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* spec bar */ + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * spec_pct), bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec %d%%", p->special_energy), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* tab bar — 7 tabs at TOP of panel (real OSRS fixed-mode layout) */ +/* ======================================================================== */ + +static void gui_draw_tab_bar(GuiState* gs) { + /* tabs drawn at top: right after the status bar */ + int ty = gs->panel_y + gs->status_bar_h; + + /* draw the tab row background strip (real OSRS asset) */ + if (gs->chrome_loaded && gs->tabs_row_top.id != 0) { + Rectangle src = { 0, 0, (float)gs->tabs_row_top.width, (float)gs->tabs_row_top.height }; + Rectangle dst = { (float)gs->panel_x, (float)ty, (float)gs->panel_w, (float)gs->tab_h }; + DrawTexturePro(gs->tabs_row_top, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(gs->panel_x, ty, gs->panel_w, gs->tab_h, GUI_TAB_INACTIVE); + } + + /* draw individual tabs using slanted tab sprites */ + int tab_w = gs->panel_w / GUI_TAB_COUNT; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + int tx = gs->panel_x + i * tab_w; + int is_active = (i == (int)gs->active_tab); + + /* active tab: draw selected tab stone pieces */ + if (is_active && gs->tab_stone_sel[4].id != 0) { + /* use middle selected piece stretched to fill tab area */ + Rectangle src = { 0, 0, (float)gs->tab_stone_sel[4].width, (float)gs->tab_stone_sel[4].height }; + Rectangle dst = { (float)tx, (float)ty, (float)tab_w, (float)gs->tab_h }; + DrawTexturePro(gs->tab_stone_sel[4], src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* draw tab icon sprite centered in tab */ + if (gs->sprites_loaded && gs->tab_icons[i].id != 0) { + Color tint = is_active ? WHITE : CLITERAL(Color){ 160, 160, 160, 255 }; + Texture2D tex = gs->tab_icons[i]; + float ssx = (float)(tab_w - 8) / (float)tex.width; + float ssy = (float)(gs->tab_h - 6) / (float)tex.height; + float s = (ssx < ssy) ? ssx : ssy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = tx + (tab_w - dw) / 2; + int dy = ty + (gs->tab_h - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, tint); + } + } +} + +static int gui_handle_tab_click(GuiState* gs, int mouse_x, int mouse_y) { + /* tabs are at top, right after status bar */ + int ty = gs->panel_y + gs->status_bar_h; + if (mouse_y < ty || mouse_y > ty + gs->tab_h) return 0; + if (mouse_x < gs->panel_x || mouse_x >= gs->panel_x + gs->panel_w) return 0; + + int tab_w = gs->panel_w / GUI_TAB_COUNT; + int idx = (mouse_x - gs->panel_x) / tab_w; + if (idx >= 0 && idx < GUI_TAB_COUNT) { + gs->active_tab = (GuiTab)idx; + return 1; + } + return 0; +} + +/* ======================================================================== */ +/* content area Y: below status bar + tab row */ +/* ======================================================================== */ + +static int gui_content_y(GuiState* gs) { + return gs->panel_y + gs->status_bar_h + gs->tab_h; +} + +/* ======================================================================== */ +/* inventory panel (interface 149) — 4x7 grid with equipment + consumables */ +/* ======================================================================== */ + +/* inventory grid dimensions — scaled to fill the 320px panel width. + OSRS native: 42x36 cell pitch, 36x32 sprites. we scale ~1.81x so + the 4-column grid (304px) fills the panel with 8px padding each side. */ +#define INV_COLS 4 +#define INV_ROWS 7 +#define INV_CELL_W 76 +#define INV_CELL_H 65 +#define INV_SPRITE_W 65 /* 36 * 76/42 */ +#define INV_SPRITE_H 57 /* 32 * 76/42 */ + +/** Get the OSRS item ID for a consumable based on remaining doses/count. */ +static int gui_consumable_osrs_id(InvSlotType type, int doses) { + switch (type) { + case INV_SLOT_FOOD: return OSRS_ID_SHARK; + case INV_SLOT_KARAMBWAN: return OSRS_ID_KARAMBWAN; + case INV_SLOT_BREW: + if (doses >= 4) return OSRS_ID_BREW_4; + if (doses == 3) return OSRS_ID_BREW_3; + if (doses == 2) return OSRS_ID_BREW_2; + return OSRS_ID_BREW_1; + case INV_SLOT_RESTORE: + if (doses >= 4) return OSRS_ID_RESTORE_4; + if (doses == 3) return OSRS_ID_RESTORE_3; + if (doses == 2) return OSRS_ID_RESTORE_2; + return OSRS_ID_RESTORE_1; + case INV_SLOT_COMBAT_POT: + if (doses >= 4) return OSRS_ID_COMBAT_4; + if (doses == 3) return OSRS_ID_COMBAT_3; + if (doses == 2) return OSRS_ID_COMBAT_2; + return OSRS_ID_COMBAT_1; + case INV_SLOT_RANGED_POT: + if (doses >= 4) return OSRS_ID_RANGED_4; + if (doses == 3) return OSRS_ID_RANGED_3; + if (doses == 2) return OSRS_ID_RANGED_2; + return OSRS_ID_RANGED_1; + case INV_SLOT_ANTIVENOM: + if (doses >= 4) return OSRS_ID_ANTIVENOM_4; + if (doses == 3) return OSRS_ID_ANTIVENOM_3; + if (doses == 2) return OSRS_ID_ANTIVENOM_2; + return OSRS_ID_ANTIVENOM_1; + case INV_SLOT_PRAYER_POT: + if (doses >= 4) return OSRS_ID_PRAYER_POT_4; + if (doses == 3) return OSRS_ID_PRAYER_POT_3; + if (doses == 2) return OSRS_ID_PRAYER_POT_2; + return OSRS_ID_PRAYER_POT_1; + default: return 0; + } +} + +/** Find first empty slot in inventory grid (scanning left→right, top→bottom). + Returns -1 if inventory is full. */ +static int gui_inv_first_empty(GuiState* gs) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EMPTY) return i; + } + return -1; +} + +/** Find the slot index of an equipment item in the inventory grid. + Returns -1 if not found. */ +static int gui_inv_find_equipment(GuiState* gs, uint8_t item_db_idx) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[i].item_db_idx == item_db_idx) return i; + } + return -1; +} + +/** Remove the last occurrence of a consumable type from the inventory grid. + In OSRS, eating removes from the slot the item is in — we remove from the + last slot of that type (bottom-right first) since that's where the cursor + typically is when spam-eating. Returns 1 if removed, 0 if not found. */ +static int gui_inv_remove_last_consumable(GuiState* gs, InvSlotType type) { + for (int i = INV_GRID_SLOTS - 1; i >= 0; i--) { + if (gs->inv_grid[i].type == type) { + gs->inv_grid[i].type = INV_SLOT_EMPTY; + gs->inv_grid[i].item_db_idx = 0; + gs->inv_grid[i].osrs_id = 0; + return 1; + } + } + return 0; +} + +/** Place an equipment item into the inventory grid at the first empty slot. + Returns the slot index, or -1 if full. */ +static int gui_inv_place_equipment(GuiState* gs, uint8_t item_db_idx) { + int slot = gui_inv_first_empty(gs); + if (slot < 0) return -1; + gs->inv_grid[slot].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[slot].item_db_idx = item_db_idx; + gs->inv_grid[slot].osrs_id = ITEM_DATABASE[item_db_idx].item_id; + return slot; +} + +/** Full inventory grid build from player state. Called once at reset. + Equipment items go first (unequipped gear), then consumables. + After this, use gui_update_inventory() for incremental changes. */ +static void gui_populate_inventory(GuiState* gs, Player* p) { + memset(gs->inv_grid, 0, sizeof(gs->inv_grid)); + int n = 0; + + /* unequipped gear items from the slot inventory */ + for (int s = 0; s < NUM_GEAR_SLOTS && n < INV_GRID_SLOTS; s++) { + for (int i = 0; i < p->num_items_in_slot[s] && n < INV_GRID_SLOTS; i++) { + uint8_t item = p->inventory[s][i]; + if (item == ITEM_NONE) continue; + /* skip if currently equipped */ + int is_equipped = 0; + for (int e = 0; e < NUM_GEAR_SLOTS; e++) { + if (p->equipped[e] == item) { is_equipped = 1; break; } + } + if (is_equipped) continue; + /* skip duplicates */ + int dup = 0; + for (int j = 0; j < n; j++) { + if (gs->inv_grid[j].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[j].item_db_idx == item) { dup = 1; break; } + } + if (dup) continue; + gs->inv_grid[n].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[n].item_db_idx = item; + gs->inv_grid[n].osrs_id = ITEM_DATABASE[item].item_id; + n++; + } + } + + /* consumables: food/potions are NOT stackable in OSRS. + each shark = 1 slot. each potion vial = 1 slot (with dose-specific sprite). + total doses are split into individual vials: e.g. 7 brew doses = 1x3-dose + 1x4-dose. */ + + /* food: each unit = 1 slot */ + for (int i = 0; i < p->food_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_FOOD; + gs->inv_grid[n].osrs_id = OSRS_ID_SHARK; + n++; + } + for (int i = 0; i < p->karambwan_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_KARAMBWAN; + gs->inv_grid[n].osrs_id = OSRS_ID_KARAMBWAN; + n++; + } + + /* potions: split doses into individual vials (4-dose first, remainder last) */ + #define ADD_POTION_VIALS(doses_total, slot_type) do { \ + int _rem = (doses_total); \ + while (_rem > 0 && n < INV_GRID_SLOTS) { \ + int _d = (_rem >= 4) ? 4 : _rem; \ + gs->inv_grid[n].type = (slot_type); \ + gs->inv_grid[n].osrs_id = gui_consumable_osrs_id((slot_type), _d); \ + _rem -= _d; \ + n++; \ + } \ + } while(0) + + ADD_POTION_VIALS(p->brew_doses, INV_SLOT_BREW); + ADD_POTION_VIALS(p->restore_doses, INV_SLOT_RESTORE); + ADD_POTION_VIALS(p->combat_potion_doses, INV_SLOT_COMBAT_POT); + ADD_POTION_VIALS(p->ranged_potion_doses, INV_SLOT_RANGED_POT); + ADD_POTION_VIALS(p->antivenom_doses, INV_SLOT_ANTIVENOM); + ADD_POTION_VIALS(p->prayer_pot_doses, INV_SLOT_PRAYER_POT); + #undef ADD_POTION_VIALS + + /* snapshot player state for incremental change detection */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Update potion vial doses in-place when doses change. + E.g. drinking 1 dose from a 4-dose brew changes it to 3-dose (different sprite). + When human_clicked_inv_slot targets a vial of this type, that specific vial loses + the dose first (OSRS behavior: you drink from the vial you clicked). */ +static void gui_inv_update_potion_doses(GuiState* gs, InvSlotType type, + int total_doses) { + /* collect existing vials of this type */ + int vial_slots[INV_GRID_SLOTS]; + int vial_count = 0; + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == type) { + vial_slots[vial_count++] = i; + } + } + if (vial_count == 0) return; + + /* figure out how many doses were lost */ + int old_total = 0; + for (int v = 0; v < vial_count; v++) { + /* reverse-lookup current dose count from OSRS ID */ + int oid = gs->inv_grid[vial_slots[v]].osrs_id; + int d4 = gui_consumable_osrs_id(type, 4); + int d3 = gui_consumable_osrs_id(type, 3); + int d2 = gui_consumable_osrs_id(type, 2); + int d1 = gui_consumable_osrs_id(type, 1); + if (oid == d4) old_total += 4; + else if (oid == d3) old_total += 3; + else if (oid == d2) old_total += 2; + else if (oid == d1) old_total += 1; + } + int doses_lost = old_total - total_doses; + + /* if a human clicked a specific vial of this type, decrement that one first */ + int clicked = gs->human_clicked_inv_slot; + if (doses_lost > 0 && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == type) { + /* find current dose count of clicked vial */ + int oid = gs->inv_grid[clicked].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose > 0) { + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + } else { + gs->inv_grid[clicked].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } + } + + /* if doses still need removing (non-human or multiple doses lost), + take from remaining vials in reverse order (last first) */ + for (int v = vial_count - 1; v >= 0 && doses_lost > 0; v--) { + int slot = vial_slots[v]; + if (slot == clicked) continue; /* already handled */ + if (gs->inv_grid[slot].type != type) continue; + int oid = gs->inv_grid[slot].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose <= 0) continue; + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[slot].type = INV_SLOT_EMPTY; + gs->inv_grid[slot].item_db_idx = 0; + gs->inv_grid[slot].osrs_id = 0; + } else { + gs->inv_grid[slot].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } +} + +/** Incremental inventory update. Detects gear switches and consumable changes + by comparing against the previous snapshot, then modifies only affected slots. + Items stay in their assigned positions — no compaction on eat/drink. + + OSRS gear swap rule: when you click an inventory item to equip it, the + previously equipped item goes into that exact inventory slot (direct swap). + Exception: equipping a 2H weapon while a shield is equipped — the shield + goes to the first empty inventory slot since it wasn't directly clicked. */ +static void gui_update_inventory(GuiState* gs, Player* p) { + /* --- gear switches: direct slot swaps --- */ + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + uint8_t prev = gs->inv_prev_equipped[s]; + uint8_t curr = p->equipped[s]; + if (prev == curr) continue; + + if (curr != ITEM_NONE && prev != ITEM_NONE) { + /* swap: new item was in inventory, old item takes its exact slot */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + /* check if old item is still a valid swap item */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout) { + /* direct swap: old item goes into the slot the new item came from */ + gs->inv_grid[src].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[src].item_db_idx = prev; + gs->inv_grid[src].osrs_id = ITEM_DATABASE[prev].item_id; + } else { + /* old item not in loadout — just clear the slot */ + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } + } else if (curr != ITEM_NONE) { + /* equipping from inventory, nothing was in this gear slot before */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } else if (prev != ITEM_NONE) { + /* gear slot cleared (e.g. shield removed by 2H weapon equip). + the old item goes to the first empty inventory slot. */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout && gui_inv_find_equipment(gs, prev) < 0) { + gui_inv_place_equipment(gs, prev); + } + } + } + + /* --- consumable changes: remove clicked slot or fall back to last --- */ + + /* if a human clicked a specific consumable slot, remove that exact slot first */ + int clicked = gs->human_clicked_inv_slot; + int clicked_used = 0; + + /* food */ + int food_diff = gs->inv_prev_food_count - p->food_count; + for (int i = 0; i < food_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_FOOD) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_FOOD); + } + } + + /* karambwan */ + int karam_diff = gs->inv_prev_karambwan_count - p->karambwan_count; + for (int i = 0; i < karam_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_KARAMBWAN) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_KARAMBWAN); + } + } + + /* potions: dose changes update existing vials in-place (sprite change), + and remove empty vials when a full vial is consumed. + human_clicked_inv_slot is still set here so the clicked vial loses the dose. */ + if (p->brew_doses != gs->inv_prev_brew_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_BREW, p->brew_doses); + } + if (p->restore_doses != gs->inv_prev_restore_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RESTORE, p->restore_doses); + } + if (p->combat_potion_doses != gs->inv_prev_combat_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_COMBAT_POT, p->combat_potion_doses); + } + if (p->ranged_potion_doses != gs->inv_prev_ranged_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RANGED_POT, p->ranged_potion_doses); + } + if (p->antivenom_doses != gs->inv_prev_antivenom_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_ANTIVENOM, p->antivenom_doses); + } + if (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_PRAYER_POT, p->prayer_pot_doses); + } + + /* only clear human click when a consumable was actually used this frame. + if no diff happened yet, keep it for the next tick when the sim processes the action. */ + int any_consumable_changed = clicked_used + || (p->brew_doses != gs->inv_prev_brew_doses) + || (p->restore_doses != gs->inv_prev_restore_doses) + || (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) + || (p->combat_potion_doses != gs->inv_prev_combat_doses) + || (p->ranged_potion_doses != gs->inv_prev_ranged_doses) + || (p->antivenom_doses != gs->inv_prev_antivenom_doses); + if (any_consumable_changed) { + gs->human_clicked_inv_slot = -1; + } + + /* update snapshot */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Get the inventory grid screen position for a slot index. */ +static void gui_inv_slot_pos(GuiState* gs, int slot, int* out_x, int* out_y) { + int grid_w = INV_COLS * INV_CELL_W; + int grid_x = gs->panel_x + (gs->panel_w - grid_w) / 2; + int grid_y = gui_content_y(gs) + 4; + int col = slot % INV_COLS; + int row = slot / INV_COLS; + *out_x = grid_x + col * INV_CELL_W; + *out_y = grid_y + row * INV_CELL_H; +} + +/** Hit test: return inventory slot index at screen position, or -1. */ +static int gui_inv_slot_at(GuiState* gs, int mx, int my) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + int sx, sy; + gui_inv_slot_pos(gs, i, &sx, &sy); + if (mx >= sx && mx < sx + INV_CELL_W && my >= sy && my < sy + INV_CELL_H) { + return i; + } + } + return -1; +} + +/** Handle inventory click: equip gear items, eat/drink consumables. + hi is a HumanInput* (from osrs_pvp_human_input_types.h, included above). + When non-NULL and enabled, food/potion clicks set pending_* fields instead of + directly mutating player state, so the action system handles timers. */ + +static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, + HumanInput* hi) { + if (slot < 0 || slot >= INV_GRID_SLOTS) return INV_ACTION_NONE; + InvSlot* inv = &gs->inv_grid[slot]; + if (inv->type == INV_SLOT_EMPTY) return INV_ACTION_NONE; + + /* start dim animation */ + gs->inv_dim_slot = slot; + gs->inv_dim_timer = INV_DIM_TICKS; + + /* when human control is active, route food/potion through action system + instead of directly mutating player state (respects timers) */ + int human_active = (hi && hi->enabled); + + switch (inv->type) { + case INV_SLOT_EQUIPMENT: { + /* equipment clicks always directly equip (more faithful than RL loadout presets) */ + int gear_slot = item_to_gear_slot(inv->item_db_idx); + if (gear_slot >= 0) { + slot_equip_item(p, gear_slot, inv->item_db_idx); + } + return INV_ACTION_EQUIP; + } + case INV_SLOT_FOOD: + if (human_active) { hi->pending_food = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 0); } + return INV_ACTION_EAT; + case INV_SLOT_KARAMBWAN: + if (human_active) { hi->pending_karambwan = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 1); } + return INV_ACTION_EAT; + case INV_SLOT_BREW: + if (human_active) { hi->pending_potion = POTION_BREW; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RESTORE: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_COMBAT_POT: + if (human_active) { hi->pending_potion = POTION_COMBAT; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RANGED_POT: + if (human_active) { hi->pending_potion = POTION_RANGED; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_ANTIVENOM: + if (human_active) { hi->pending_potion = POTION_ANTIVENOM; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_PRAYER_POT: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + default: + return INV_ACTION_NONE; + } +} + +/** Handle inventory mouse input: clicks, drag start/move/release. + When hi is non-NULL and enabled, food/potion clicks route through the + action system instead of directly mutating player state. */ +static void gui_inv_handle_mouse(GuiState* gs, Player* p, HumanInput* hi) { + if (gs->active_tab != GUI_TAB_INVENTORY) return; + + int mx = GetMouseX(); + int my = GetMouseY(); + + /* drag in progress */ + if (gs->inv_drag_active) { + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + /* drop: swap src and target slots */ + int target = gui_inv_slot_at(gs, mx, my); + if (target >= 0 && target != gs->inv_drag_src_slot) { + InvSlot tmp = gs->inv_grid[target]; + gs->inv_grid[target] = gs->inv_grid[gs->inv_drag_src_slot]; + gs->inv_grid[gs->inv_drag_src_slot] = tmp; + } + gs->inv_drag_active = 0; + gs->inv_drag_src_slot = -1; + } + return; + } + + /* new click */ + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + int slot = gui_inv_slot_at(gs, mx, my); + if (slot >= 0 && gs->inv_grid[slot].type != INV_SLOT_EMPTY) { + gs->inv_drag_start_x = mx; + gs->inv_drag_start_y = my; + gs->inv_drag_src_slot = slot; + } + } + + /* check if held mouse has moved past dead zone → start drag */ + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + int dx = mx - gs->inv_drag_start_x; + int dy = my - gs->inv_drag_start_y; + if (dx > INV_DRAG_DEAD_ZONE || dx < -INV_DRAG_DEAD_ZONE || + dy > INV_DRAG_DEAD_ZONE || dy < -INV_DRAG_DEAD_ZONE) { + gs->inv_drag_active = 1; + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + /* dim the source slot during drag */ + gs->inv_dim_slot = gs->inv_drag_src_slot; + gs->inv_dim_timer = 9999; /* stays dim during entire drag */ + } + } + + /* click release without drag = activate item */ + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + gui_inv_click(gs, p, gs->inv_drag_src_slot, hi); + gs->inv_drag_src_slot = -1; + } +} + +/** Tick the inventory dim timer (call at 50 Hz). */ +static void gui_inv_tick(GuiState* gs) { + if (gs->inv_dim_timer > 0 && !gs->inv_drag_active) { + gs->inv_dim_timer--; + if (gs->inv_dim_timer <= 0) { + gs->inv_dim_slot = -1; + } + } +} + +static void gui_draw_inventory(GuiState* gs, Player* p) { + /* full rebuild on first frame or reset, incremental updates after */ + if (gs->inv_grid_dirty) { + gui_populate_inventory(gs, p); + gs->inv_grid_dirty = 0; + } else { + gui_update_inventory(gs, p); + } + + /* draw 4x7 slot backgrounds (subtle dark rectangles matching OSRS inventory) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + /* OSRS inventory slots have a very subtle dark border/tint. + draw a 36x32 centered slot background to delineate cells. */ + int sx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int sy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + DrawRectangle(sx, sy, INV_SPRITE_W, INV_SPRITE_H, + CLITERAL(Color){ 0, 0, 0, 30 }); + } + + /* draw items (sprites are 36x32 native, scaled to INV_SPRITE_W x INV_SPRITE_H) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + InvSlot* inv = &gs->inv_grid[slot]; + + if (inv->type == INV_SLOT_EMPTY) continue; + + /* determine sprite */ + Texture2D tex = { 0 }; + if (inv->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, inv->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, inv->osrs_id); + } + + /* dim tint: 50% alpha when clicked/dragged (matches OSRS var17=128) */ + int is_dimmed = (gs->inv_dim_slot == slot && gs->inv_dim_timer > 0); + Color tint = is_dimmed ? CLITERAL(Color){ 255, 255, 255, 128 } : WHITE; + + int dx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int dy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + + /* skip drawing at grid position if being dragged (drawn at cursor instead) */ + if (gs->inv_drag_active && slot == gs->inv_drag_src_slot) { + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 80 }); + } + continue; + } + + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, tint); + } else { + const char* name = (inv->type == INV_SLOT_EQUIPMENT) + ? gui_item_short_name(inv->item_db_idx) : "???"; + gui_text_shadow(name, cx + 2, cy + 12, 7, GUI_TEXT_YELLOW); + } + } + + /* draw dragged item at cursor position */ + if (gs->inv_drag_active && gs->inv_drag_src_slot >= 0) { + InvSlot* drag = &gs->inv_grid[gs->inv_drag_src_slot]; + Texture2D tex = { 0 }; + if (drag->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, drag->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, drag->osrs_id); + } + if (tex.id != 0) { + int dx = gs->inv_drag_mouse_x - INV_SPRITE_W / 2; + int dy = gs->inv_drag_mouse_y - INV_SPRITE_H / 2; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 200 }); + } + + /* highlight target slot under cursor */ + int target = gui_inv_slot_at(gs, gs->inv_drag_mouse_x, gs->inv_drag_mouse_y); + if (target >= 0 && target != gs->inv_drag_src_slot) { + int tx, ty; + gui_inv_slot_pos(gs, target, &tx, &ty); + DrawRectangle(tx, ty, INV_CELL_W, INV_CELL_H, + CLITERAL(Color){ 255, 255, 255, 40 }); + } + } +} + +/* ======================================================================== */ +/* equipment panel (interface 387: paperdoll layout only) */ +/* ======================================================================== */ + +static void gui_draw_equipment(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + gui_text_shadow("Worn Equipment", gs->panel_x + 8, oy, 12, GUI_TEXT_ORANGE); + oy += 22; + + /* OSRS paperdoll: 5 rows, 3-column layout centered in panel. + slot sizes scaled to fill panel width (320px - 16px padding = 304px). */ + int gap = 6; + int sw = (gs->panel_w - 16 - gap * 2) / 3; /* ~97px per slot */ + int sh = (int)(sw * 0.75f); /* maintain ~4:3 aspect ratio (~73px) */ + int cx = gs->panel_x + gs->panel_w / 2; + int r3_w = sw * 3 + gap * 2; + int r3_x = cx - r3_w / 2; + + /* row 0: head (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_HEAD, p->equipped[GEAR_SLOT_HEAD]); + oy += sh + gap; + + /* row 1: cape, neck, ammo */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_CAPE, p->equipped[GEAR_SLOT_CAPE]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_NECK, p->equipped[GEAR_SLOT_NECK]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_AMMO, p->equipped[GEAR_SLOT_AMMO]); + oy += sh + gap; + + /* row 2: weapon, body, shield */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_WEAPON, p->equipped[GEAR_SLOT_WEAPON]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_BODY, p->equipped[GEAR_SLOT_BODY]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_SHIELD, p->equipped[GEAR_SLOT_SHIELD]); + oy += sh + gap; + + /* row 3: legs (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_LEGS, p->equipped[GEAR_SLOT_LEGS]); + oy += sh + gap; + + /* row 4: hands, feet, ring */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_HANDS, p->equipped[GEAR_SLOT_HANDS]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_FEET, p->equipped[GEAR_SLOT_FEET]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_RING, p->equipped[GEAR_SLOT_RING]); +} + +/* ======================================================================== */ +/* prayer panel (interface 541) — single 5-column grid, all 25 prayers */ +/* ======================================================================== */ + +/* OSRS prayer book order: 5 columns, 5 rows = 25 prayers. + each entry maps a grid position to a GuiPrayerIdx. */ +static const GuiPrayerIdx GUI_PRAYER_GRID[25] = { + GUI_PRAY_THICK_SKIN, GUI_PRAY_BURST_STR, GUI_PRAY_CLARITY, + GUI_PRAY_SHARP_EYE, GUI_PRAY_MYSTIC_WILL, + GUI_PRAY_ROCK_SKIN, GUI_PRAY_SUPERHUMAN, GUI_PRAY_IMPROVED_REFLEX, + GUI_PRAY_RAPID_RESTORE, GUI_PRAY_RAPID_HEAL, + GUI_PRAY_PROTECT_ITEM, GUI_PRAY_HAWK_EYE, GUI_PRAY_PROTECT_MAGIC, + GUI_PRAY_PROTECT_MISSILES, GUI_PRAY_PROTECT_MELEE, + GUI_PRAY_REDEMPTION, GUI_PRAY_RETRIBUTION, GUI_PRAY_SMITE, + GUI_PRAY_CHIVALRY, GUI_PRAY_PIETY, + GUI_PRAY_EAGLE_EYE, GUI_PRAY_MYSTIC_MIGHT, GUI_PRAY_PRESERVE, + GUI_PRAY_RIGOUR, GUI_PRAY_AUGURY, +}; +#define GUI_PRAYER_GRID_COUNT 25 + +/** Check if a prayer grid slot is currently active based on player state. */ +static int gui_prayer_is_active(GuiPrayerIdx pidx, Player* p) { + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: return p->prayer == PRAYER_PROTECT_MAGIC; + case GUI_PRAY_PROTECT_MISSILES: return p->prayer == PRAYER_PROTECT_RANGED; + case GUI_PRAY_PROTECT_MELEE: return p->prayer == PRAYER_PROTECT_MELEE; + case GUI_PRAY_REDEMPTION: return p->prayer == PRAYER_REDEMPTION; + case GUI_PRAY_SMITE: return p->prayer == PRAYER_SMITE; + case GUI_PRAY_PIETY: return p->offensive_prayer == OFFENSIVE_PRAYER_PIETY; + case GUI_PRAY_RIGOUR: return p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR; + case GUI_PRAY_AUGURY: return p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY; + default: return 0; + } +} + +static void gui_draw_prayer(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 4; + + /* prayer points bar at top */ + int bar_x = gs->panel_x + 8; + int bar_w = gs->panel_w - 16; + int bar_h = 18; + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(bar_x, oy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(bar_x, oy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(bar_x, oy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("%d / %d", p->current_prayer, p->base_prayer), + bar_x + bar_w / 2 - 20, oy + 3, 10, GUI_TEXT_WHITE); + oy += bar_h + 6; + + /* 5-column grid of all 25 prayers. + OSRS native: 37x37 pitch, scaled to fill 320px panel (~60px cells). */ + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~60px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_PRAYER_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[i]; + int active = gui_prayer_is_active(pidx, p); + + /* draw slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* active prayer: yellow highlight overlay */ + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw prayer sprite (scaled to cell) */ + if (gs->sprites_loaded) { + Texture2D tex = active ? gs->prayer_on[pidx] : gs->prayer_off[pidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } +} + +/* ======================================================================== */ +/* combat panel (interface 593) — weapon + 4 style buttons + spec bar */ +/* ======================================================================== */ + +static void gui_draw_combat(GuiState* gs, Player* p) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* weapon name + sprite (scaled to match panel) */ + const char* wpn_name = "Unarmed"; + if (p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] < NUM_ITEMS) { + wpn_name = gui_item_short_name(p->equipped[GEAR_SLOT_WEAPON]); + } + + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + if (wpn_tex.id != 0) { + Rectangle src = { 0, 0, (float)wpn_tex.width, (float)wpn_tex.height }; + Rectangle dst = { (float)ox, (float)oy, 60.0f, 54.0f }; + DrawTexturePro(wpn_tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + gui_text_shadow(wpn_name, ox + 66, oy + 16, 14, GUI_TEXT_ORANGE); + oy += 60; + } else { + gui_text_shadow(wpn_name, ox, oy, 14, GUI_TEXT_ORANGE); + oy += 22; + } + + /* 4 attack style buttons (2x2 grid) scaled to fill panel width */ + static const char* style_names[] = { "Accurate", "Aggressive", "Controlled", "Defensive" }; + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; /* ~151px */ + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + + int active = ((int)p->fight_style == i); + + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)bx, (float)by, (float)btn_w, (float)btn_h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + if (active) { + DrawRectangle(bx, by, btn_w, btn_h, GUI_PRAYER_ON); + } + DrawRectangleLines(bx, by, btn_w, btn_h, GUI_BORDER); + + Color txt_c = active ? GUI_TEXT_YELLOW : GUI_TEXT_WHITE; + int txt_w = MeasureText(style_names[i], 11); + gui_text_shadow(style_names[i], bx + btn_w / 2 - txt_w / 2, by + btn_h / 2 - 5, 11, txt_c); + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* special attack bar — clickable. yellow border when spec is queued (OSRS-style). */ + Color spec_label_color = p->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); +} + +/* ======================================================================== */ +/* spellbook panel (interface 218: ancient magicks + vengeance) */ +/* ======================================================================== */ + +typedef struct { + const char* name; + GuiSpellIdx idx; +} GuiSpellEntry; + +static const GuiSpellEntry GUI_SPELL_GRID[] = { + { "Ice Rush", GUI_SPELL_ICE_RUSH }, + { "Ice Burst", GUI_SPELL_ICE_BURST }, + { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, + { "Blood Rush", GUI_SPELL_BLOOD_RUSH }, + { "Blood Burst", GUI_SPELL_BLOOD_BURST }, + { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, + { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, + { "Vengeance", GUI_SPELL_VENGEANCE }, +}; +#define GUI_SPELL_GRID_COUNT 9 + +static void gui_draw_spellbook(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + /* 4-column grid of all spells, scaled to fill panel width. + OSRS ancient spellbook native is ~26x26 icons. we scale up to match + the inventory cell size for visual consistency. */ + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~76px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_SPELL_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + /* active highlight for vengeance */ + int active = (i == 8 && p->veng_active); + + /* slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw spell sprite (scaled to cell) */ + GuiSpellIdx sidx = GUI_SPELL_GRID[i].idx; + if (gs->sprites_loaded) { + Texture2D tex = gs->spell_on[sidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } + oy += ((GUI_SPELL_GRID_COUNT + cols - 1) / cols) * (icon_sz + gap) + 12; + + /* veng cooldown below the grid */ + int ox = gs->panel_x + 8; + gui_text_shadow(TextFormat("Veng cooldown: %d", p->veng_cooldown), + ox, oy, 10, p->veng_cooldown > 0 ? GUI_TEXT_RED : GUI_TEXT_GREEN); + (void)p; +} + +/* ======================================================================== */ +/* stats panel — OSRS-authentic skills tab layout */ +/* */ +/* matches the real OSRS fixed-mode skills interface: 3-column grid, */ +/* skill icon on left, current level center, base level right. */ +/* only combat skills are shown; non-combat rows are empty. */ +/* below the skill grid: combat info (max hit, gear, prayer, consumables). */ +/* */ +/* OSRS skill grid order (3 columns, 8 rows): */ +/* col 0: Attack, Strength, Defence, Ranged, Prayer, Magic, RC, Constr */ +/* col 1: Hitpoints, Agility, Herblore, Thieving, Crafting, Fletch, ... */ +/* col 2: Mining, Smithing, Fishing, Cooking, Firemaking, WC, ... */ +/* we show rows 0-5 (the 7 combat skills) and leave col 2 empty. */ +/* ======================================================================== */ + +/* skill icon indices (matches skill_icon_files load order) */ +#define SKILL_ICON_ATTACK 0 +#define SKILL_ICON_STRENGTH 1 +#define SKILL_ICON_DEFENCE 2 +#define SKILL_ICON_RANGED 3 +#define SKILL_ICON_PRAYER 4 +#define SKILL_ICON_MAGIC 5 +#define SKILL_ICON_HITPOINTS 6 + +/* draw one skill cell: icon + current level (left) + base level (right). + OSRS style: yellow if current == base, green if boosted, red if drained. + cell dimensions match the real client scaled to our panel width. */ +static void gui_draw_skill_cell(GuiState* gs, int cx, int cy, int cw, int ch, + int icon_idx, int current, int base) { + /* dark cell background with border (matches OSRS skill cell) */ + DrawRectangle(cx, cy, cw, ch, (Color){30, 27, 20, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){60, 54, 42, 255}); + + /* skill icon (scaled to fit cell height with padding) */ + int icon_sz = ch - 6; + int icon_x = cx + 3; + int icon_y = cy + 3; + if (gs->skill_icons_loaded && icon_idx >= 0 && icon_idx < 7 && + gs->skill_icons[icon_idx].id != 0) { + Texture2D tex = gs->skill_icons[icon_idx]; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)icon_x, (float)icon_y, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* level color: yellow=normal, green=boosted, red=drained */ + Color lvl_color = GUI_TEXT_YELLOW; + if (current > base) lvl_color = GUI_TEXT_GREEN; + else if (current < base) lvl_color = (Color){255, 60, 60, 255}; + + /* current level (left side, after icon) */ + int text_y = cy + ch / 2 - 5; + gui_text_shadow(TextFormat("%d", current), icon_x + icon_sz + 4, text_y, 10, lvl_color); + + /* base level (right-aligned) */ + const char* base_str = TextFormat("%d", base); + int bw = MeasureText(base_str, 10); + gui_text_shadow(base_str, cx + cw - bw - 4, text_y, 10, lvl_color); +} + +static const char* gui_gear_name(GearSet g) { + switch (g) { + case GEAR_MAGE: return "Mage"; + case GEAR_RANGED: return "Ranged"; + case GEAR_MELEE: return "Melee"; + case GEAR_SPEC: return "Spec"; + case GEAR_TANK: return "Tank"; + default: return "???"; + } +} + +static const char* gui_prayer_name(OverheadPrayer pr) { + switch (pr) { + case PRAYER_PROTECT_MAGIC: return "Protect Magic"; + case PRAYER_PROTECT_RANGED: return "Protect Ranged"; + case PRAYER_PROTECT_MELEE: return "Protect Melee"; + case PRAYER_SMITE: return "Smite"; + case PRAYER_REDEMPTION: return "Redemption"; + default: return "None"; + } +} + +static void gui_draw_stats(GuiState* gs, Player* p) { + int ox = gs->panel_x + 4; + int oy = gui_content_y(gs) + 4; + + /* OSRS skill grid: 3 columns, 6 visible rows for combat skills. + cell dimensions scale to panel width. OSRS original: 62x32 in 190px panel. */ + int gap = 2; + int cols = 3; + int cw = (gs->panel_w - 8 - gap * (cols - 1)) / cols; + int ch = 32; + + /* OSRS grid: row x col → (icon_idx, current, base) or -1 for empty. + col 0: attack, strength, defence, ranged, prayer, magic + col 1: hitpoints, then empty + col 2: empty (non-combat) */ + int grid_icon[6][3]; + int grid_cur[6][3]; + int grid_base[6][3]; + memset(grid_icon, -1, sizeof(grid_icon)); + memset(grid_cur, 0, sizeof(grid_cur)); + memset(grid_base, 0, sizeof(grid_base)); + + /* col 0: combat stats in OSRS order */ + grid_icon[0][0] = SKILL_ICON_ATTACK; grid_cur[0][0] = p->current_attack; grid_base[0][0] = p->base_attack; + grid_icon[1][0] = SKILL_ICON_STRENGTH; grid_cur[1][0] = p->current_strength; grid_base[1][0] = p->base_strength; + grid_icon[2][0] = SKILL_ICON_DEFENCE; grid_cur[2][0] = p->current_defence; grid_base[2][0] = p->base_defence; + grid_icon[3][0] = SKILL_ICON_RANGED; grid_cur[3][0] = p->current_ranged; grid_base[3][0] = p->base_ranged; + grid_icon[4][0] = SKILL_ICON_PRAYER; grid_cur[4][0] = p->current_prayer; grid_base[4][0] = p->base_prayer; + grid_icon[5][0] = SKILL_ICON_MAGIC; grid_cur[5][0] = p->current_magic; grid_base[5][0] = p->base_magic; + + /* col 1: hitpoints at row 0, rest stays -1 (empty) */ + grid_icon[0][1] = SKILL_ICON_HITPOINTS; grid_cur[0][1] = p->current_hitpoints; grid_base[0][1] = p->base_hitpoints; + + /* draw the grid */ + for (int r = 0; r < 6; r++) { + for (int c = 0; c < cols; c++) { + int cx = ox + c * (cw + gap); + int cy = oy + r * (ch + gap); + if (grid_icon[r][c] >= 0) { + gui_draw_skill_cell(gs, cx, cy, cw, ch, + grid_icon[r][c], grid_cur[r][c], grid_base[r][c]); + } else { + /* empty cell: just dark bg */ + DrawRectangle(cx, cy, cw, ch, (Color){20, 18, 14, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){40, 36, 28, 255}); + } + } + } + oy += 6 * (ch + gap) + 6; + + /* separator */ + int bar_w = gs->panel_w - 8; + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* combat info below the skill grid */ + int lh = 17; + gui_text_shadow(TextFormat("Gear: %s", gui_gear_name(p->current_gear)), + ox + 2, oy, 10, GUI_TEXT_ORANGE); + oy += lh; + gui_text_shadow(TextFormat("Max Hit: %d Str Bonus: %d", p->gui_max_hit, p->gui_strength_bonus), + ox + 2, oy, 10, GUI_TEXT_YELLOW); + oy += lh; + gui_text_shadow(TextFormat("Speed: %d Range: %d", p->gui_attack_speed, p->gui_attack_range), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Prayer: %s", gui_prayer_name(p->prayer)), + ox + 2, oy, 10, GUI_TEXT_CYAN); + oy += lh + 4; + + /* separator */ + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* consumables */ + gui_text_shadow(TextFormat("Brews: %d Restores: %d", p->brew_doses, p->restore_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Bastion: %d Stamina: %d", + p->combat_potion_doses, p->ranged_potion_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + + /* special attack energy bar */ + int spec_bar_w = bar_w; + int spec_bar_h = 14; + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(ox, oy, spec_bar_w, spec_bar_h, GUI_SPEC_DARK); + DrawRectangle(ox, oy, (int)(spec_bar_w * spec_pct), spec_bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(ox, oy, spec_bar_w, spec_bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec: %d%%", p->special_energy), + ox + 4, oy + 1, 10, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* main GUI draw (dispatches to active tab) */ +/* ======================================================================== */ + +static void gui_cycle_entity(GuiState* gs) { + if (gs->gui_entity_count <= 0) return; + gs->gui_entity_idx = (gs->gui_entity_idx + 1) % gs->gui_entity_count; +} + +static void gui_draw(GuiState* gs, Player* p) { + int px = gs->panel_x; + int py = gs->panel_y + gs->status_bar_h + gs->tab_h; /* content area starts after status + tabs */ + int pw = gs->panel_w; + int ph = gs->panel_h - gs->status_bar_h - gs->tab_h; /* remaining height for content */ + + /* draw real OSRS stone panel background, stretched to fill content area (single draw, not tiled) */ + if (gs->chrome_loaded && gs->side_panel_bg.id != 0) { + Rectangle src = { 0, 0, (float)gs->side_panel_bg.width, (float)gs->side_panel_bg.height }; + Rectangle dst = { (float)px, (float)py, (float)pw, (float)ph }; + DrawTexturePro(gs->side_panel_bg, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(px, py, pw, ph, GUI_BG_DARK); + } + + /* entity selector header */ + if (gs->gui_entity_count > 1) { + int hx = px + 4; + int hy = py + 2; + const char* etype = (p->entity_type == ENTITY_NPC) ? "NPC" : "Player"; + gui_text_shadow(TextFormat("[G] %s %d/%d", etype, + gs->gui_entity_idx + 1, gs->gui_entity_count), + hx, hy, 8, GUI_TEXT_ORANGE); + } + + /* draw status bar (HP/prayer/spec) above the tab row */ + gui_draw_status_bar(gs, p); + + /* draw tab bar at top (after status bar) */ + gui_draw_tab_bar(gs); + + /* dispatch to active tab content */ + switch (gs->active_tab) { + case GUI_TAB_COMBAT: gui_draw_combat(gs, p); break; + case GUI_TAB_INVENTORY: gui_draw_inventory(gs, p); break; + case GUI_TAB_EQUIPMENT: gui_draw_equipment(gs, p); break; + case GUI_TAB_PRAYER: gui_draw_prayer(gs, p); break; + case GUI_TAB_SPELLBOOK: gui_draw_spellbook(gs, p); break; + case GUI_TAB_STATS: gui_draw_stats(gs, p); break; + case GUI_TAB_QUESTS: /* empty tab */ break; + default: break; + } +} + +#endif /* OSRS_GUI_H */ diff --git a/src/osrs/osrs_human_input.h b/src/osrs/osrs_human_input.h new file mode 100644 index 0000000000..5a36fd46df --- /dev/null +++ b/src/osrs/osrs_human_input.h @@ -0,0 +1,486 @@ +/** + * @file osrs_pvp_human_input.h + * @brief Interactive human control for the visual debug viewer. + * + * Collects mouse/keyboard input as semantic intents between render frames, + * then translates them to encounter-specific action arrays at tick boundary. + * Toggle human control with H key. Works across PvP and encounter modes. + * + * Architecture: clicks at 60Hz → HumanInput staging buffer → per-encounter + * translator at tick rate → int[] action array fed to step(). + */ + +#ifndef OSRS_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; + +/* ======================================================================== */ +/* init / reset */ +/* ======================================================================== */ + +static void human_input_init(HumanInput* hi) { + memset(hi, 0, sizeof(*hi)); + hi->pending_move_x = -1; + hi->pending_move_y = -1; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_target_idx = -1; + hi->click_cross_active = 0; +} + +/** Clear pending actions after they've been consumed at tick boundary. + Movement is NOT cleared here — it persists until the player reaches the + destination or a new click overrides it. Use human_input_clear_move() + for that. */ +static void human_input_clear_pending(HumanInput* hi) { + /* pending_move_x/y intentionally NOT cleared — movement is persistent */ + hi->pending_attack = 0; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_food = 0; + hi->pending_karambwan = 0; + hi->pending_potion = 0; + hi->pending_veng = 0; + hi->pending_spec = 0; + hi->pending_spell = 0; + hi->pending_target_idx = -1; + hi->pending_gear = 0; + /* don't clear cursor_mode or selected_spell — those persist until cancelled */ + /* don't clear click_tile — visual feedback fades on its own */ +} + +/** Clear persistent movement destination. Call when player reaches target tile. */ +static void human_input_clear_move(HumanInput* hi) { + hi->pending_move_x = -1; + hi->pending_move_y = -1; +} + +/* ======================================================================== */ +/* screen-to-world conversion */ +/* ======================================================================== */ + +/** Convert screen X to world tile X (inverse of render_world_to_screen_x_rc). + tile_size = RENDER_TILE_SIZE (passed to avoid header ordering issues). */ +static inline int human_screen_to_world_x(int screen_x, int arena_base_x, + int tile_size) { + return screen_x / tile_size + arena_base_x; +} + +/** Convert screen Y to world tile Y (inverse of render_world_to_screen_y_rc). + OSRS Y increases north, screen Y increases down. */ +static inline int human_screen_to_world_y(int screen_y, int arena_base_y, + int arena_height, int header_h, + int tile_size) { + int flipped = (screen_y - header_h) / tile_size; + return arena_base_y + (arena_height - 1) - flipped; +} + +/* ======================================================================== */ +/* click handlers — set semantic intents on HumanInput */ +/* ======================================================================== */ + +/** Check if world tile (wx,wy) is within an NPC's bounding box. + OSRS NPCs occupy npc_size x npc_size tiles anchored at (x,y) as southwest corner. + Players have npc_size 0 or 1, occupying just their tile. */ +static int human_tile_hits_entity(RenderEntity* ent, int wx, int wy) { + int size = ent->npc_size > 1 ? ent->npc_size : 1; + return wx >= ent->x && wx < ent->x + size && + wy >= ent->y && wy < ent->y + size; +} + +/** Set click cross at screen position (2D overlay, like real OSRS client). */ +static void human_set_click_cross(HumanInput* hi, int screen_x, int screen_y, int is_attack) { + hi->click_screen_x = screen_x; + hi->click_screen_y = screen_y; + hi->click_cross_timer = 0; + hi->click_cross_active = 1; + hi->click_is_attack = is_attack; +} + +/** Process a world-tile click: attack entity if hit, otherwise move. + screen_x/y is the raw mouse position for the click cross overlay. */ +static void human_process_tile_click(HumanInput* hi, + int wx, int wy, + int screen_x, int screen_y, + RenderEntity* entities, int entity_count, + int gui_entity_idx) { + /* check if an attackable entity occupies this tile (bounding box) */ + for (int i = 0; i < entity_count; i++) { + if (i == gui_entity_idx) continue; /* can't attack self */ + if (!entities[i].npc_visible && entities[i].entity_type == ENTITY_NPC) continue; + if (human_tile_hits_entity(&entities[i], wx, wy)) { + hi->pending_attack = 1; + hi->pending_target_idx = entities[i].npc_slot; + /* attack cancels movement — server stops walking to old dest + and auto-walks toward target instead (OSRS server behavior) */ + hi->pending_move_x = -1; + hi->pending_move_y = -1; + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->pending_spell = hi->selected_spell; + hi->cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(hi, screen_x, screen_y, 1); + return; + } + } + + /* no entity — movement click */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->cursor_mode = CURSOR_NORMAL; + return; + } + + hi->pending_move_x = wx; + hi->pending_move_y = wy; + human_set_click_cross(hi, screen_x, screen_y, 0); +} + +/** Handle ground/entity click in 2D grid mode. + tile_size and header_h are RENDER_TILE_SIZE/RENDER_HEADER_HEIGHT. */ +static void human_handle_ground_click(HumanInput* hi, + int mouse_x, int mouse_y, + int arena_base_x, int arena_base_y, + int arena_width, int arena_height, + RenderEntity* entities, int entity_count, + int gui_entity_idx, + int tile_size, int header_h) { + if (mouse_y < header_h) return; + int grid_pixel_w = arena_width * tile_size; + int grid_pixel_h = arena_height * tile_size; + if (mouse_x < 0 || mouse_x >= grid_pixel_w) return; + if (mouse_y >= header_h + grid_pixel_h) return; + + int wx = human_screen_to_world_x(mouse_x, arena_base_x, tile_size); + int wy = human_screen_to_world_y(mouse_y, arena_base_y, arena_height, + header_h, tile_size); + human_process_tile_click(hi, wx, wy, mouse_x, mouse_y, + entities, entity_count, gui_entity_idx); +} + +/** Handle prayer icon click. Hit-tests the 5-col prayer grid. + Reuses the same layout math as gui_draw_prayer(). */ +static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 4; + /* skip prayer points bar */ + int bar_h = 18; + oy += bar_h + 6; + + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + /* hit-test: which cell was clicked? */ + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_PRAYER_GRID_COUNT) return; + + /* check click is within the cell bounds (not in the gap) */ + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[idx]; + + /* map prayer to action — only actionable prayers */ + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MAGIC) + ? OVERHEAD_NONE : OVERHEAD_MAGE; + break; + case GUI_PRAY_PROTECT_MISSILES: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_RANGED) + ? OVERHEAD_NONE : OVERHEAD_RANGED; + break; + case GUI_PRAY_PROTECT_MELEE: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MELEE) + ? OVERHEAD_NONE : OVERHEAD_MELEE; + break; + case GUI_PRAY_SMITE: + hi->pending_prayer = (p->prayer == PRAYER_SMITE) + ? OVERHEAD_NONE : OVERHEAD_SMITE; + break; + case GUI_PRAY_REDEMPTION: + hi->pending_prayer = (p->prayer == PRAYER_REDEMPTION) + ? OVERHEAD_NONE : OVERHEAD_REDEMPTION; + break; + case GUI_PRAY_PIETY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) + ? 0 : 1; + break; + case GUI_PRAY_RIGOUR: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) + ? 0 : 2; + break; + case GUI_PRAY_AUGURY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) + ? 0 : 3; + break; + default: + break; /* non-actionable prayer */ + } +} + +/** Handle spell icon click. Hit-tests the 4-col spell grid. + Ice/blood spells enter CURSOR_SPELL_TARGET mode; vengeance is instant. */ +static void human_handle_spell_click(HumanInput* hi, GuiState* gs, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 8; + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_SPELL_GRID_COUNT) return; + + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiSpellIdx sidx = GUI_SPELL_GRID[idx].idx; + + if (sidx == GUI_SPELL_VENGEANCE) { + /* vengeance is instant — no targeting needed */ + hi->pending_veng = 1; + } else if (sidx >= GUI_SPELL_ICE_RUSH && sidx <= GUI_SPELL_ICE_BARRAGE) { + /* ice spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_ICE; + } else if (sidx >= GUI_SPELL_BLOOD_RUSH && sidx <= GUI_SPELL_BLOOD_BARRAGE) { + /* blood spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_BLOOD; + } +} + +/** Handle combat panel click (fight style buttons + spec bar). + Fight style is set directly on Player (not in the action space). + Spec bar click sets pending_spec. */ +static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* skip weapon name/sprite area */ + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + oy += (wpn_tex.id != 0) ? 60 : 22; + + /* 2x2 fight style buttons */ + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + if (mouse_x >= bx && mouse_x < bx + btn_w && + mouse_y >= by && mouse_y < by + btn_h) { + p->fight_style = (FightStyle)i; + return; + } + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* skip "Special Attack" label */ + oy += 16; + + /* spec bar */ + int spec_w = gs->panel_w - 16; + int spec_h = 26; + if (mouse_x >= ox && mouse_x < ox + spec_w && + mouse_y >= oy && mouse_y < oy + spec_h) { + hi->pending_spec = 1; + } +} + +/* ======================================================================== */ +/* action translators — convert semantic intents to action arrays */ +/* ======================================================================== */ + +/** Translate human input to PvP 7-head action array for agent 0. + Movement is target-relative (ADJACENT/UNDER/DIAGONAL/FARCAST_N). */ +static void human_to_pvp_actions(HumanInput* hi, int* actions, + Player* agent, Player* target) { + /* zero all heads */ + for (int h = 0; h < NUM_ACTION_HEADS; h++) actions[h] = 0; + + /* HEAD_LOADOUT: keep current gear (human equips items via inventory clicks) */ + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + + /* HEAD_COMBAT: attack or movement */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (hi->pending_spell == ATTACK_BLOOD) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (hi->pending_move_x >= 0 && hi->pending_move_y >= 0) { + /* convert absolute tile to target-relative movement */ + int dx = hi->pending_move_x - target->x; + int dy = hi->pending_move_y - target->y; + int dist = (abs(dx) > abs(dy)) ? abs(dx) : abs(dy); /* chebyshev */ + + if (dist == 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (dist == 1) { + /* check if cardinal (adjacent) or diagonal */ + if (dx == 0 || dy == 0) { + actions[HEAD_COMBAT] = MOVE_ADJACENT; + } else { + actions[HEAD_COMBAT] = MOVE_DIAGONAL; + } + } else { + /* farcast: clamp to 2-7 */ + int fc = dist; + if (fc < 2) fc = 2; + if (fc > 7) fc = 7; + actions[HEAD_COMBAT] = MOVE_FARCAST_2 + (fc - 2); + } + } + + /* HEAD_OVERHEAD: prayer */ + if (hi->pending_prayer >= 0) { + actions[HEAD_OVERHEAD] = hi->pending_prayer; + } + + /* HEAD_FOOD */ + if (hi->pending_food) { + actions[HEAD_FOOD] = FOOD_EAT; + } + + /* HEAD_POTION */ + if (hi->pending_potion > 0) { + actions[HEAD_POTION] = hi->pending_potion; + } + + /* HEAD_KARAMBWAN */ + if (hi->pending_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + } + + /* HEAD_VENG */ + if (hi->pending_veng) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* spec: use LOADOUT_SPEC_MELEE/RANGE/MAGIC based on current weapon style */ + if (hi->pending_spec) { + AttackStyle style = get_item_attack_style(agent->equipped[GEAR_SLOT_WEAPON]); + switch (style) { + case ATTACK_STYLE_MELEE: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MELEE; break; + case ATTACK_STYLE_RANGED: actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; break; + case ATTACK_STYLE_MAGIC: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MAGIC; break; + default: break; + } + } + + /* offensive prayer: set via loadout-aware mechanism. + piety/rigour/augury are auto-set by the action system based on loadout, + so we handle this by setting the appropriate loadout if prayer changed. + for human play we just directly mutate the player's offensive prayer. */ + if (hi->pending_offensive_prayer >= 0) { + switch (hi->pending_offensive_prayer) { + case 0: agent->offensive_prayer = OFFENSIVE_PRAYER_NONE; break; + case 1: agent->offensive_prayer = OFFENSIVE_PRAYER_PIETY; break; + case 2: agent->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; break; + case 3: agent->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; break; + default: break; + } + } +} + +/* shared translate helpers (encounter_translate_movement/prayer/target) + live in osrs_encounter.h so encounter headers can use them directly. */ + +/* ======================================================================== */ +/* visual feedback drawing */ +/* ======================================================================== */ + +/* click cross sprite textures: 4 yellow (move) + 4 red (attack) animation frames. + loaded from data/sprites/gui/cross_*.png, indexed [0..3] yellow, [4..7] red. */ +#define CLICK_CROSS_NUM_FRAMES 4 +#define CLICK_CROSS_ANIM_TICKS 20 /* total animation duration in client ticks (50Hz) */ + +/** Draw click cross at screen-space position using sprite animation. + cross_sprites must point to 8 loaded Texture2D (4 yellow + 4 red). + Falls back to line drawing if sprites aren't loaded. */ +static void human_draw_click_cross(HumanInput* hi, Texture2D* cross_sprites, int sprites_loaded) { + if (!hi->click_cross_active) return; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + return; + } + + int frame = hi->click_cross_timer * CLICK_CROSS_NUM_FRAMES / CLICK_CROSS_ANIM_TICKS; + if (frame >= CLICK_CROSS_NUM_FRAMES) frame = CLICK_CROSS_NUM_FRAMES - 1; + int sprite_idx = hi->click_is_attack ? frame + CLICK_CROSS_NUM_FRAMES : frame; + + int cx = hi->click_screen_x; + int cy = hi->click_screen_y; + + if (sprites_loaded && cross_sprites[sprite_idx].id > 0) { + Texture2D tex = cross_sprites[sprite_idx]; + /* center sprite on click position (OSRS draws at mouseX-8, mouseY-8 for 16px) */ + DrawTexture(tex, cx - tex.width / 2, cy - tex.height / 2, WHITE); + } else { + /* fallback: simple X lines */ + float progress = 1.0f - (float)hi->click_cross_timer / CLICK_CROSS_ANIM_TICKS; + int alpha = (int)(progress * 255); + Color c = hi->click_is_attack + ? CLITERAL(Color){ 255, 50, 50, (unsigned char)alpha } + : CLITERAL(Color){ 255, 255, 0, (unsigned char)alpha }; + DrawLine(cx - 6, cy - 6, cx + 6, cy + 6, c); + DrawLine(cx + 6, cy - 6, cx - 6, cy + 6, c); + } +} + +/** Draw HUD indicators for human control mode. + Call from the header rendering section. */ +static void human_draw_hud(HumanInput* hi) { + if (!hi->enabled) return; + + /* "HUMAN" indicator in header */ + DrawText("HUMAN", 8, 8, 16, YELLOW); + + /* spell targeting mode indicator */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + const char* spell = (hi->selected_spell == ATTACK_ICE) ? "[ICE]" : + (hi->selected_spell == ATTACK_BLOOD) ? "[BLOOD]" : "[SPELL]"; + DrawText(spell, 80, 8, 14, CLITERAL(Color){100, 200, 255, 255}); + } +} + +/** Tick the click cross animation timer. Call at 50Hz (client tick rate). */ +static void human_tick_visuals(HumanInput* hi) { + if (hi->click_cross_active) { + hi->click_cross_timer++; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + } + } +} + +#endif /* OSRS_HUMAN_INPUT_H */ diff --git a/src/osrs/osrs_human_input_types.h b/src/osrs/osrs_human_input_types.h new file mode 100644 index 0000000000..7cb50678bf --- /dev/null +++ b/src/osrs/osrs_human_input_types.h @@ -0,0 +1,43 @@ +/** + * @file osrs_pvp_human_input_types.h + * @brief HumanInput struct and CursorMode enum — separated from human_input.h + * to break circular include dependency (gui.h needs HumanInput, but + * human_input.h needs gui.h for prayer/spell grid constants). + */ + +#ifndef OSRS_HUMAN_INPUT_TYPES_H +#define OSRS_HUMAN_INPUT_TYPES_H + +typedef enum { + CURSOR_NORMAL = 0, + CURSOR_SPELL_TARGET, /* clicked a combat spell, waiting for target click */ +} CursorMode; + +typedef struct HumanInput { + int enabled; /* H key toggle: 1 = human controls active */ + + /* semantic action staging (set by clicks, consumed at tick boundary) */ + int pending_move_x, pending_move_y; /* world tile coords, -1 = none */ + int pending_attack; /* 1 = attack target entity */ + int pending_prayer; /* OverheadAction value, -1 = no change */ + int pending_offensive_prayer; /* 0=none, 1=piety, 2=rigour, 3=augury, -1=no change */ + int pending_food; /* 1 = eat food */ + int pending_karambwan; /* 1 = eat karambwan */ + int pending_potion; /* PotionAction value, 0 = none */ + int pending_veng; /* 1 = cast vengeance */ + int pending_spec; /* 1 = use special attack */ + int pending_spell; /* 0=none, ATTACK_ICE or ATTACK_BLOOD */ + int pending_target_idx; /* NPC entity index to attack, -1 = none */ + int pending_gear; /* gear switch action value, 0 = none */ + + CursorMode cursor_mode; + int selected_spell; /* ATTACK_ICE or ATTACK_BLOOD for targeting */ + + /* visual feedback: click cross at screen-space position (like real OSRS client) */ + int click_screen_x, click_screen_y; /* screen pixel where click occurred */ + int click_cross_timer; /* counts up from 0, animation progresses over time */ + int click_cross_active; /* 1 = cross is visible */ + int click_is_attack; /* 1 = red cross (attack), 0 = yellow cross (move) */ +} HumanInput; + +#endif /* OSRS_HUMAN_INPUT_TYPES_H */ diff --git a/src/osrs/osrs_interaction.h b/src/osrs/osrs_interaction.h new file mode 100644 index 0000000000..0d79928ac8 --- /dev/null +++ b/src/osrs/osrs_interaction.h @@ -0,0 +1,97 @@ +/** + * @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 + +/* ======================================================================== */ +/* interaction state */ +/* ======================================================================== */ + +typedef struct { + int target_slot; /* target entity slot index, -1 = no interaction */ +} OsrsInteraction; + +/* ======================================================================== */ +/* interaction management */ +/* ======================================================================== */ + +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; +} + +/* ======================================================================== */ +/* action type constants */ +/* ======================================================================== */ + +#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) */ + +/* ======================================================================== */ +/* interrupt checking */ +/* ======================================================================== */ + +/* 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 armed state helpers */ +/* ======================================================================== */ + +/* 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/src/osrs/osrs_inventory.h b/src/osrs/osrs_inventory.h new file mode 100644 index 0000000000..a39b908323 --- /dev/null +++ b/src/osrs/osrs_inventory.h @@ -0,0 +1,193 @@ +/** + * @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; + +/* ======================================================================== */ +/* inventory management */ +/* ======================================================================== */ + +/** 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; +} + +/* ======================================================================== */ +/* gear slot mapping */ +/* ======================================================================== */ + +/** 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; + } +} + +/* ======================================================================== */ +/* equipment management */ +/* ======================================================================== */ + +/** 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/src/osrs/osrs_items.h b/src/osrs/osrs_items.h new file mode 100644 index 0000000000..85180d360e --- /dev/null +++ b/src/osrs/osrs_items.h @@ -0,0 +1,316 @@ +/** + * @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 + +// ============================================================================ +// EQUIPMENT SLOTS +// ============================================================================ + +typedef enum { + SLOT_HEAD = 0, + SLOT_CAPE = 1, + SLOT_NECK = 2, + SLOT_WEAPON = 3, + SLOT_BODY = 4, + SLOT_SHIELD = 5, + SLOT_LEGS = 6, + SLOT_HANDS = 7, + SLOT_FEET = 8, + SLOT_RING = 9, + SLOT_AMMO = 10, + NUM_EQUIPMENT_SLOTS = 11 +} EquipmentSlot; + +// ============================================================================ +// ITEM STRUCT +// ============================================================================ + +typedef struct { + uint16_t item_id; // Real OSRS item ID + char name[32]; // Human-readable name + uint8_t slot; // Equipment slot (EquipmentSlot enum) + uint8_t attack_speed; // Weapon attack speed (ticks) + uint8_t attack_range; // Weapon attack range (tiles) + int16_t attack_stab; + int16_t attack_slash; + int16_t attack_crush; + int16_t attack_magic; + int16_t attack_ranged; + int16_t defence_stab; + int16_t defence_slash; + int16_t defence_crush; + int16_t defence_magic; + int16_t defence_ranged; + int16_t melee_strength; + int16_t ranged_strength; + int16_t magic_damage; // Magic damage % bonus + int16_t prayer; +} Item; + +// ============================================================================ +// ITEM DATABASE INDICES + STATIC DATABASE (auto-generated from equipment.json) +// ============================================================================ + +#include "osrs_items_generated.h" + +// ============================================================================ +// LOOKUP TABLES +// ============================================================================ + +// Max items per slot (inventory width for dynamic gear) +#define MAX_ITEMS_PER_SLOT_DB 10 + +// Items available per slot (for masking and inventory) +// 255 = end marker (slot has fewer than MAX_ITEMS_PER_SLOT_DB options) +static const uint8_t ITEMS_BY_SLOT[NUM_EQUIPMENT_SLOTS][MAX_ITEMS_PER_SLOT_DB] = { + [SLOT_HEAD] = {ITEM_HELM_NEITIZNOT, ITEM_ANCESTRAL_HAT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_CAPE] = {ITEM_GOD_CAPE, ITEM_INFERNAL_CAPE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_NECK] = {ITEM_GLORY, ITEM_FURY, ITEM_OCCULT_NECKLACE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_WEAPON] = {ITEM_WHIP, ITEM_RUNE_CROSSBOW, ITEM_AHRIM_STAFF, ITEM_DRAGON_DAGGER, + ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_STAFF_OF_DEAD, ITEM_KODAI_WAND, + ITEM_VOLATILE_STAFF, ITEM_ZURIELS_STAFF}, + [SLOT_BODY] = {ITEM_BLACK_DHIDE_BODY, ITEM_MYSTIC_TOP, ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, + ITEM_KARILS_TOP, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_SHIELD] = {ITEM_DRAGON_DEFENDER, ITEM_SPIRIT_SHIELD, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_MAGES_BOOK, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_LEGS] = {ITEM_RUNE_PLATELEGS, ITEM_MYSTIC_BOTTOM, ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_NONE, ITEM_NONE}, + [SLOT_HANDS] = {ITEM_BARROWS_GLOVES, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_FEET] = {ITEM_CLIMBING_BOOTS, ITEM_ETERNAL_BOOTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_RING] = {ITEM_BERSERKER_RING, ITEM_SEERS_RING_I, ITEM_LIGHTBEARER, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_AMMO] = {ITEM_DIAMOND_BOLTS_E, ITEM_DRAGON_ARROWS, ITEM_OPAL_DRAGON_BOLTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, +}; + +// Number of items per slot in the static DB table above +static const uint8_t NUM_ITEMS_IN_SLOT[NUM_EQUIPMENT_SLOTS] = { + [SLOT_HEAD] = 6, // neitiznot, ancestral hat, torag/dharok/verac/guthan helms + [SLOT_CAPE] = 2, // god cape, infernal + [SLOT_NECK] = 3, // glory, fury, occult + [SLOT_WEAPON] = 10, // whip, rcb, ahrim, dds, rapier, inq mace, sotd, kodai, volatile, zuriel + [SLOT_BODY] = 5, // dhide, mystic, ancestral, ahrim, karil + [SLOT_SHIELD] = 4, // defender, spirit, blessed spirit, mages book + [SLOT_LEGS] = 8, // rune, mystic, ancestral, ahrim, bandos, torag/dharok/verac legs + [SLOT_HANDS] = 1, // barrows gloves + [SLOT_FEET] = 2, // climbing boots, eternal boots + [SLOT_RING] = 3, // berserker, seers (i), lightbearer + [SLOT_AMMO] = 3, // diamond bolts (e), dragon arrows, opal dragon bolts (e) +}; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Get item from database by index. Returns NULL if invalid. */ +static inline const Item* get_item(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return NULL; + return &ITEM_DATABASE[item_index]; +} + +/** Check if item is a weapon. */ +static inline int item_is_weapon(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_WEAPON; +} + +/** Check if item is a shield. */ +static inline int item_is_shield(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_SHIELD; +} + +/** Get attack style for a weapon item (1=melee, 2=ranged, 3=magic). */ +static inline int get_item_attack_style(uint8_t item_index) { + switch (item_index) { + // Melee weapons + case ITEM_WHIP: + case ITEM_DRAGON_DAGGER: + case ITEM_GHRAZI_RAPIER: + case ITEM_INQUISITORS_MACE: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + return 1; // ATTACK_STYLE_MELEE + // Ranged weapons + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_MORRIGANS_JAVELIN: + return 2; // ATTACK_STYLE_RANGED + // Magic weapons + case ITEM_AHRIM_STAFF: + case ITEM_STAFF_OF_DEAD: + case ITEM_KODAI_WAND: + case ITEM_VOLATILE_STAFF: + case ITEM_ZURIELS_STAFF: + return 3; // ATTACK_STYLE_MAGIC + default: + return 0; // ATTACK_STYLE_NONE + } +} + +/** Check if weapon is two-handed. */ +static inline int item_is_two_handed(uint8_t item_index) { + switch (item_index) { + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_DRAGON_CLAWS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// ITEM STATS EXTRACTION (for observations) +// ============================================================================ + +/** Normalization constants for item stats (max observed values in game). */ +#define STAT_NORM_ATTACK 150.0f +#define STAT_NORM_DEFENCE 100.0f +#define STAT_NORM_STRENGTH 150.0f +#define STAT_NORM_MAGIC_DMG 30.0f +#define STAT_NORM_PRAYER 10.0f +#define STAT_NORM_SPEED 10.0f +#define STAT_NORM_RANGE 15.0f + +/** + * Extract normalized item stats for observations. + * + * Writes 18 floats to output buffer: + * [0-4] attack bonuses (stab, slash, crush, magic, ranged) + * [5-9] defence bonuses (stab, slash, crush, magic, ranged) + * [10-12] strength bonuses (melee, ranged, magic damage %) + * [13] prayer bonus + * [14] attack speed + * [15] attack range + * [16] is_weapon flag (for quick filtering) + * [17] is_empty flag (1 if item_index >= NUM_ITEMS) + * + * @param item_index Item database index + * @param out Output buffer (must have space for 18 floats) + */ +static inline void get_item_stats_normalized(uint8_t item_index, float* out) { + if (item_index >= NUM_ITEMS) { + // Empty slot - all zeros except is_empty flag + for (int i = 0; i < 17; i++) out[i] = 0.0f; + out[17] = 1.0f; // is_empty + return; + } + + const Item* item = &ITEM_DATABASE[item_index]; + + // Attack bonuses (normalized by max expected values) + out[0] = (float)item->attack_stab / STAT_NORM_ATTACK; + out[1] = (float)item->attack_slash / STAT_NORM_ATTACK; + out[2] = (float)item->attack_crush / STAT_NORM_ATTACK; + out[3] = (float)item->attack_magic / STAT_NORM_ATTACK; + out[4] = (float)item->attack_ranged / STAT_NORM_ATTACK; + + // Defence bonuses + out[5] = (float)item->defence_stab / STAT_NORM_DEFENCE; + out[6] = (float)item->defence_slash / STAT_NORM_DEFENCE; + out[7] = (float)item->defence_crush / STAT_NORM_DEFENCE; + out[8] = (float)item->defence_magic / STAT_NORM_DEFENCE; + out[9] = (float)item->defence_ranged / STAT_NORM_DEFENCE; + + // Strength bonuses + out[10] = (float)item->melee_strength / STAT_NORM_STRENGTH; + out[11] = (float)item->ranged_strength / STAT_NORM_STRENGTH; + out[12] = (float)item->magic_damage / STAT_NORM_MAGIC_DMG; + + // Other bonuses + out[13] = (float)item->prayer / STAT_NORM_PRAYER; + out[14] = (float)item->attack_speed / STAT_NORM_SPEED; + out[15] = (float)item->attack_range / STAT_NORM_RANGE; + + // Flags + out[16] = (item->slot == SLOT_WEAPON) ? 1.0f : 0.0f; + out[17] = 0.0f; // not empty +} + +/** + * Get item index from slot inventory. + * + * Maps GearSlotIndex to EquipmentSlot and looks up item. + * Returns 255 (ITEM_NONE) if slot doesn't have that item. + */ +static inline uint8_t get_item_for_slot(int gear_slot, int item_idx) { + if (item_idx < 0 || item_idx >= MAX_ITEMS_PER_SLOT_DB) return ITEM_NONE; + + // Map GearSlotIndex to EquipmentSlot (they're slightly different) + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; // GEAR_SLOT_HEAD + case 1: eq_slot = SLOT_CAPE; break; // GEAR_SLOT_CAPE + case 2: eq_slot = SLOT_NECK; break; // GEAR_SLOT_NECK + case 3: return ITEM_NONE; // GEAR_SLOT_AMMO (not in LMS) + case 4: eq_slot = SLOT_WEAPON; break; // GEAR_SLOT_WEAPON + case 5: eq_slot = SLOT_SHIELD; break; // GEAR_SLOT_SHIELD + case 6: eq_slot = SLOT_BODY; break; // GEAR_SLOT_BODY + case 7: eq_slot = SLOT_LEGS; break; // GEAR_SLOT_LEGS + case 8: eq_slot = SLOT_HANDS; break; // GEAR_SLOT_HANDS + case 9: eq_slot = SLOT_FEET; break; // GEAR_SLOT_FEET + case 10: eq_slot = SLOT_RING; break; // GEAR_SLOT_RING + default: return ITEM_NONE; + } + + return ITEMS_BY_SLOT[eq_slot][item_idx]; +} + +/** + * Get number of available items for a gear slot. + */ +static inline int get_num_items_for_slot(int gear_slot) { + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; + case 1: eq_slot = SLOT_CAPE; break; + case 2: eq_slot = SLOT_NECK; break; + case 3: return 0; // AMMO + case 4: eq_slot = SLOT_WEAPON; break; + case 5: eq_slot = SLOT_SHIELD; break; + case 6: eq_slot = SLOT_BODY; break; + case 7: eq_slot = SLOT_LEGS; break; + case 8: eq_slot = SLOT_HANDS; break; + case 9: eq_slot = SLOT_FEET; break; + case 10: eq_slot = SLOT_RING; break; + default: return 0; + } + return NUM_ITEMS_IN_SLOT[eq_slot]; +} + +#endif // OSRS_ITEMS_H diff --git a/src/osrs/osrs_items_generated.h b/src/osrs/osrs_items_generated.h new file mode 100644 index 0000000000..a4ad6b0ec3 --- /dev/null +++ b/src/osrs/osrs_items_generated.h @@ -0,0 +1,1350 @@ +/** + * @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_DRAGONFIRE_SHIELD = 132, /* Dragonfire shield */ + NUM_ITEMS = 133, + 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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, + [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 + }, +}; + +#endif /* OSRS_ITEMS_GENERATED_H */ diff --git a/src/osrs/osrs_models.h b/src/osrs/osrs_models.h new file mode 100644 index 0000000000..6233520c57 --- /dev/null +++ b/src/osrs/osrs_models.h @@ -0,0 +1,213 @@ +/** + * @fileoverview Loads OSRS 3D models from .models v2 binary and converts to raylib meshes. + * + * Binary format produced by scripts/export_models.py (MDL2): + * header: uint32 magic ("MDL2"), uint32 count, uint32 offsets[count] + * per model: + * uint32 model_id + * uint16 expanded_vert_count (face_count * 3) + * uint16 face_count + * uint16 base_vert_count (original indexed vertex count) + * float expanded_verts[expanded_vert_count * 3] + * uint8 colors[expanded_vert_count * 4] + * int16 base_verts[base_vert_count * 3] (original OSRS coords, y NOT negated) + * uint8 vertex_skins[base_vert_count] (label group per vertex for animation) + * uint16 face_indices[face_count * 3] (a,b,c per face into base verts) + * + * Expanded vertices + colors are used directly by raylib Mesh for rendering. + * Base vertices, skins, and face indices are used by the animation system to + * transform the original geometry and re-expand for GPU upload. + */ + +#ifndef OSRS_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; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static ModelCache* model_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "model_cache_load: cannot open %s\n", path); + return NULL; + } + + /* read header */ + uint32_t magic, count; + fread(&magic, 4, 1, f); + fread(&count, 4, 1, f); + + if (magic != MDL2_MAGIC) { + fprintf(stderr, "model_cache_load: bad magic 0x%08X (expected MDL2 0x%08X)\n", + magic, MDL2_MAGIC); + fclose(f); + return NULL; + } + + /* read offset table */ + uint32_t* offsets = (uint32_t*)malloc(count * sizeof(uint32_t)); + fread(offsets, 4, count, f); + + ModelCache* cache = (ModelCache*)calloc(1, sizeof(ModelCache)); + cache->models = (OsrsModel*)calloc(count, sizeof(OsrsModel)); + cache->count = (int)count; + + for (uint32_t i = 0; i < count; i++) { + fseek(f, (long)offsets[i], SEEK_SET); + + uint32_t model_id; + uint16_t vert_count, face_count, base_vert_count; + fread(&model_id, 4, 1, f); + fread(&vert_count, 2, 1, f); + fread(&face_count, 2, 1, f); + fread(&base_vert_count, 2, 1, f); + + cache->models[i].model_id = model_id; + cache->models[i].base_vert_count = base_vert_count; + + /* allocate raylib mesh for expanded rendering geometry */ + Mesh mesh = { 0 }; + mesh.vertexCount = vert_count; + mesh.triangleCount = face_count; + + mesh.vertices = (float*)RL_MALLOC(vert_count * 3 * sizeof(float)); + mesh.colors = (unsigned char*)RL_MALLOC(vert_count * 4); + + fread(mesh.vertices, sizeof(float), vert_count * 3, f); + fread(mesh.colors, 1, vert_count * 4, f); + + /* read animation data */ + cache->models[i].base_vertices = (int16_t*)malloc(base_vert_count * 3 * sizeof(int16_t)); + fread(cache->models[i].base_vertices, sizeof(int16_t), base_vert_count * 3, f); + + cache->models[i].vertex_skins = (uint8_t*)malloc(base_vert_count); + fread(cache->models[i].vertex_skins, 1, base_vert_count, f); + + cache->models[i].face_indices = (uint16_t*)malloc(face_count * 3 * sizeof(uint16_t)); + fread(cache->models[i].face_indices, sizeof(uint16_t), face_count * 3, f); + + cache->models[i].face_priorities = (uint8_t*)malloc(face_count); + fread(cache->models[i].face_priorities, 1, face_count, f); + + /* compute min priority for this model */ + uint8_t min_pri = 255; + for (uint16_t fp = 0; fp < face_count; fp++) { + if (cache->models[i].face_priorities[fp] < min_pri) + min_pri = cache->models[i].face_priorities[fp]; + } + cache->models[i].min_priority = min_pri; + + /* upload to GPU */ + UploadMesh(&mesh, false); + cache->models[i].mesh = mesh; + cache->models[i].model = LoadModelFromMesh(mesh); + } + + free(offsets); + fclose(f); + + fprintf(stderr, "model_cache_load: loaded %d models from %s\n", cache->count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +static OsrsModel* model_cache_get(ModelCache* cache, uint32_t model_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->count; i++) { + if (cache->models[i].model_id == model_id) { + return &cache->models[i]; + } + } + return NULL; +} + +/** + * Find the inv_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_inv_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].inv_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Find the wield_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_wield_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].wield_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Check if a body item provides its own arm model (has sleeves). + * When true, the default arm body parts should be hidden. + */ +static int item_has_sleeves(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].has_sleeves; + } + } + return 0; +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +static void model_cache_free(ModelCache* cache) { + if (!cache) return; + for (int i = 0; i < cache->count; i++) { + UnloadModel(cache->models[i].model); + /* UnloadModel already frees the mesh */ + free(cache->models[i].base_vertices); + free(cache->models[i].vertex_skins); + free(cache->models[i].face_indices); + free(cache->models[i].face_priorities); + } + free(cache->models); + free(cache); +} + +#endif /* OSRS_MODELS_H */ diff --git a/src/osrs/osrs_monsters_generated.h b/src/osrs/osrs_monsters_generated.h new file mode 100644 index 0000000000..16e7f10f4e --- /dev/null +++ b/src/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 = 0, + .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/src/osrs/osrs_objects.h b/src/osrs/osrs_objects.h new file mode 100644 index 0000000000..de37463567 --- /dev/null +++ b/src/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/src/osrs/osrs_pathfinding.h b/src/osrs/osrs_pathfinding.h new file mode 100644 index 0000000000..d430dc302e --- /dev/null +++ b/src/osrs/osrs_pathfinding.h @@ -0,0 +1,515 @@ +/** + * @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 — arena-sized (48x48 max = ~9KB each vs 104x104 = ~43KB) */ + int via[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; + int cost[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; + memset(via, 0, sizeof(via)); + memset(cost, 0, sizeof(cost)); + + int queue_x[PATHFIND_MAX_QUEUE_ARENA]; + int queue_y[PATHFIND_MAX_QUEUE_ARENA]; + int head = 0, tail = 0; + + 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; + + 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 = cost[cur_x][cur_y] + 1; + + #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) + + /* south */ + 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; + } + /* west */ + 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; + } + /* north */ + if (cur_y < arena_h - 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; + } + /* east */ + if (cur_x < arena_w - 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; + } + /* 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; + } + /* north-west */ + if (cur_x > 0 && cur_y < arena_h - 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; + } + /* south-east */ + if (cur_x < arena_w - 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; + } + /* north-east */ + if (cur_x < arena_w - 1 && cur_y < arena_h - 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: 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 (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 && 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; + 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 = 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 (via[cur_x][cur_y] == VIA_NONE || via[cur_x][cur_y] == VIA_START) break; + } + + return result; +} + +#endif /* OSRS_PATHFINDING_H */ diff --git a/src/osrs/osrs_pvp_actions.h b/src/osrs/osrs_pvp_actions.h new file mode 100644 index 0000000000..a0cb01586d --- /dev/null +++ b/src/osrs/osrs_pvp_actions.h @@ -0,0 +1,1043 @@ +/** + * @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. + +// ============================================================================ +// PRAYER DRAIN +// ============================================================================ +// overhead drain: use encounter_prayer_drain_effect() from osrs_encounter.h. +// offensive drain: get_offensive_drain_effect() below. +// drain math: encounter_drain_prayer() from osrs_encounter.h. + +// NH gear prayer bonus: fury amulet +3, neitiznot helm +3 = 6 total. +// hardcoded because these are always equipped regardless of gear set. +#define PRAYER_BONUS 6 + +/** get drain effect for an offensive prayer (PvP only — encounters don't use these yet). */ +static inline int get_offensive_drain_effect(OffensivePrayer prayer) { + switch (prayer) { + case OFFENSIVE_PRAYER_NONE: return 0; + case OFFENSIVE_PRAYER_MELEE_LOW: return 6; /* 1 point per 6 seconds */ + case OFFENSIVE_PRAYER_RANGED_LOW: return 6; + case OFFENSIVE_PRAYER_MAGIC_LOW: return 6; + case OFFENSIVE_PRAYER_PIETY: return 24; /* 1 point per 1.5 seconds */ + case OFFENSIVE_PRAYER_RIGOUR: return 24; + case OFFENSIVE_PRAYER_AUGURY: return 24; + default: return 0; + } +} + +// ============================================================================ +// CONSUMABLE ACTIONS +// ============================================================================ + +/** + * Eat food (regular or karambwan). + * + * Food: heals based on HP level (capped at 22), no overheal. + * Karambwan: heals 18, shares timer with food. + * + * @param p Player eating + * @param is_karambwan 1 for karambwan, 0 for regular food + */ +static void eat_food(Player* p, int is_karambwan) { + if (is_karambwan) { + if (p->karambwan_count <= 0 || p->karambwan_timer > 0) return; + p->karambwan_count--; + int hp_before = p->current_hitpoints; + int heal_amount = 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; +} + +// ============================================================================ +// TIMER UPDATES +// ============================================================================ + +/** Update all per-tick timers for a player. */ +static void update_timers(Player* p) { + p->damage_applied_this_tick = 0; + + if (p->has_attack_timer) { + p->attack_timer_uncapped -= 1; + if (p->attack_timer >= 0) { + p->attack_timer -= 1; + } + } + // food/potion/karambwan timers are decremented AFTER execute_switches in c_step + // so that observations show the correct countdown (2, 1, Ready instead of 3, 2, 1) + if (p->frozen_ticks > 0) p->frozen_ticks--; + if (p->freeze_immunity_ticks > 0) p->freeze_immunity_ticks--; + if (p->veng_cooldown > 0) p->veng_cooldown--; + + /* prayer drain — uses shared encounter_drain_prayer for the counter math. + LMS has no prayer drain (prayer points are unlimited). */ + if (p->current_prayer > 0 && !p->is_lms) { + int drain_effect = encounter_prayer_drain_effect(p->prayer) + + get_offensive_drain_effect(p->offensive_prayer); + encounter_drain_prayer(&p->current_prayer, &p->prayer, + PRAYER_BONUS, &p->prayer_drain_counter, drain_effect); + /* shared function deactivates overhead prayer; PvP also needs to + deactivate offensive prayer when prayer points run out. */ + if (p->current_prayer <= 0) + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + } + + if (p->run_energy < 100 && (!p->is_moving || !p->is_running)) { + p->run_recovery_ticks += 1; + if (p->run_recovery_ticks >= RUN_ENERGY_RECOVER_TICKS) { + p->run_energy = clamp(p->run_energy + 1, 0, 100); + p->run_recovery_ticks = 0; + } + } else { + p->run_recovery_ticks = 0; + } + + int has_lightbearer = is_lightbearer_equipped(p); + if (has_lightbearer != p->was_lightbearer_equipped) { + if (has_lightbearer) { + if (p->special_regen_ticks > 25) { + p->special_regen_ticks = 0; + } + } else { + p->special_regen_ticks = 0; + } + p->was_lightbearer_equipped = has_lightbearer; + } + if (p->spec_regen_active && p->special_energy < 100) { + int regen_interval = has_lightbearer ? 25 : 50; + p->special_regen_ticks += 1; + if (p->special_regen_ticks >= regen_interval) { + p->special_energy = clamp(p->special_energy + 10, 0, 100); + p->special_regen_ticks = 0; + } + } else if (p->spec_regen_active) { + p->special_regen_ticks = 0; + } +} + +/** Reset per-tick flags at end of tick. */ +static void reset_tick_flags(Player* p) { + p->just_attacked = 0; + p->last_queued_hit_damage = 0; + p->attack_was_on_prayer = 0; + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + // Reset reward shaping action flags + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->used_special_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->ate_brew_this_tick = 0; + p->cast_veng_this_tick = 0; + p->clicks_this_tick = 0; +} + +// ============================================================================ +// LOADOUT-BASED ACTION EXECUTION +// ============================================================================ + +// Forward declarations for phased execution +static void execute_switches(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; + + // ========================================================================= + // PHASE 1: OVERHEAD PRAYER - must happen first so attacks see new prayer + // ========================================================================= + + int overhead_action = actions[HEAD_OVERHEAD]; + OverheadPrayer prev_prayer = p->prayer; + switch (overhead_action) { + case OVERHEAD_MAGE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MAGIC; + break; + case OVERHEAD_RANGED: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_RANGED; + break; + case OVERHEAD_MELEE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MELEE; + break; + case OVERHEAD_SMITE: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_SMITE; + break; + case OVERHEAD_REDEMPTION: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_REDEMPTION; + break; + } + if (p->prayer != prev_prayer) p->clicks_this_tick++; + + // ========================================================================= + // PHASE 2: LOADOUT SWITCH - equips dynamic gear slots, returns # changed + // ========================================================================= + + int loadout_action = actions[HEAD_LOADOUT]; + 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; + } + + // ========================================================================= + // PHASE 3: AUTO-OFFENSIVE PRAYER + // Loadout determines prayer if switching, attack head is fallback for KEEP + // ========================================================================= + + if (p->current_prayer > 0 && p->base_prayer >= 70) { + AttackStyle pray_style = ATTACK_STYLE_NONE; + if (loadout_action != LOADOUT_KEEP) { + switch (loadout_action) { + case LOADOUT_MELEE: + case LOADOUT_SPEC_MELEE: + case LOADOUT_GMAUL: + pray_style = ATTACK_STYLE_MELEE; + break; + case LOADOUT_RANGE: + case LOADOUT_SPEC_RANGE: + pray_style = ATTACK_STYLE_RANGED; + break; + case LOADOUT_MAGE: + case LOADOUT_TANK: + case LOADOUT_SPEC_MAGIC: + pray_style = ATTACK_STYLE_MAGIC; + break; + } + } else { + int combat_action_val = actions[HEAD_COMBAT]; + pray_style = resolve_attack_style_for_action(p, combat_action_val); + } + switch (pray_style) { + case ATTACK_STYLE_MELEE: + p->offensive_prayer = OFFENSIVE_PRAYER_PIETY; + break; + case ATTACK_STYLE_RANGED: + p->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; + break; + case ATTACK_STYLE_MAGIC: + p->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + break; + default: + break; + } + } + + // ========================================================================= + // PHASE 4: CONSUMABLES - eating delays attack timer + // ========================================================================= + + int food_action = actions[HEAD_FOOD]; + if (food_action == FOOD_EAT && can_eat_food(p)) { + eat_food(p, 0); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + 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); + } + + // ========================================================================= + // PHASE 5: MOVEMENT + // ========================================================================= + + int combat_action = actions[HEAD_COMBAT]; + int is_spec_loadout = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + int move_action = (!is_spec_loadout && is_move_action(combat_action)) + ? combat_action : MOVE_NONE; + + int farcast_dist = 0; + switch (move_action) { + case MOVE_ADJACENT: + process_movement(p, t, 1, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_UNDER: + process_movement(p, t, 2, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_DIAGONAL: + process_movement(p, t, 4, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_FARCAST_2: + case MOVE_FARCAST_3: + case MOVE_FARCAST_4: + case MOVE_FARCAST_5: + case MOVE_FARCAST_6: + case MOVE_FARCAST_7: + farcast_dist = move_action - MOVE_FARCAST_2 + 2; + process_movement(p, t, 3, farcast_dist, cmap); + p->clicks_this_tick++; + break; + default: + break; + } + if (move_action != MOVE_NONE) + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_MOVE); + + // ========================================================================= + // PHASE 6: VENGEANCE + // ========================================================================= + + int veng_action = actions[HEAD_VENG]; + if (veng_action == VENG_CAST && p->is_lunar_spellbook && + !p->veng_active && remaining_ticks(p->veng_cooldown) == 0 && + p->current_magic >= 94) { + p->veng_active = 1; + p->veng_cooldown = 50; + p->cast_veng_this_tick = 1; + p->clicks_this_tick++; + } +} + +/** + * Execute attack-phase actions for an agent (Phase 2). + * + * Processes attacks AFTER all switches have been applied for BOTH players. + * SPEC loadout overrides the ATTACK head (atomic spec = equip + enable + attack). + */ +/** + * Attack movement phase: auto-walk to melee range + step out from same tile. + * Called for ALL players before any attack combat checks, so positions are + * fully resolved before range checks happen (matches OSRS tick processing). + */ +static void execute_attack_movement(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); +} + +// ============================================================================ +// REWARD CALCULATION +// ============================================================================ + +/** + * Calculate reward for an agent. + * + * Sparse terminal signal: + * - +1.0 for win + * - -0.5 for loss + * - 0 for ongoing ticks + * + * When shaping is enabled, adds per-tick reward shaping (scaled by shaping_scale): + * - Damage dealt/received + * - Defensive prayer correctness + * - Off-prayer hits + * - Eating penalties (premature, wasted) + * - Spec timing bonuses + * - Bad behavior penalties (melee frozen, magic no staff) + * - Terminal shaping (KO bonus, wasted resources penalty) + */ +static float calculate_reward(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; + } + } + + // NOTE: prayer switch penalty moved above !cfg->enabled gate (always-on). + // Not duplicated here to avoid double-counting when shaping is enabled. + + // Off-prayer hit and offensive prayer checks: we attacked + if (p->just_attacked) { + if (!p->target_prayed_correct) { + tick_shaping += cfg->off_prayer_hit_bonus; + } + + // Bad behavior: melee attack when frozen and out of range + if (p->attack_style_this_tick == ATTACK_STYLE_MELEE + && p->frozen_ticks > 0 && !is_in_melee_range(p, t)) { + tick_shaping += cfg->melee_frozen_penalty; + } + + // Spec timing rewards + if (p->used_special_this_tick) { + // Off prayer: target NOT praying melee + if (t->prayer != PRAYER_PROTECT_MELEE) { + tick_shaping += cfg->spec_off_prayer_bonus; + } + // Low defence: target in mage gear (mystic has no melee def) + AttackStyle target_style = get_slot_weapon_attack_style(t); + if (target_style == ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->spec_low_defence_bonus; + } + // Low HP: target below 50% + float target_hp_pct = (float)t->current_hitpoints / (float)t->base_hitpoints; + if (target_hp_pct < 0.5f) { + tick_shaping += cfg->spec_low_hp_bonus; + } + } + + // Bad behavior: magic attack without staff equipped + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + AttackStyle weapon_style = get_slot_weapon_attack_style(p); + if (weapon_style != ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->magic_no_staff_penalty; + } + } + + // Gear mismatch penalty: attacking with negative attack bonus for the style + GearBonuses* gear = get_slot_gear_bonuses(p); + int attack_bonus = 0; + switch (p->attack_style_this_tick) { + case ATTACK_STYLE_MAGIC: + attack_bonus = gear->magic_attack; + break; + case ATTACK_STYLE_RANGED: + attack_bonus = gear->ranged_attack; + break; + case ATTACK_STYLE_MELEE: + // Use max of stab/slash/crush for melee + attack_bonus = gear->slash_attack; + if (gear->stab_attack > attack_bonus) attack_bonus = gear->stab_attack; + if (gear->crush_attack > attack_bonus) attack_bonus = gear->crush_attack; + break; + default: + break; + } + if (attack_bonus < 0) { + tick_shaping += cfg->gear_mismatch_penalty; + } + } + + // Eating penalties (not attack-related) + int ate_food = p->ate_food_this_tick; + int ate_karam = p->ate_karambwan_this_tick; + int ate_brew = p->ate_brew_this_tick; + + if (ate_food || ate_karam) { + float hp_before = p->prev_hp_percent; + // Premature eating: penalize eating above threshold + if (hp_before > cfg->premature_eat_threshold) { + tick_shaping += cfg->premature_eat_penalty; + } + // Wasted healing: penalize overflow past max HP + float max_heal; + if (ate_food) { + max_heal = 20.0f / base_hp; // Sharks heal 20 + } else { + max_heal = 18.0f / base_hp; // Karambwan heals 18 + } + float wasted = hp_before + max_heal - 1.0f; + if (wasted > 0.0f) { + float wasted_hp = wasted * base_hp; + tick_shaping += cfg->wasted_eat_penalty * wasted_hp; + } + } + + // Triple eat timing (shark + brew + karam = 54 HP) + if (ate_food && ate_brew && ate_karam) { + float hp_before = p->prev_hp_percent; + float hp_threshold = 45.0f / base_hp; + if (hp_before <= hp_threshold) { + tick_shaping += cfg->smart_triple_eat_bonus; + } else { + float food_brew_heal = (20.0f + 16.0f) / base_hp; + float hp_after_food_brew = hp_before + food_brew_heal; + if (hp_after_food_brew > 1.0f) hp_after_food_brew = 1.0f; + float missing_after = 1.0f - hp_after_food_brew; + float karam_heal_norm = 18.0f / base_hp; + float wasted_karam = karam_heal_norm - missing_after; + if (wasted_karam > 0.0f) { + float wasted_karam_hp = wasted_karam * base_hp; + tick_shaping += cfg->wasted_triple_eat_penalty * wasted_karam_hp; + } + } + } + + reward += tick_shaping * cfg->shaping_scale; + + // KO bonus and wasted resources are in the base terminal reward (not shaped) + + return reward; +} + +#endif // OSRS_PVP_ACTIONS_H diff --git a/src/osrs/osrs_pvp_api.h b/src/osrs/osrs_pvp_api.h new file mode 100644 index 0000000000..4225c67145 --- /dev/null +++ b/src/osrs/osrs_pvp_api.h @@ -0,0 +1,787 @@ +/** + * @file osrs_pvp_api.h + * @brief Public API for OSRS PvP simulation + * + * Provides the public interface for: + * - Player initialization + * - Environment reset (pvp_reset) + * - Environment step (pvp_step) + * - Seeding for deterministic runs (pvp_seed) + * - Cleanup (pvp_close) + */ + +#ifndef OSRS_PVP_API_H +#define OSRS_PVP_API_H + +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" +#include "osrs_pvp_actions.h" + +// ============================================================================ +// PLAYER INITIALIZATION +// ============================================================================ + +/** + * Initialize a player with default pure build stats and gear. + * + * Sets up: + * - Base stats (75 attack, 99 strength, etc.) + * - Current stats equal to base + * - Starting gear (mage setup) + * - Consumables (food, brews, restores) + * - All timers reset to 0 + * - Sequential mode equipment state + * + * @param p Player to initialize + */ +static void init_player(Player* p) { + p->base_attack = MAXED_BASE_ATTACK; + p->base_strength = MAXED_BASE_STRENGTH; + p->base_defence = MAXED_BASE_DEFENCE; + p->base_ranged = MAXED_BASE_RANGED; + p->base_magic = MAXED_BASE_MAGIC; + p->base_prayer = MAXED_BASE_PRAYER; + p->base_hitpoints = MAXED_BASE_HITPOINTS; + + p->current_attack = p->base_attack; + p->current_strength = p->base_strength; + p->current_defence = p->base_defence; + p->current_ranged = p->base_ranged; + p->current_magic = p->base_magic; + p->current_prayer = p->base_prayer; + p->current_hitpoints = p->base_hitpoints; + + p->special_energy = 100; + p->special_regen_ticks = 0; + p->spec_regen_active = 0; + p->was_lightbearer_equipped = 0; + p->spec_armed = 0; + osrs_interaction_init(&p->interaction); + + p->current_gear = GEAR_MAGE; + p->visible_gear = GEAR_MAGE; + + p->food_count = MAXED_FOOD_COUNT; + p->karambwan_count = MAXED_KARAMBWAN_COUNT; + p->brew_doses = MAXED_BREW_DOSES; + p->restore_doses = MAXED_RESTORE_DOSES; + p->combat_potion_doses = MAXED_COMBAT_POTION_DOSES; + p->ranged_potion_doses = MAXED_RANGED_POTION_DOSES; + + p->attack_timer = 0; + p->attack_timer_uncapped = 0; + p->has_attack_timer = 0; + p->food_timer = 0; + p->potion_timer = 0; + p->karambwan_timer = 0; + p->consumable_used_this_tick = 0; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + + p->frozen_ticks = 0; + p->freeze_immunity_ticks = 0; + + p->veng_active = 0; + p->veng_cooldown = 0; + p->recoil_charges = 0; + + p->prayer = PRAYER_NONE; + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + p->fight_style = FIGHT_STYLE_ACCURATE; + p->prayer_drain_counter = 0; + p->morr_dot_remaining = 0; + p->morr_dot_tick_counter = 0; + + p->x = 0; + p->y = 0; + p->dest_x = 0; + p->dest_y = 0; + p->is_moving = 0; + p->is_running = 0; + p->run_energy = 100; + p->run_recovery_ticks = 0; + p->last_obs_target_x = 0; + p->last_obs_target_y = 0; + + p->just_attacked = 0; + p->last_attack_style = ATTACK_STYLE_NONE; + p->attack_was_on_prayer = 0; + p->last_attack_dx = 0; + p->last_attack_dy = 0; + p->last_attack_dist = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + + memset(p->pending_hits, 0, sizeof(p->pending_hits)); + p->num_pending_hits = 0; + p->damage_applied_this_tick = 0; + p->did_attack_auto_move = 0; + + // Hit event tracking + p->hit_landed_this_tick = 0; + p->hit_was_successful = 0; + p->hit_damage = 0; + p->hit_style = ATTACK_STYLE_NONE; + p->hit_defender_prayer = PRAYER_NONE; + p->hit_was_on_prayer = 0; + p->hit_attacker_idx = -1; + p->freeze_applied_this_tick = 0; + + p->last_target_health_percent = 0.0f; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + + p->total_target_hit_count = 0; + p->target_hit_melee_count = 0; + p->target_hit_ranged_count = 0; + p->target_hit_magic_count = 0; + p->target_hit_off_prayer_count = 0; + p->target_hit_correct_count = 0; + + p->total_target_pray_count = 0; + p->target_pray_melee_count = 0; + p->target_pray_ranged_count = 0; + p->target_pray_magic_count = 0; + p->target_pray_correct_count = 0; + + p->player_hit_melee_count = 0; + p->player_hit_ranged_count = 0; + p->player_hit_magic_count = 0; + + p->player_pray_melee_count = 0; + p->player_pray_ranged_count = 0; + p->player_pray_magic_count = 0; + + memset(p->recent_target_attack_styles, 0, sizeof(p->recent_target_attack_styles)); + memset(p->recent_player_attack_styles, 0, sizeof(p->recent_player_attack_styles)); + memset(p->recent_target_prayer_styles, 0, sizeof(p->recent_target_prayer_styles)); + memset(p->recent_player_prayer_styles, 0, sizeof(p->recent_player_prayer_styles)); + memset(p->recent_target_prayer_correct, 0, sizeof(p->recent_target_prayer_correct)); + memset(p->recent_target_hit_correct, 0, sizeof(p->recent_target_hit_correct)); + p->recent_target_attack_index = 0; + p->recent_player_attack_index = 0; + p->recent_target_prayer_index = 0; + p->recent_player_prayer_index = 0; + p->recent_target_prayer_correct_index = 0; + p->recent_target_hit_correct_index = 0; + + p->target_magic_accuracy = -1; + p->target_magic_strength = -1; + p->target_ranged_accuracy = -1; + p->target_ranged_strength = -1; + p->target_melee_accuracy = -1; + p->target_melee_strength = -1; + p->target_magic_gear_magic_defence = -1; + p->target_magic_gear_ranged_defence = -1; + p->target_magic_gear_melee_defence = -1; + p->target_ranged_gear_magic_defence = -1; + p->target_ranged_gear_ranged_defence = -1; + p->target_ranged_gear_melee_defence = -1; + p->target_melee_gear_magic_defence = -1; + p->target_melee_gear_ranged_defence = -1; + p->target_melee_gear_melee_defence = -1; + + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + + p->total_damage_dealt = 0; + p->total_damage_received = 0; + + p->is_lunar_spellbook = 0; + p->observed_target_lunar_spellbook = 0; + p->has_blood_fury = 1; + p->has_dharok = 0; + + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + p->bolt_proc_damage = 0.2f; + p->bolt_ignores_defense = 0; + + p->prev_hp_percent = 1.0f; // Full HP at start +} + +// ============================================================================ +// FIGHT POSITIONING +// ============================================================================ + +/** + * Set initial fight positions for both players. + * + * In seeded mode: deterministic positions. + * Otherwise: random positions within fight area, nearby each other. + * + * @param env Environment + */ +static void set_fight_positions(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; +} + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +/** + * Initialize internal buffer pointers for ocean pattern. + * + * Points observations/actions/rewards/terminals/action_masks at the internal + * _*_buf arrays so game logic writes to local storage. PufferLib shared + * buffers are accessed via ocean_* pointers set by the binding. + * + * @param env Environment to initialize + */ +void pvp_init(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)); +} + +/** + * Render stub (required by PufferLib ocean template). + * When OSRS_VISUAL is defined, osrs_pvp_render.h provides the real implementation. + */ +#ifndef OSRS_VISUAL +void pvp_render(OsrsEnv* env) { + (void)env; +} +#endif + +/** + * Reset the environment to initial state. + * + * Initializes both players, sets fight positions, resets tick counter, + * and generates initial observations. + * + * @param env Environment to reset + */ +void pvp_reset(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 + + // NOTE: is_lms is NOT reset here - it's controlled by set_lms() from Python + // env->is_lms = 0; + 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); + + // Clear action buffers. With immediate application, pending is not used + // for timing - actions are applied in the same step they're input. + // This gives OSRS-correct 1-tick delay: action at tick N → effects at tick N+1. + memset(env->pending_actions, 0, sizeof(env->pending_actions)); + memset(env->last_executed_actions, 0, sizeof(env->last_executed_actions)); + + // 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]); + env->players[i].recoil_charges = + osrs_has_recoil_ring(env->players[i].equipped) ? RECOIL_MAX_CHARGES : 0; + } + + // Reset C-side opponent state for new episode + // Always reset when PFSP is configured (selfplay toggle happens inside opponent_reset) + if (env->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]); + + // ======================================================================== + // PHASE 1: Gather actions from all sources into env->actions + // ======================================================================== + + // Copy model's actions (player 0) or clear if C opponent controls p0 + if (env->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; + + // ======================================================================== + // PHASE 2: Apply actions IMMEDIATELY (not pending from previous step) + // ======================================================================== + + // Copy actions to local arrays for each agent + int actions_p0[NUM_ACTION_HEADS]; + int actions_p1[NUM_ACTION_HEADS]; + memcpy(actions_p0, env->actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy(actions_p1, env->actions + NUM_ACTION_HEADS, NUM_ACTION_HEADS * sizeof(int)); + + // Clamp impossible cross-head combos: + // - MELEE/RANGE/SPEC_MELEE/SPEC_RANGE/GMAUL cannot cast spells + // - MAGE/TANK cannot use ATK (except SPEC_MAGIC which forces ATK internally) + for (int i = 0; i < NUM_AGENTS; i++) { + int* a = (i == 0) ? actions_p0 : actions_p1; + int lo = a[HEAD_LOADOUT]; + int cv = a[HEAD_COMBAT]; + if (lo == LOADOUT_MAGE || lo == LOADOUT_TANK || lo == LOADOUT_SPEC_MAGIC) { + if (cv == ATTACK_ATK) { + a[HEAD_COMBAT] = ATTACK_NONE; + } + } + } + + // Write clamped actions back for recording and read functions + memcpy(env->actions, actions_p0, NUM_ACTION_HEADS * sizeof(int)); + memcpy(env->actions + NUM_ACTION_HEADS, actions_p1, NUM_ACTION_HEADS * sizeof(int)); + + // Save executed actions for recording + memcpy( + env->last_executed_actions, + env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int) + ); + + update_timers(&env->players[0]); + update_timers(&env->players[1]); + + // Save HP percent BEFORE actions execute (for reward shaping eat checks) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + pi->prev_hp_percent = (float)pi->current_hitpoints / (float)pi->base_hitpoints; + } + + // Resolve local action arrays by PID order + int* agent_actions[NUM_AGENTS]; + agent_actions[0] = actions_p0; + agent_actions[1] = actions_p1; + + // Save positions before movement for walk/run detection + int pre_move_x[NUM_AGENTS], pre_move_y[NUM_AGENTS]; + for (int i = 0; i < NUM_AGENTS; i++) { + pre_move_x[i] = env->players[i].x; + pre_move_y[i] = env->players[i].y; + } + + // CRITICAL: Two-phase execution for correct prayer timing + // Phase 2A: Apply switches (gear, prayer, consumables, movement) for BOTH players + // This ensures attacks will see the correct prayer state + execute_switches(env, first, agent_actions[first]); + execute_switches(env, second, agent_actions[second]); + + // Decrement consumable timers AFTER eating so obs shows correct countdown + // (eat → 2, 1, Ready instead of eat → 3, 2, 1 with Ready never visible) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + if (pi->food_timer > 0) pi->food_timer--; + if (pi->potion_timer > 0) pi->potion_timer--; + if (pi->karambwan_timer > 0) pi->karambwan_timer--; + } + + // Resolve same-tile stacking (OSRS prevents unfrozen players from sharing a tile) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2B: Attack movement for BOTH players (auto-walk to melee range, step-out) + // All movements resolve before any range checks, matching OSRS tick processing. + // This prevents PID-dependent behavior where one player's movement check depends + // on whether the other player has already stepped out. + execute_attack_movement(env, first, agent_actions[first]); + execute_attack_movement(env, second, agent_actions[second]); + + // Resolve same-tile after attack movements (step-out may have caused overlap) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2C: Attack combat for BOTH players (range check + attack + chase) + // dist is recomputed from CURRENT positions after all movements resolved. + execute_attack_combat(env, first, agent_actions[first]); + execute_attack_combat(env, second, agent_actions[second]); + + // Resolve same-tile after attack-phase chase movement + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Compute walk vs run: Chebyshev distance moved this tick + // 1 tile = walk, 2+ tiles = run (OSRS sends 1 waypoint for walk, 2 for run) + for (int i = 0; i < NUM_AGENTS; i++) { + int dx = abs(env->players[i].x - pre_move_x[i]); + int dy = abs(env->players[i].y - pre_move_y[i]); + int dist = (dx > dy) ? dx : dy; + env->players[i].is_running = (dist >= 2) ? 1 : 0; + } + + process_pending_hits(env, 0, 1); + process_pending_hits(env, 1, 0); + + // Morrigan's javelin DoT: 5 HP every 3 ticks from calc tick + for (int i = 0; i < NUM_AGENTS; i++) { + Player* p = &env->players[i]; + if (p->morr_dot_remaining > 0) { + p->morr_dot_tick_counter--; + if (p->morr_dot_tick_counter <= 0) { + int dot_dmg = (p->morr_dot_remaining >= 5) ? 5 : p->morr_dot_remaining; + p->current_hitpoints -= dot_dmg; + p->morr_dot_remaining -= dot_dmg; + p->damage_applied_this_tick += dot_dmg; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + p->morr_dot_tick_counter = 3; + } + } + } + + if (env->players[0].veng_active) { + env->players[1].observed_target_lunar_spellbook = 1; + } + if (env->players[1].veng_active) { + env->players[0].observed_target_lunar_spellbook = 1; + } + + // ======================================================================== + // PHASE 3: Increment tick + // ======================================================================== + env->tick++; + + if (!env->has_rng_seed) { + env->pid_shuffle_countdown--; + if (env->pid_shuffle_countdown <= 0) { + env->pid_holder = 1 - env->pid_holder; + env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks + } + } + + // Keep pending in sync for compatibility (not used for timing anymore) + memcpy(env->pending_actions, env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); + + // ======================================================================== + // PHASE 4: Check win conditions + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + if (env->players[i].current_hitpoints <= 0) { + env->episode_over = 1; + env->winner = 1 - i; + } + } + + // Tick limit: treat timeout as agent 0 loss + if (!env->episode_over && env->tick >= MAX_EPISODE_TICKS) { + env->episode_over = 1; + env->winner = 1; + } + + // ======================================================================== + // PHASE 5: Calculate rewards + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + env->rewards[i] = calculate_reward(env, i); + + if (env->episode_over) { + env->terminals[i] = 1; + } + } + + // Accumulate agent 0's episode return (written to log at episode end) + env->_episode_return += env->rewards[0]; + + // ======================================================================== + // PHASE 6: Generate observations (current state, BEFORE new actions apply) + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + generate_slot_observations(env, i); + if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { + compute_action_masks(env, i); + } + } + + // NOTE: reset_tick_flags() moved to START of pvp_step() so flags survive + // for get_state() to read after step returns + + // Write observations to PufferLib shared buffer + ocean_write_obs(env); + if (env->ocean_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/src/osrs/osrs_pvp_combat.h b/src/osrs/osrs_pvp_combat.h new file mode 100644 index 0000000000..61583d4d08 --- /dev/null +++ b/src/osrs/osrs_pvp_combat.h @@ -0,0 +1,1204 @@ +/** + * @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" + + +// ============================================================================ +// FORWARD DECLARATIONS +// ============================================================================ + +static void register_hit_calculated(OsrsEnv* env, int attacker_idx, int defender_idx, + AttackStyle style, int total_damage); + +// ============================================================================ +// SPEC WEAPON ENUM-TO-ITEM MAPPING +// ============================================================================ + +/* 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; + } +} + +// ============================================================================ +// SPEC WEAPON COSTS (kept for osrs_pvp_observations.h compatibility) +// ============================================================================ + +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; + } +} + +// ============================================================================ +// SPEC WEAPON MULTIPLIERS (kept for osrs_pvp_observations.h compatibility) +// ============================================================================ + +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; + } +} + +// ============================================================================ +// PRAYER MULTIPLIERS +// ============================================================================ + +static inline float get_defence_prayer_mult(Player* p) { + switch (p->offensive_prayer) { + case OFFENSIVE_PRAYER_MELEE_LOW: + case OFFENSIVE_PRAYER_RANGED_LOW: + case OFFENSIVE_PRAYER_MAGIC_LOW: + return 1.15f; + case OFFENSIVE_PRAYER_PIETY: + case OFFENSIVE_PRAYER_RIGOUR: + case OFFENSIVE_PRAYER_AUGURY: + return 1.25f; + default: + return 1.0f; + } +} + +// ============================================================================ +// EFFECTIVE LEVEL ADAPTERS (delegate to osrs_player_eff_level) +// ============================================================================ + +static int calculate_effective_attack(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_attack; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) prayer_mult = 1.20f; + else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) prayer_mult = 1.15f; + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) prayer_mult = 1.20f; + else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) prayer_mult = 1.15f; + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) prayer_mult = 1.25f; + else if (p->offensive_prayer == OFFENSIVE_PRAYER_MAGIC_LOW) prayer_mult = 1.15f; + break; + default: + return 0; + } + + if (style == ATTACK_STYLE_MELEE) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) style_bonus = 3; + else if (p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; + } else if (style == ATTACK_STYLE_RANGED) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) style_bonus = 3; + } + + /* 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; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_strength; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) prayer_mult = 1.23f; + else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) prayer_mult = 1.15f; + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) prayer_mult = 1.23f; + else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) prayer_mult = 1.15f; + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + break; + default: + return 0; + } + + if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_AGGRESSIVE) style_bonus = 3; + else if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; + + 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 = 0; + + if (p->fight_style == FIGHT_STYLE_DEFENSIVE) style_bonus = 3; + else if (p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; + + if (incoming_style == ATTACK_STYLE_MAGIC) { + /* 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); +} + +// ============================================================================ +// ATTACK/DEFENCE BONUS LOOKUPS +// ============================================================================ + +static MeleeBonusType get_melee_bonus_type(Player* p) { + if (p->current_gear == GEAR_SPEC) { + return MELEE_SPEC_BONUS_TYPES[p->melee_spec_weapon]; + } + 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); + } + + /* Dharok set effect: quadratic scaling with missing HP */ + if (p->has_dharok && style == ATTACK_STYLE_MELEE) { + float hp_ratio = 1.0f - ((float)p->current_hitpoints / p->base_hitpoints); + max_hit = (int)(max_hit * (1.0f + hp_ratio * hp_ratio)); + } + + return max_hit; +} + +// ============================================================================ +// MAGIC SPELL HELPERS +// ============================================================================ + +static inline int get_ice_freeze_ticks(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return 32; + if (current_magic >= ICE_BLITZ_LEVEL) return 24; + if (current_magic >= ICE_BURST_LEVEL) return 16; + return 8; +} + +static inline int get_ice_base_hit(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return ICE_BARRAGE_MAX_HIT; + if (current_magic >= ICE_BLITZ_LEVEL) return ICE_BLITZ_MAX_HIT; + if (current_magic >= ICE_BURST_LEVEL) return ICE_BURST_MAX_HIT; + return ICE_RUSH_MAX_HIT; +} + +static inline int get_blood_base_hit(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return BLOOD_BARRAGE_MAX_HIT; + if (current_magic >= BLOOD_BLITZ_LEVEL) return BLOOD_BLITZ_MAX_HIT; + if (current_magic >= BLOOD_BURST_LEVEL) return BLOOD_BURST_MAX_HIT; + return BLOOD_RUSH_MAX_HIT; +} + +static inline int get_blood_heal_percent(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return 25; + if (current_magic >= BLOOD_BLITZ_LEVEL) return 20; + if (current_magic >= BLOOD_BURST_LEVEL) return 15; + return 10; +} + +// ============================================================================ +// PVP HIT DELAY HELPERS +// ============================================================================ + +/* 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); + } +} + +// ============================================================================ +// HIT QUEUE +// ============================================================================ + +static void queue_hit(Player* attacker, Player* defender, int damage, + AttackStyle style, int delay, int is_special, int hit_success, + int freeze_ticks, int heal_percent, int drain_type, int drain_percent, + int flat_heal) { + if (attacker->num_pending_hits >= MAX_PENDING_HITS) return; + + PendingHit* hit = &attacker->pending_hits[attacker->num_pending_hits++]; + hit->damage = damage; + hit->ticks_until_hit = delay; + hit->attack_type = style; + hit->is_special = is_special; + hit->hit_success = hit_success; + hit->freeze_ticks = freeze_ticks; + hit->heal_percent = heal_percent; + hit->drain_type = drain_type; + hit->drain_percent = drain_percent; + hit->flat_heal = flat_heal; + hit->is_morr_bleed = 0; + hit->defender_prayer_at_attack = defender->prayer; + + 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]; + + /* shared damage pipeline: prayer → veng → recoil → smite */ + DamageResult dr = osrs_apply_damage_pipeline( + hit->damage, hit->attack_type, + hit->defender_prayer_at_attack, + /* is_pvp */ 1, + defender->veng_active, + osrs_has_recoil_ring(defender->equipped) && defender->recoil_charges > 0, + attacker->prayer == PRAYER_SMITE && !defender->is_lms + ); + + 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 && defender->recoil_charges > 0) { + int recoil = dr.recoil_damage; + if (recoil > defender->recoil_charges) recoil = defender->recoil_charges; + attacker->current_hitpoints -= recoil; + if (attacker->current_hitpoints < 0) attacker->current_hitpoints = 0; + float recoil_scale = (float)recoil / (float)attacker->base_hitpoints; + attacker->total_damage_received += recoil_scale; + defender->total_damage_dealt += recoil_scale; + attacker->damage_received_scale += recoil_scale; + defender->damage_dealt_scale += recoil_scale; + + /* ring of suffering (i) has infinite charges; ring of recoil shatters */ + if (defender->equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + defender->recoil_charges -= recoil; + if (defender->recoil_charges <= 0) { + defender->recoil_charges = 0; + defender->equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } + + /* 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--; + } + } +} + +// ============================================================================ +// HIT STATISTICS TRACKING +// ============================================================================ + +static inline void push_recent_attack(AttackStyle* buffer, int* index, AttackStyle style) { + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_prayer(AttackStyle* buffer, int* index, OverheadPrayer prayer) { + AttackStyle style = ATTACK_STYLE_NONE; + if (prayer == PRAYER_PROTECT_MAGIC) style = ATTACK_STYLE_MAGIC; + else if (prayer == PRAYER_PROTECT_RANGED) style = ATTACK_STYLE_RANGED; + else if (prayer == PRAYER_PROTECT_MELEE) style = ATTACK_STYLE_MELEE; + if (style == ATTACK_STYLE_NONE) return; + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_bool(int* buffer, int* index, int value) { + buffer[*index] = value ? 1 : 0; + *index = (*index + 1) % HISTORY_SIZE; +} + +static void register_hit_calculated( + 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); + } +} + +// ============================================================================ +// ATTACK AVAILABILITY CHECKS +// ============================================================================ + +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; +} + +// ============================================================================ +// WEAPON RANGE +// ============================================================================ + +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->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/src/osrs/osrs_pvp_effects.h b/src/osrs/osrs_pvp_effects.h new file mode 100644 index 0000000000..396a3135af --- /dev/null +++ b/src/osrs/osrs_pvp_effects.h @@ -0,0 +1,366 @@ +/** + * @fileoverview Visual effect system for spell impacts and projectiles. + * + * Manages animated spotanim effects (ice barrage splash, blood barrage) and + * traveling projectiles (crossbow bolts, ice barrage orb). Each effect has a + * model, animation, position, and lifetime. Projectiles follow parabolic arcs + * matching OSRS SceneProjectile.java trajectory math. + * + * Effects are spawned from game state in render_post_tick and drawn as 3D + * models in the render pipeline. Animation advances at 50 Hz client ticks. + */ + +#ifndef OSRS_PVP_EFFECTS_H +#define OSRS_PVP_EFFECTS_H + +#include "osrs_models.h" +#include "osrs_anim.h" +#include + +#define MAX_ACTIVE_EFFECTS 16 + +/* ======================================================================== */ +/* spotanim metadata (hardcoded for the effects we care about) */ +/* ======================================================================== */ + +/* GFX IDs from spotanim.dat */ +#define GFX_BOLT 27 +#define GFX_SPLASH 85 /* blue splash on spell miss */ +#define GFX_ICE_BARRAGE_PROJ 368 +#define GFX_ICE_BARRAGE_HIT 369 +#define GFX_BLOOD_BARRAGE_HIT 377 +#define GFX_DRAGON_BOLT 1468 + +/* player weapon projectiles (zulrah encounter) */ +#define GFX_TRIDENT_CAST 665 /* casting effect on player */ +#define GFX_TRIDENT_PROJ 1040 /* trident projectile in flight */ +#define GFX_TRIDENT_IMPACT 1042 /* trident hit splash on target */ +#define GFX_RUNE_ARROW 15 /* rune arrow projectile (MSB) */ +#define GFX_DRAGON_DART 1122 /* dragon dart projectile (blowpipe) */ +#define GFX_RUNE_DART 231 /* rune dart projectile */ +#define GFX_BLOWPIPE_SPEC 1043 /* blowpipe special attack effect */ +/* TODO: add voidwaker lightning on-hit GFX (spotanim on opponent). + * TODO: add VLS special attack on-hit effect. + * combat mechanics for both work correctly, just missing visual effects. */ + +typedef struct { + int gfx_id; + uint32_t model_id; + int anim_seq_id; /* -1 = no animation (static model) */ + int resize_xy; /* 128 = 1.0x */ + int resize_z; +} SpotAnimMeta; + +/* parsed from spotanim.dat via export_spotanims.py */ +static const SpotAnimMeta SPOTANIM_TABLE[] = { + { GFX_BOLT, 3135, -1, 128, 128 }, + { GFX_SPLASH, 3080, 653, 128, 128 }, + { GFX_ICE_BARRAGE_PROJ, 14215, 1964, 128, 128 }, + { GFX_ICE_BARRAGE_HIT, 6381, 1965, 128, 128 }, + { GFX_BLOOD_BARRAGE_HIT, 6375, 1967, 128, 128 }, + { GFX_DRAGON_BOLT, 0xD0001, -1, 128, 128 }, /* synthetic recolored model */ + /* player weapon projectiles (zulrah encounter) */ + { GFX_TRIDENT_CAST, 20823, 5460, 128, 128 }, + { GFX_TRIDENT_PROJ, 20825, 5462, 128, 128 }, + { GFX_TRIDENT_IMPACT, 20824, 5461, 128, 128 }, + { GFX_RUNE_ARROW, 3136, -1, 128, 128 }, + { GFX_DRAGON_DART, 26379, 6622, 128, 128 }, + { GFX_RUNE_DART, 3131, -1, 128, 128 }, + { GFX_BLOWPIPE_SPEC, 29421, 876, 128, 128 }, +}; +#define SPOTANIM_TABLE_SIZE (sizeof(SPOTANIM_TABLE) / sizeof(SPOTANIM_TABLE[0])) + +static const SpotAnimMeta* spotanim_lookup(int gfx_id) { + for (int i = 0; i < (int)SPOTANIM_TABLE_SIZE; i++) { + if (SPOTANIM_TABLE[i].gfx_id == gfx_id) return &SPOTANIM_TABLE[i]; + } + return NULL; +} + +/* ======================================================================== */ +/* effect types */ +/* ======================================================================== */ + +typedef enum { + EFFECT_NONE = 0, + EFFECT_SPOTANIM, /* plays at a fixed position (impact effects) */ + EFFECT_PROJECTILE, /* travels from source to target with parabolic arc */ +} EffectType; + +typedef struct { + EffectType type; + int gfx_id; + const SpotAnimMeta* meta; + + /* world position in sub-tile coords (128 units per tile) */ + double src_x, src_y; /* start (projectiles) */ + double dst_x, dst_y; /* end (projectiles) or position (spotanims) */ + double cur_x, cur_y; /* current interpolated position */ + double height; /* current height in sub-tile units */ + + /* projectile trajectory (from SceneProjectile.java) */ + double x_increment; + double y_increment; + double diagonal_increment; + double height_increment; + double height_accel; /* aDouble1578: parabolic curvature */ + int start_height; /* sub-tile units */ + int end_height; + int initial_slope; /* trajectory arc angle */ + + /* timing in client ticks (50 Hz) */ + int start_tick; + int stop_tick; + int started; /* has calculateIncrements been called? */ + + /* animation state */ + int anim_frame; + int anim_tick_counter; + AnimModelState* anim_state; /* per-effect vertex transform state (heap) */ + + /* orientation */ + int turn_value; /* 0-2047 OSRS angle units */ + int tilt_angle; +} ActiveEffect; + +/* ======================================================================== */ +/* internal helpers */ +/* ======================================================================== */ + +/** Free an effect's animation state and mark it inactive. */ +static void effect_free(ActiveEffect* e) { + if (e->anim_state) { + anim_model_state_free(e->anim_state); + e->anim_state = NULL; + } + e->type = EFFECT_NONE; +} + +/** Find a free effect slot, evicting the oldest if full. */ +static int effect_find_slot(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].type == EFFECT_NONE) return i; + } + /* evict oldest */ + int oldest = 0; + for (int i = 1; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].start_tick < effects[oldest].start_tick) oldest = i; + } + effect_free(&effects[oldest]); + return oldest; +} + +/** Create AnimModelState for an effect's model (if it has animation data). */ +static void effect_init_anim_state( + ActiveEffect* e, + ModelCache* model_cache +) { + if (!e->meta || e->meta->anim_seq_id < 0 || !model_cache) return; + + OsrsModel* om = model_cache_get(model_cache, e->meta->model_id); + if (!om || !om->vertex_skins || om->base_vert_count == 0) return; + + e->anim_state = anim_model_state_create( + om->vertex_skins, om->base_vert_count); +} + +/* ======================================================================== */ +/* effect lifecycle */ +/* ======================================================================== */ + +/** + * Spawn a spotanim effect at a world position (impact splash, etc). + * Duration is determined by the animation length, or a fixed 30 client ticks + * for static models. + */ +static int effect_spawn_spotanim_subtile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + float subtile_x, float subtile_y, + int current_client_tick, + AnimCache* anim_cache, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_SPOTANIM; + e->gfx_id = gfx_id; + e->meta = meta; + + e->cur_x = subtile_x; + e->cur_y = subtile_y; + e->height = 0; + + e->start_tick = current_client_tick; + + /* duration from animation, or 30 client ticks default */ + int duration = 30; + if (meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, meta->anim_seq_id); + if (seq) { + duration = 0; + for (int f = 0; f < seq->frame_count; f++) { + duration += seq->frames[f].delay; + } + } + } + e->stop_tick = current_client_tick + duration; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/* convenience wrapper: integer tile coords → sub-tile center */ +static int effect_spawn_spotanim( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, int world_x, int world_y, + int current_client_tick, AnimCache* anim_cache, ModelCache* model_cache +) { + return effect_spawn_spotanim_subtile(effects, gfx_id, + world_x * 128.0f + 64.0f, world_y * 128.0f + 64.0f, + current_client_tick, anim_cache, model_cache); +} + +/** + * Spawn a traveling projectile from source to target position. + * + * Trajectory math from SceneProjectile.java calculateIncrements/progressCycles: + * - parabolic height arc controlled by initialSlope + * - position advances linearly per client tick + * - height has quadratic acceleration term + */ +static int effect_spawn_projectile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + int src_world_x, int src_world_y, + int dst_world_x, int dst_world_y, + int delay_client_ticks, + int duration_client_ticks, + int start_height_subtile, + int end_height_subtile, + int slope, + int current_client_tick, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_PROJECTILE; + e->gfx_id = gfx_id; + e->meta = meta; + + e->src_x = src_world_x * 128.0 + 64.0; + e->src_y = src_world_y * 128.0 + 64.0; + e->dst_x = dst_world_x * 128.0 + 64.0; + e->dst_y = dst_world_y * 128.0 + 64.0; + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->start_height = start_height_subtile; + e->end_height = end_height_subtile; + e->height = start_height_subtile; + e->initial_slope = slope; + e->started = 0; + + e->start_tick = current_client_tick + delay_client_ticks; + e->stop_tick = current_client_tick + delay_client_ticks + duration_client_ticks; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/** + * Advance all active effects by one client tick (20ms). + * Call this from the 50 Hz client-tick loop. + */ +static void effect_client_tick( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int current_client_tick, + AnimCache* anim_cache +) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &effects[i]; + if (e->type == EFFECT_NONE) continue; + + /* expired? */ + if (current_client_tick >= e->stop_tick) { + effect_free(e); + continue; + } + + /* not started yet (delayed projectile) */ + if (current_client_tick < e->start_tick) continue; + + if (e->type == EFFECT_PROJECTILE) { + if (!e->started) { + /* calculateIncrements (SceneProjectile.java:37-58) */ + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->height = e->start_height; + + double cycles_left = (double)(e->stop_tick + 1 - current_client_tick); + e->x_increment = (e->dst_x - e->cur_x) / cycles_left; + e->y_increment = (e->dst_y - e->cur_y) / cycles_left; + e->diagonal_increment = sqrt( + e->x_increment * e->x_increment + + e->y_increment * e->y_increment + ); + + e->height_increment = -e->diagonal_increment * + tan((double)e->initial_slope * 0.02454369); + e->height_accel = 2.0 * ( + (double)e->end_height - e->height - + e->height_increment * cycles_left + ) / (cycles_left * cycles_left); + + e->started = 1; + } + + /* progressCycles (SceneProjectile.java:100-118) */ + e->cur_x += e->x_increment; + e->cur_y += e->y_increment; + e->height += e->height_increment + 0.5 * e->height_accel; + e->height_increment += e->height_accel; + + /* update orientation */ + e->turn_value = (int)(atan2(e->x_increment, e->y_increment) * + 325.949) + 1024; + e->turn_value &= 0x7FF; + e->tilt_angle = (int)(atan2(e->height_increment, + e->diagonal_increment) * 325.949); + e->tilt_angle &= 0x7FF; + } + + /* advance animation */ + if (e->meta && e->meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, e->meta->anim_seq_id); + if (seq && seq->frame_count > 0) { + e->anim_tick_counter++; + while (e->anim_tick_counter >= seq->frames[e->anim_frame].delay) { + e->anim_tick_counter -= seq->frames[e->anim_frame].delay; + e->anim_frame++; + if (e->anim_frame >= seq->frame_count) { + e->anim_frame = 0; + } + } + } + } + } +} + +/** + * Clear all active effects (on episode reset). + */ +static void effect_clear_all(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + effect_free(&effects[i]); + } +} + +#endif /* OSRS_PVP_EFFECTS_H */ diff --git a/src/osrs/osrs_pvp_gear.h b/src/osrs/osrs_pvp_gear.h new file mode 100644 index 0000000000..3d5f98f12e --- /dev/null +++ b/src/osrs/osrs_pvp_gear.h @@ -0,0 +1,1102 @@ +/** + * @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" + +// ============================================================================ +// MELEE SPEC WEAPON BONUS TYPES +// ============================================================================ + +static const MeleeBonusType MELEE_SPEC_BONUS_TYPES[] = { + [MELEE_SPEC_NONE] = MELEE_BONUS_SLASH, + [MELEE_SPEC_AGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_CLAWS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_GRANITE_MAUL] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_DRAGON_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_VOIDWAKER] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DWH] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_BGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ZGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_SGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ANCIENT_GS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_VESTAS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ABYSSAL_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_DRAGON_LONGSWORD] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_MACE] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_ABYSSAL_BLUDGEON] = MELEE_BONUS_CRUSH, +}; + +// ============================================================================ +// WEAPON PRIORITY TABLES (best to worst within each style) +// ============================================================================ + +static const uint8_t MELEE_WEAPON_PRIORITY[] = { + ITEM_VESTAS, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_ELDER_MAUL, + ITEM_VOIDWAKER, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_STATIUS_WARHAMMER, ITEM_WHIP +}; +#define MELEE_WEAPON_PRIORITY_LEN 9 + +static const uint8_t RANGE_WEAPON_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, ITEM_RUNE_CROSSBOW +}; +#define RANGE_WEAPON_PRIORITY_LEN 4 + +static const uint8_t MAGE_WEAPON_PRIORITY[] = { + ITEM_ZURIELS_STAFF, ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF, + ITEM_STAFF_OF_DEAD, ITEM_AHRIM_STAFF +}; +#define MAGE_WEAPON_PRIORITY_LEN 5 + +// ============================================================================ +// SPEC WEAPON PRIORITY TABLES +// ============================================================================ + +static const uint8_t MELEE_SPEC_PRIORITY[] = { + ITEM_VESTAS, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_DRAGON_CLAWS, + ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_DAGGER +}; +#define MELEE_SPEC_PRIORITY_LEN 7 + +static const uint8_t RANGE_SPEC_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_HEAVY_BALLISTA +}; +#define RANGE_SPEC_PRIORITY_LEN 5 + +// Magic spec: only volatile nightmare staff +static const uint8_t MAGIC_SPEC_PRIORITY[] = { + ITEM_VOLATILE_STAFF +}; +#define MAGIC_SPEC_PRIORITY_LEN 1 + +// ============================================================================ +// ARMOR PRIORITY TABLES (per style) +// ============================================================================ + +// Body armor +static const uint8_t TANK_BODY_PRIORITY[] = { + ITEM_KARILS_TOP, ITEM_BLACK_DHIDE_BODY +}; +#define TANK_BODY_PRIORITY_LEN 2 + +static const uint8_t MAGE_BODY_PRIORITY[] = { + ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, ITEM_MYSTIC_TOP +}; +#define MAGE_BODY_PRIORITY_LEN 3 + +// Legs armor +static const uint8_t TANK_LEGS_PRIORITY[] = { + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, + ITEM_VERACS_PLATESKIRT, ITEM_RUNE_PLATELEGS +}; +#define TANK_LEGS_PRIORITY_LEN 5 + +static const uint8_t MAGE_LEGS_PRIORITY[] = { + ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, ITEM_MYSTIC_BOTTOM +}; +#define MAGE_LEGS_PRIORITY_LEN 3 + +// Shield +static const uint8_t MELEE_SHIELD_PRIORITY[] = { + ITEM_DRAGON_DEFENDER +}; +#define MELEE_SHIELD_PRIORITY_LEN 1 + +static const uint8_t TANK_SHIELD_PRIORITY[] = { + ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define TANK_SHIELD_PRIORITY_LEN 2 + +static const uint8_t MAGE_SHIELD_PRIORITY[] = { + ITEM_MAGES_BOOK, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define MAGE_SHIELD_PRIORITY_LEN 3 + +// Head +static const uint8_t TANK_HEAD_PRIORITY[] = { + ITEM_TORAGS_HELM, ITEM_GUTHANS_HELM, ITEM_VERACS_HELM, + ITEM_DHAROKS_HELM, ITEM_HELM_NEITIZNOT +}; +#define TANK_HEAD_PRIORITY_LEN 5 + +static const uint8_t MAGE_HEAD_PRIORITY[] = {ITEM_ANCESTRAL_HAT, ITEM_HELM_NEITIZNOT}; +#define MAGE_HEAD_PRIORITY_LEN 2 + +// Cape +static const uint8_t MELEE_CAPE_PRIORITY[] = {ITEM_INFERNAL_CAPE, ITEM_GOD_CAPE}; +#define MELEE_CAPE_PRIORITY_LEN 2 + +static const uint8_t MAGE_CAPE_PRIORITY[] = {ITEM_GOD_CAPE}; +#define MAGE_CAPE_PRIORITY_LEN 1 + +// Neck +static const uint8_t MELEE_NECK_PRIORITY[] = {ITEM_FURY, ITEM_GLORY}; +#define MELEE_NECK_PRIORITY_LEN 2 + +static const uint8_t MAGE_NECK_PRIORITY[] = {ITEM_OCCULT_NECKLACE, ITEM_GLORY}; +#define MAGE_NECK_PRIORITY_LEN 2 + +// Ring +static const uint8_t MELEE_RING_PRIORITY[] = {ITEM_BERSERKER_RING}; +#define MELEE_RING_PRIORITY_LEN 1 + +static const uint8_t MAGE_RING_PRIORITY[] = {ITEM_LIGHTBEARER, ITEM_SEERS_RING_I, ITEM_BERSERKER_RING}; +#define MAGE_RING_PRIORITY_LEN 3 + +// ============================================================================ +// SLOT-BASED GEAR COMPUTATION FROM EQUIPPED[] ARRAY +// ============================================================================ + +/** + * Compute total gear bonuses from equipped[] array. + * 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); + + GearBonuses total = {0}; + total.stab_attack = eb.attack_stab; + total.slash_attack = eb.attack_slash; + total.crush_attack = eb.attack_crush; + total.magic_attack = eb.attack_magic; + total.ranged_attack = eb.attack_ranged; + total.stab_defence = eb.defence_stab; + total.slash_defence = eb.defence_slash; + total.crush_defence = eb.defence_crush; + total.magic_defence = eb.defence_magic; + total.ranged_defence = eb.defence_ranged; + total.melee_strength = eb.melee_strength; + total.ranged_strength = eb.ranged_strength; + total.magic_strength = eb.magic_damage; + total.attack_speed = eb.attack_speed; + total.attack_range = eb.attack_range; + return total; +} + +/** Get cached slot-based gear bonuses, recomputing if dirty. */ +static inline GearBonuses* get_slot_gear_bonuses(Player* p) { + if (p->slot_gear_dirty) { + p->slot_cached_bonuses = compute_slot_gear_bonuses(p); + p->slot_gear_dirty = 0; + } + return &p->slot_cached_bonuses; +} + +// ============================================================================ +// SPEC WEAPON MAPPING +// ============================================================================ + +/** Set spec weapon enums based on equipped weapon. */ +static inline void update_spec_weapons_for_weapon(Player* p, uint8_t weapon_item) { + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_DAGGER; break; + case ITEM_DRAGON_CLAWS: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_CLAWS; break; + case ITEM_AGS: + p->melee_spec_weapon = MELEE_SPEC_AGS; break; + case ITEM_ANCIENT_GS: + p->melee_spec_weapon = MELEE_SPEC_ANCIENT_GS; break; + case ITEM_GRANITE_MAUL: + p->melee_spec_weapon = MELEE_SPEC_GRANITE_MAUL; break; + case ITEM_VESTAS: + p->melee_spec_weapon = MELEE_SPEC_VESTAS; break; + case ITEM_VOIDWAKER: + p->melee_spec_weapon = MELEE_SPEC_VOIDWAKER; break; + case ITEM_STATIUS_WARHAMMER: + p->melee_spec_weapon = MELEE_SPEC_DWH; break; + case ITEM_ELDER_MAUL: + break; // Elder maul has no spec + case ITEM_DARK_BOW: + p->ranged_spec_weapon = RANGED_SPEC_DARK_BOW; break; + case ITEM_HEAVY_BALLISTA: + p->ranged_spec_weapon = RANGED_SPEC_BALLISTA; break; + case ITEM_ARMADYL_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ACB; break; + case ITEM_ZARYTE_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ZCB; break; + case ITEM_MORRIGANS_JAVELIN: + p->ranged_spec_weapon = RANGED_SPEC_MORRIGANS; break; + case ITEM_VOLATILE_STAFF: + p->magic_spec_weapon = MAGIC_SPEC_VOLATILE_STAFF; break; + default: + break; + } +} + +/** Check if a weapon is a spec weapon (any spec enum becomes non-NONE). */ +static inline int item_is_spec_weapon(uint8_t weapon_item) { + // Quick check without modifying player state + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_MORRIGANS_JAVELIN: + case ITEM_VOLATILE_STAFF: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// EQUIP AND GEAR DETECTION +// ============================================================================ + +/** + * Equip item in slot-based mode. + * Returns 1 if equipment changed, 0 if already equipped. + * + * 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; + } + + return 1; +} + +// ============================================================================ +// INVENTORY SEARCH HELPERS +// ============================================================================ + +/** Check if player has an item in the given slot's inventory. */ +static inline int player_has_item_in_slot(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 1; + } + return 0; +} + +/** + * Find best available item from a priority list in the player's inventory. + * Returns ITEM_NONE if no item from the list is available. + */ +static inline uint8_t find_best_available( + Player* p, int gear_slot, + const uint8_t* priority, int priority_len +) { + for (int i = 0; i < priority_len; i++) { + if (player_has_item_in_slot(p, gear_slot, priority[i])) { + return priority[i]; + } + } + return ITEM_NONE; +} + +/** Find best melee spec weapon available in weapon inventory. */ +static inline uint8_t find_best_melee_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MELEE_SPEC_PRIORITY, MELEE_SPEC_PRIORITY_LEN); +} + +/** Find best ranged spec weapon available in weapon inventory. */ +static inline uint8_t find_best_ranged_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, RANGE_SPEC_PRIORITY, RANGE_SPEC_PRIORITY_LEN); +} + +/** Find best magic spec weapon available in weapon inventory. */ +static inline uint8_t find_best_magic_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MAGIC_SPEC_PRIORITY, MAGIC_SPEC_PRIORITY_LEN); +} + +/** Check if player has granite maul in weapon inventory. */ +static inline int player_has_gmaul(Player* p) { + return player_has_item_in_slot(p, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); +} + +// ============================================================================ +// DYNAMIC LOADOUT RESOLUTION +// ============================================================================ + +/** + * Resolve loadout for a given style from available inventory. + * + * Writes item indices to out[8] (one per dynamic gear slot). + * Any slot without a matching item keeps its current equipment. + * + * @param p Player (for inventory lookup) + * @param loadout Style to resolve (MELEE/RANGE/MAGE/TANK/SPEC_*) + * @param out Output array of 8 item indices (NUM_DYNAMIC_GEAR_SLOTS) + */ +static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAMIC_GEAR_SLOTS]) { + // Initialize all outputs to current equipment + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + out[i] = p->equipped[DYNAMIC_GEAR_SLOTS[i]]; + } + + // Slot order in DYNAMIC_GEAR_SLOTS: weapon(0), shield(1), body(2), legs(3), + // head(4), cape(5), neck(6), ring(7) + + switch (loadout) { + case LOADOUT_MELEE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MELEE_WEAPON_PRIORITY, MELEE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_RANGE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, RANGE_WEAPON_PRIORITY, RANGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_MAGE: + case LOADOUT_TANK: { + // MAGE uses best magic weapon + magic gear + // TANK uses best magic weapon + defensive body/legs/shield + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MAGE_WEAPON_PRIORITY, MAGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + + if (loadout == LOADOUT_MAGE) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } else { + // TANK: defensive shield/body/legs/head/neck + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } + + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MELEE: { + uint8_t weapon = find_best_melee_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + // If 2H, shield gets cleared by slot_equip_item + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_RANGE: { + uint8_t weapon = find_best_ranged_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MAGIC: { + uint8_t weapon = find_best_magic_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_GMAUL: { + // GMAUL: 2H weapon, must clear shield + out[0] = ITEM_GRANITE_MAUL; + out[1] = ITEM_NONE; + break; + } + default: + break; + } +} + +/** + * Apply a loadout to a player using dynamic resolution. + * Returns number of slots that actually changed. + */ +static inline int apply_loadout(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + int changed = 0; + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + changed += slot_equip_item(p, gear_slot, resolved[i]); + } + + return changed; +} + +/** + * Check if current equipment matches a resolved loadout. + */ +static inline int is_loadout_active(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + if (p->equipped[gear_slot] != resolved[i]) return 0; + } + return 1; +} + +/** + * Get current active loadout (1-8), or 0 if no loadout matches. + */ +static inline int get_current_loadout(Player* p) { + for (int l = 1; l <= LOADOUT_GMAUL; l++) { + if (is_loadout_active(p, l)) return l; + } + return 0; +} + +// ============================================================================ +// LOADOUT-TO-GEAR MAPPING +// ============================================================================ + +/** Visible GearSet for each loadout (actual damage type, no GEAR_SPEC). */ +static inline GearSet loadout_to_gear_set(int loadout) { + switch (loadout) { + case LOADOUT_MELEE: return GEAR_MELEE; + case LOADOUT_RANGE: return GEAR_RANGED; + case LOADOUT_MAGE: return GEAR_MAGE; + case LOADOUT_TANK: return GEAR_TANK; + case LOADOUT_SPEC_MELEE: return GEAR_MELEE; + case LOADOUT_SPEC_RANGE: return GEAR_RANGED; + case LOADOUT_SPEC_MAGIC: return GEAR_MAGE; + case LOADOUT_GMAUL: return GEAR_MELEE; + default: return GEAR_MELEE; + } +} + +// ============================================================================ +// EQUIPMENT INIT +// ============================================================================ + +/** Get attack style for currently equipped weapon. */ +static inline AttackStyle get_slot_weapon_attack_style(Player* p) { + uint8_t weapon = p->equipped[GEAR_SLOT_WEAPON]; + if (weapon >= NUM_ITEMS) return ATTACK_STYLE_NONE; + return (AttackStyle)get_item_attack_style(weapon); +} + +/** + * Initialize basic LMS equipment (tier 0). + * Sets equipped[] and inventory[] arrays for the basic loadout. + */ +static inline void init_slot_equipment_lms(Player* p) { + // Clear all inventory + memset(p->inventory, ITEM_NONE, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + // Default to melee style starting gear + p->equipped[GEAR_SLOT_HEAD] = ITEM_HELM_NEITIZNOT; + p->equipped[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + p->equipped[GEAR_SLOT_NECK] = ITEM_GLORY; + p->equipped[GEAR_SLOT_AMMO] = ITEM_DIAMOND_BOLTS_E; + p->equipped[GEAR_SLOT_WEAPON] = ITEM_WHIP; + p->equipped[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + p->equipped[GEAR_SLOT_BODY] = ITEM_BLACK_DHIDE_BODY; + p->equipped[GEAR_SLOT_LEGS] = ITEM_RUNE_PLATELEGS; + p->equipped[GEAR_SLOT_HANDS] = ITEM_BARROWS_GLOVES; + p->equipped[GEAR_SLOT_FEET] = ITEM_CLIMBING_BOOTS; + p->equipped[GEAR_SLOT_RING] = ITEM_BERSERKER_RING; + update_spec_weapons_for_weapon(p, p->equipped[GEAR_SLOT_WEAPON]); + + // HEAD + p->inventory[GEAR_SLOT_HEAD][0] = ITEM_HELM_NEITIZNOT; + p->num_items_in_slot[GEAR_SLOT_HEAD] = 1; + + // CAPE + p->inventory[GEAR_SLOT_CAPE][0] = ITEM_GOD_CAPE; + p->num_items_in_slot[GEAR_SLOT_CAPE] = 1; + + // NECK + p->inventory[GEAR_SLOT_NECK][0] = ITEM_GLORY; + p->num_items_in_slot[GEAR_SLOT_NECK] = 1; + + // AMMO + p->inventory[GEAR_SLOT_AMMO][0] = ITEM_DIAMOND_BOLTS_E; + p->num_items_in_slot[GEAR_SLOT_AMMO] = 1; + + // WEAPON: whip, rcb, staff, dds + p->inventory[GEAR_SLOT_WEAPON][0] = ITEM_WHIP; + p->inventory[GEAR_SLOT_WEAPON][1] = ITEM_RUNE_CROSSBOW; + p->inventory[GEAR_SLOT_WEAPON][2] = ITEM_AHRIM_STAFF; + p->inventory[GEAR_SLOT_WEAPON][3] = ITEM_DRAGON_DAGGER; + p->num_items_in_slot[GEAR_SLOT_WEAPON] = 4; + + // SHIELD: defender, spirit + p->inventory[GEAR_SLOT_SHIELD][0] = ITEM_DRAGON_DEFENDER; + p->inventory[GEAR_SLOT_SHIELD][1] = ITEM_SPIRIT_SHIELD; + p->num_items_in_slot[GEAR_SLOT_SHIELD] = 2; + + // BODY: dhide, mystic + p->inventory[GEAR_SLOT_BODY][0] = ITEM_BLACK_DHIDE_BODY; + p->inventory[GEAR_SLOT_BODY][1] = ITEM_MYSTIC_TOP; + p->num_items_in_slot[GEAR_SLOT_BODY] = 2; + + // LEGS: rune, mystic + p->inventory[GEAR_SLOT_LEGS][0] = ITEM_RUNE_PLATELEGS; + p->inventory[GEAR_SLOT_LEGS][1] = ITEM_MYSTIC_BOTTOM; + p->num_items_in_slot[GEAR_SLOT_LEGS] = 2; + + // HANDS + p->inventory[GEAR_SLOT_HANDS][0] = ITEM_BARROWS_GLOVES; + p->num_items_in_slot[GEAR_SLOT_HANDS] = 1; + + // FEET + p->inventory[GEAR_SLOT_FEET][0] = ITEM_CLIMBING_BOOTS; + p->num_items_in_slot[GEAR_SLOT_FEET] = 1; + + // RING + p->inventory[GEAR_SLOT_RING][0] = ITEM_BERSERKER_RING; + p->num_items_in_slot[GEAR_SLOT_RING] = 1; + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Add an item to a player's slot inventory. + * Returns 1 if added, 0 if slot is full or item already present. + */ +static inline int add_item_to_inventory(Player* p, int gear_slot, uint8_t item_idx) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + if (p->num_items_in_slot[gear_slot] >= MAX_ITEMS_PER_SLOT) return 0; + + // Check duplicate + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 0; + } + + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = item_idx; + p->num_items_in_slot[gear_slot]++; + return 1; +} + +// ============================================================================ +// UPGRADE REPLACEMENT TABLE +// ============================================================================ + +// Maps each loot item to the basic item it replaces (ITEM_NONE = doesn't replace) +static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { + [ITEM_HELM_NEITIZNOT] = ITEM_NONE, + [ITEM_GOD_CAPE] = ITEM_NONE, + [ITEM_GLORY] = ITEM_NONE, + [ITEM_BLACK_DHIDE_BODY] = ITEM_NONE, + [ITEM_MYSTIC_TOP] = ITEM_NONE, + [ITEM_RUNE_PLATELEGS] = ITEM_NONE, + [ITEM_MYSTIC_BOTTOM] = ITEM_NONE, + [ITEM_WHIP] = ITEM_NONE, + [ITEM_RUNE_CROSSBOW] = ITEM_NONE, + [ITEM_AHRIM_STAFF] = ITEM_NONE, + [ITEM_DRAGON_DAGGER] = ITEM_NONE, + [ITEM_DRAGON_DEFENDER] = ITEM_NONE, + [ITEM_SPIRIT_SHIELD] = ITEM_NONE, + [ITEM_BARROWS_GLOVES] = ITEM_NONE, + [ITEM_CLIMBING_BOOTS] = ITEM_NONE, + [ITEM_BERSERKER_RING] = ITEM_NONE, + [ITEM_DIAMOND_BOLTS_E] = ITEM_NONE, + // Weapons + [ITEM_GHRAZI_RAPIER] = ITEM_WHIP, + [ITEM_INQUISITORS_MACE] = ITEM_WHIP, + [ITEM_STAFF_OF_DEAD] = ITEM_AHRIM_STAFF, + [ITEM_KODAI_WAND] = ITEM_AHRIM_STAFF, + [ITEM_VOLATILE_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ZURIELS_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ARMADYL_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_ZARYTE_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_DRAGON_CLAWS] = ITEM_DRAGON_DAGGER, + [ITEM_AGS] = ITEM_DRAGON_DAGGER, + [ITEM_ANCIENT_GS] = ITEM_DRAGON_DAGGER, + [ITEM_GRANITE_MAUL] = ITEM_NONE, + [ITEM_ELDER_MAUL] = ITEM_WHIP, + [ITEM_DARK_BOW] = ITEM_NONE, + [ITEM_HEAVY_BALLISTA] = ITEM_NONE, + [ITEM_VESTAS] = ITEM_DRAGON_DAGGER, + [ITEM_VOIDWAKER] = ITEM_DRAGON_DAGGER, + [ITEM_STATIUS_WARHAMMER] = ITEM_DRAGON_DAGGER, + [ITEM_MORRIGANS_JAVELIN] = ITEM_RUNE_CROSSBOW, + // Armor and accessories + [ITEM_ANCESTRAL_HAT] = ITEM_NONE, + [ITEM_ANCESTRAL_TOP] = ITEM_MYSTIC_TOP, + [ITEM_ANCESTRAL_BOTTOM] = ITEM_MYSTIC_BOTTOM, + [ITEM_AHRIMS_ROBETOP] = ITEM_MYSTIC_TOP, + [ITEM_AHRIMS_ROBESKIRT] = ITEM_MYSTIC_BOTTOM, + [ITEM_KARILS_TOP] = ITEM_BLACK_DHIDE_BODY, + [ITEM_BANDOS_TASSETS] = ITEM_RUNE_PLATELEGS, + [ITEM_BLESSED_SPIRIT_SHIELD]= ITEM_SPIRIT_SHIELD, + [ITEM_FURY] = ITEM_GLORY, + [ITEM_OCCULT_NECKLACE] = ITEM_NONE, + [ITEM_INFERNAL_CAPE] = ITEM_NONE, + [ITEM_ETERNAL_BOOTS] = ITEM_CLIMBING_BOOTS, + [ITEM_SEERS_RING_I] = ITEM_NONE, + [ITEM_LIGHTBEARER] = ITEM_NONE, + [ITEM_MAGES_BOOK] = ITEM_NONE, + [ITEM_DRAGON_ARROWS] = ITEM_NONE, + // Barrows armor + [ITEM_TORAGS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_DHAROKS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_VERACS_PLATESKIRT] = ITEM_RUNE_PLATELEGS, + [ITEM_TORAGS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_DHAROKS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_VERACS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_GUTHANS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_OPAL_DRAGON_BOLTS] = ITEM_NONE, // conditional, handled in add_loot_item +}; + +/** + * Remove an item from a player's slot inventory. + * Returns 1 if removed, 0 if item not found. + */ +static inline int remove_item_from_inventory(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) { + for (int j = i; j < p->num_items_in_slot[gear_slot] - 1; j++) { + p->inventory[gear_slot][j] = p->inventory[gear_slot][j + 1]; + } + p->num_items_in_slot[gear_slot]--; + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = ITEM_NONE; + return 1; + } + } + return 0; +} + +/** Map item database index to 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; +} + +// ============================================================================ +// GEAR TIER RANDOMIZATION +// ============================================================================ + +// Loot tables for gear tiers (items that can drop from LMS chests) +// Each chest gives 2 rolls from the same combined pool +static const uint8_t CHEST_LOOT[] = { + // offensive + ITEM_DRAGON_CLAWS, ITEM_AGS, ITEM_ANCIENT_GS, ITEM_GRANITE_MAUL, + ITEM_VOLATILE_STAFF, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, + ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD, ITEM_ELDER_MAUL, + ITEM_HEAVY_BALLISTA, ITEM_OCCULT_NECKLACE, ITEM_INFERNAL_CAPE, + ITEM_SEERS_RING_I, ITEM_MAGES_BOOK, + // defensive + ITEM_ANCESTRAL_HAT, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, + ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, ITEM_KARILS_TOP, + ITEM_BANDOS_TASSETS, ITEM_BLESSED_SPIRIT_SHIELD, + ITEM_FURY, ITEM_ETERNAL_BOOTS, + // barrows armor + opal bolts + ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_OPAL_DRAGON_BOLTS, +}; +#define CHEST_LOOT_LEN 36 + +static const uint8_t BLOODIER_LOOT[] = { + ITEM_VESTAS, ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, + ITEM_MORRIGANS_JAVELIN, ITEM_ZURIELS_STAFF, ITEM_LIGHTBEARER +}; +#define BLOODIER_LOOT_LEN 6 + +/** + * Initialize player gear for a given tier (randomized loot). + * + * Each chest = 2 rolls from a single combined loot pool. + * Tier 0: basic LMS (17 items), no chests + * Tier 1: basic + 1 own chest (2 rolls) + * Tier 2: basic + 2 own chests + 1 killed player's chest (6 rolls) + * Tier 3: basic + 2 own chests + 2 killed players' chests (8 rolls) + 1 bloodier key item + * + * Duplicates are handled by add_loot_item() (dedup + chain replacement). + * + * @param p Player to initialize + * @param tier Gear tier (0-3) + * @param rng RNG state pointer + */ +static inline void init_player_gear_randomized(Player* p, int tier, uint32_t* rng) { + // Start with basic LMS loadout + init_slot_equipment_lms(p); + + if (tier <= 0) return; + + // Helper: add a random item from a loot table with upgrade logic + #define ADD_RANDOM_LOOT(table, len) do { \ + uint32_t _r = xorshift32(rng); \ + uint8_t _item = (table)[_r % (len)]; \ + add_loot_item(p, _item); \ + } while(0) + + // Tier 1: 1 own chest = 2 rolls + if (tier >= 1) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 2: 1 more own chest (2 rolls) + 1 killed player's chest (2 rolls) + if (tier >= 2) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 3: 1 more killed player's chest (2 rolls) + 1 bloodier key item + if (tier >= 3) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(BLOODIER_LOOT, BLOODIER_LOOT_LEN); + } + + #undef ADD_RANDOM_LOOT + + // Tier 3 only: drop defender if no 1-handed melee weapon exists. + // At lower tiers future loot might add a 1H melee (VLS, SWH, voidwaker). + if (tier >= 3 && player_has_item_in_slot(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER)) { + int has_1h_melee = 0; + for (int i = 0; i < p->num_items_in_slot[GEAR_SLOT_WEAPON]; i++) { + uint8_t w = p->inventory[GEAR_SLOT_WEAPON][i]; + if (get_item_attack_style(w) == ATTACK_STYLE_MELEE && !item_is_two_handed(w)) { + has_1h_melee = 1; + break; + } + } + if (!has_1h_melee) { + remove_item_from_inventory(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER); + } + } + + // Re-resolve starting equipment in melee loadout + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, LOADOUT_MELEE, resolved); + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + slot_equip_item(p, DYNAMIC_GEAR_SLOTS[i], resolved[i]); + } + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Sample gear tier from weights using RNG. + * Returns tier 0-3. + */ +static inline int sample_gear_tier(float weights[4], uint32_t* rng) { + float r = (float)xorshift32(rng) / (float)UINT32_MAX; + float cumulative = 0.0f; + for (int i = 0; i < 4; i++) { + cumulative += weights[i]; + if (r < cumulative) return i; + } + return 0; // Fallback to tier 0 +} + +#endif // OSRS_PVP_GEAR_H diff --git a/src/osrs/osrs_pvp_movement.h b/src/osrs/osrs_pvp_movement.h new file mode 100644 index 0000000000..07f90e7be8 --- /dev/null +++ b/src/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/src/osrs/osrs_pvp_observations.h b/src/osrs/osrs_pvp_observations.h new file mode 100644 index 0000000000..fe745e370f --- /dev/null +++ b/src/osrs/osrs_pvp_observations.h @@ -0,0 +1,767 @@ +/** + * @file osrs_pvp_observations.h + * @brief Observation generation and action mask computation + * + * Generates the observation vector for RL agents (334 features) + * and computes action masks to prevent invalid actions. + */ + +#ifndef OSRS_PVP_OBSERVATIONS_H +#define OSRS_PVP_OBSERVATIONS_H + +#include +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" + +/** + * Get relative combat skill level (strength/attack/defence). + * Normalized to 0-1 range based on max possible boosted level. + */ +static inline float get_relative_level_combat(int current, int base) { + int max_level = base + (int)floorf(base * 0.15f) + 5; + return (float)current / (float)max_level; +} + +/** Get relative ranged level (10% boost formula). */ +static inline float get_relative_level_ranged(int current, int base) { + int max_level = base + (int)floorf(base * 0.10f) + 4; + return (float)current / (float)max_level; +} + +/** Get relative magic level (no boost available). */ +static inline float get_relative_level_magic(int current, int base) { + return (float)current / (float)base; +} + +/** + * Check if brew/defence boost would be beneficial. + * + * @param p Player to check + * @return 1 if defence not capped or HP not full + */ +static inline int can_use_brew_boost(Player* p) { + int def_boost = (int)floorf(2.0f + (0.20f * p->base_defence)); + int def_cap = p->is_lms ? p->base_defence : p->base_defence + def_boost; + if (p->current_defence < def_cap - 1) { + return 1; + } + return p->current_hitpoints <= p->base_hitpoints; +} + +/** Check if restore potion would be beneficial. + * Stats must be drained OR prayer below 90% of base. + */ +static inline int can_restore_stats(Player* p) { + int stats_drained = p->current_attack < p->base_attack || + p->current_defence < p->base_defence || + p->current_strength < p->base_strength || + p->current_ranged < p->base_ranged || + p->current_magic < p->base_magic; + int prayer_low = p->current_prayer < (int)(p->base_prayer * 0.9f); + return stats_drained || prayer_low; +} + +/** Check if combat potion boost would be beneficial. */ +static inline int can_boost_combat_skills(Player* p) { + int max_att = (int)floorf(p->base_attack * 0.15f) + 5 + p->base_attack; + int max_str = (int)floorf(p->base_strength * 0.15f) + 5 + p->base_strength; + int def_boost = (int)floorf(p->base_defence * 0.15f) + 5; + int max_def = p->is_lms ? p->base_defence : p->base_defence + def_boost; + return max_att > p->current_attack + 1 || + max_def > p->current_defence + 1 || + max_str > p->current_strength + 1; +} + +/** Check if ranged potion boost would be beneficial. */ +static inline int can_boost_ranged(Player* p) { + int max_ranged = (int)floorf(p->base_ranged * 0.10f) + 4 + p->base_ranged; + return max_ranged > p->current_ranged + 1; +} + +/** Check if potion type is available (timer + doses). */ +static inline int can_use_potion(Player* p, int potion_type) { + if (remaining_ticks(p->potion_timer) > 0) { + return 0; + } + switch (potion_type) { + case 1: return p->brew_doses > 0; + case 2: return p->restore_doses > 0; + case 3: return p->combat_potion_doses > 0; + case 4: return p->ranged_potion_doses > 0; + default: return 0; + } +} + +/** Check if food is available and player not at full HP. */ +static inline int can_eat_food(Player* p) { + if (remaining_ticks(p->food_timer) > 0) { + return 0; + } + if (p->food_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if karambwan is available and player not at full HP. */ +static inline int can_eat_karambwan(Player* p) { + if (remaining_ticks(p->karambwan_timer) > 0) { + return 0; + } + if (p->karambwan_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if target is about to attack (tank gear useful). */ +static inline int can_switch_to_tank_gear(Player* target) { + return remaining_ticks(target->attack_timer) <= 0; +} + +/** Check if prayer switch is available (respects timing config). */ +static inline int is_protected_prayer_action_available(Player* p, Player* target) { + if (ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK && remaining_ticks(target->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if smite prayer is available (not in LMS). */ +static inline int is_smite_available(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]; + } +} + +// ============================================================================ +// SLOT-BASED MODE - OBSERVATION GENERATION +// ============================================================================ + +/** + * 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; + obs[118] = p->has_dharok ? 1.0f : 0.0f; + + // Slot-based gear bonuses (119-129) using current equipped items + GearBonuses* slot_bonuses = get_slot_gear_bonuses(p); + obs[119] = (float)slot_bonuses->magic_attack; + obs[120] = (float)slot_bonuses->magic_strength; + obs[121] = (float)slot_bonuses->ranged_attack; + obs[122] = (float)slot_bonuses->ranged_strength; + obs[123] = (float)slot_bonuses->attack_speed; + obs[124] = (float)slot_bonuses->attack_range; + obs[125] = (float)slot_bonuses->slash_attack; + obs[126] = (float)slot_bonuses->melee_strength; + obs[127] = (float)slot_bonuses->ranged_defence; + obs[128] = (float)slot_bonuses->magic_defence; + obs[129] = (float)slot_bonuses->slash_defence; + + // Target visible gear defences (130-132) - computed from actual equipped items + GearBonuses* target_bonuses = get_slot_gear_bonuses(t); + obs[130] = (float)target_bonuses->ranged_defence; + obs[131] = (float)target_bonuses->magic_defence; + obs[132] = (float)target_bonuses->slash_defence; + + // Game mode flags and ability checks (133-148) + obs[133] = env->is_lms ? 1.0f : 0.0f; + obs[134] = env->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) + int has_prayer = p->current_prayer > 0; + mask[offset + OVERHEAD_NONE] = 1; + mask[offset + OVERHEAD_MAGE] = has_prayer && (p->prayer != PRAYER_PROTECT_MAGIC); + mask[offset + OVERHEAD_RANGED] = has_prayer && (p->prayer != PRAYER_PROTECT_RANGED); + mask[offset + OVERHEAD_MELEE] = has_prayer && (p->prayer != PRAYER_PROTECT_MELEE); + mask[offset + OVERHEAD_SMITE] = has_prayer && !env->is_lms && (p->prayer != PRAYER_SMITE); + mask[offset + OVERHEAD_REDEMPTION] = has_prayer && !env->is_lms && (p->prayer != PRAYER_REDEMPTION); + offset += OVERHEAD_DIM; + + // FOOD head (2 options) + mask[offset + FOOD_NONE] = 1; + mask[offset + FOOD_EAT] = can_eat_food(p); + offset += FOOD_DIM; + + // POTION head (5 options) + mask[offset + POTION_NONE] = 1; + mask[offset + POTION_BREW] = can_use_potion(p, 1) && can_use_brew_boost(p); + mask[offset + POTION_RESTORE] = can_use_potion(p, 2) && can_restore_stats(p); + mask[offset + POTION_COMBAT] = can_use_potion(p, 3) && can_boost_combat_skills(p); + mask[offset + POTION_RANGED] = can_use_potion(p, 4) && can_boost_ranged(p); + offset += POTION_DIM; + + // KARAMBWAN head (2 options) + mask[offset + KARAM_NONE] = 1; + mask[offset + KARAM_EAT] = can_eat_karambwan(p); + offset += KARAMBWAN_DIM; + + // VENG head (2 options) + mask[offset + VENG_NONE] = 1; + mask[offset + VENG_CAST] = !env->is_lms && p->is_lunar_spellbook && !p->veng_active && + (remaining_ticks(p->veng_cooldown) == 0) && p->current_magic >= 94; + offset += VENG_DIM; +} + +#endif // OSRS_PVP_OBSERVATIONS_H diff --git a/src/osrs/osrs_pvp_opponents.h b/src/osrs/osrs_pvp_opponents.h new file mode 100644 index 0000000000..f82bd5c825 --- /dev/null +++ b/src/osrs/osrs_pvp_opponents.h @@ -0,0 +1,3666 @@ +/** + * @fileoverview Scripted opponent policies implemented in C. + * + * Ports the Python opponent policies (opponents/ *.py) to C for use within + * c_step(). Eliminates the Python round-trip for opponent action + * generation during training with scripted opponents. + * + * Opponent reads game state directly from Player structs instead of parsing + * observation arrays, which is both faster and avoids float normalization. + * + * Actions are direct head-value assignments: int actions[NUM_ACTION_HEADS]. + * Gear switches use loadout presets (LOADOUT_MELEE, LOADOUT_RANGE, etc.) + * instead of per-slot equip actions. + * + * Phase 1 policies: TrueRandom, Panicking, WeakRandom, SemiRandom, + * StickyPrayer, Beginner, BetterRandom, Improved. + * Phase 2 policies: Onetick, UnpredictableImproved, UnpredictableOnetick. + * Mixed wrappers: MixedEasy, MixedMedium, MixedHard, MixedHardBalanced. + */ + +#ifndef OSRS_PVP_OPPONENTS_H +#define OSRS_PVP_OPPONENTS_H + +/* This header is included from osrs_pvp.h AFTER all other headers, + * so osrs_types.h, osrs_items.h (via gear.h), and + * osrs_pvp_actions.h are already available. */ + +/* OpponentType enum and OpponentState struct are in osrs_types.h */ + +/* Attack style enum for opponent internal use */ +#define OPP_STYLE_MAGE 0 +#define OPP_STYLE_RANGED 1 +#define OPP_STYLE_MELEE 2 +#define OPP_STYLE_SPEC 3 + +/* ========================================================================= + * Utility: map OPP_STYLE_* to LOADOUT_* presets + * ========================================================================= */ + +static inline int opp_style_to_loadout(int style) { + switch (style) { + case OPP_STYLE_MAGE: return LOADOUT_MAGE; + case OPP_STYLE_RANGED: return LOADOUT_RANGE; + case OPP_STYLE_MELEE: return LOADOUT_MELEE; + case OPP_STYLE_SPEC: return LOADOUT_SPEC_MELEE; + default: return LOADOUT_KEEP; + } +} + +static inline void opp_apply_gear_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Fake switch: same loadout set, no attack action follows */ +static inline void opp_apply_fake_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Tank gear: LOADOUT_TANK equips dhide body, rune legs, spirit shield */ +static inline void opp_apply_tank_gear(int* actions) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; +} + +/* ========================================================================= + * Consumable availability helpers + * ========================================================================= */ + +typedef struct { + int can_food; + int can_brew; + int can_karambwan; + int can_restore; + int can_combat_pot; + int can_ranged_pot; +} OppConsumables; + +static inline void opp_tick_cooldowns(OpponentState* opp) { + if (opp->food_cooldown > 0) opp->food_cooldown--; + if (opp->potion_cooldown > 0) opp->potion_cooldown--; + if (opp->karambwan_cooldown > 0) opp->karambwan_cooldown--; +} + +static inline OppConsumables opp_get_consumables(OpponentState* opp, Player* self) { + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + OppConsumables c; + c.can_food = (opp->food_cooldown <= 0 && self->food_count > 0 && hp_pct < 1.0f); + c.can_brew = (opp->potion_cooldown <= 0 && self->brew_doses > 0); + c.can_karambwan = (opp->karambwan_cooldown <= 0 && self->karambwan_count > 0 && hp_pct < 1.0f); + c.can_restore = (opp->potion_cooldown <= 0 && self->restore_doses > 0); + c.can_combat_pot = (opp->potion_cooldown <= 0 && self->combat_potion_doses > 0); + c.can_ranged_pot = (opp->potion_cooldown <= 0 && self->ranged_potion_doses > 0); + return c; +} + +/* (opp_apply_gear_switch is defined above as inline loadout assignment) */ + +/* ========================================================================= + * Prayer helpers + * ========================================================================= */ + +static inline AttackStyle opp_get_gear_style(Player* p) { + int s = get_item_attack_style(p->equipped[GEAR_SLOT_WEAPON]); + if (s == 3) return ATTACK_STYLE_MAGIC; + if (s == 2) return ATTACK_STYLE_RANGED; + if (s == 1) return ATTACK_STYLE_MELEE; + return ATTACK_STYLE_MAGIC; /* Default */ +} + +static inline int opp_get_defensive_prayer(Player* target) { + AttackStyle target_style = opp_get_gear_style(target); + if (target_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (target_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (target_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return OVERHEAD_MAGE; /* Default to mage */ +} + +static inline int opp_has_prayer_active(Player* self, int prayer_action) { + if (prayer_action == OVERHEAD_MELEE) return self->prayer == PRAYER_PROTECT_MELEE; + if (prayer_action == OVERHEAD_RANGED) return self->prayer == PRAYER_PROTECT_RANGED; + if (prayer_action == OVERHEAD_MAGE) return self->prayer == PRAYER_PROTECT_MAGIC; + return 0; +} + +/* ========================================================================= + * Attack style helpers + * ========================================================================= */ + +static inline int opp_attack_ready(Player* self) { + return self->attack_timer <= 0; +} + +static inline int opp_can_reach_melee(Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + return dist <= 1 || (self->frozen_ticks == 0 && dist <= 5); +} + +/** + * Get off-prayer attack styles (styles target isn't protecting). + * Returns bitmask: bit 0 = mage, bit 1 = ranged, bit 2 = melee + */ +static inline int opp_get_off_prayer_mask(Player* self, Player* target) { + int mask = 0; + if (target->prayer != PRAYER_PROTECT_MAGIC) mask |= (1 << OPP_STYLE_MAGE); + if (target->prayer != PRAYER_PROTECT_RANGED) mask |= (1 << OPP_STYLE_RANGED); + if (target->prayer != PRAYER_PROTECT_MELEE && opp_can_reach_melee(self, target)) + mask |= (1 << OPP_STYLE_MELEE); + if (mask == 0) mask = (1 << OPP_STYLE_MAGE); /* Fallback to mage */ + return mask; +} + +static inline int opp_pick_from_mask(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; +} + +/* ========================================================================= + * Per-episode randomization: ranges table for all opponent types + * ========================================================================= */ + +typedef struct { float base; float variance; } RandRange; + +typedef struct { + RandRange prayer_accuracy; + RandRange off_prayer_rate; + RandRange offensive_prayer_rate; + RandRange action_delay_chance; + RandRange mistake_rate; + RandRange offensive_prayer_miss; /* chance to attack without loadout switch (skips auto-prayer) */ +} OpponentRandRanges; + +#define RR(b, v) {(b), (v)} + +/* pray_acc off_pray off_pray_r act_delay mistake off_pray_miss */ +static const OpponentRandRanges OPP_RAND_RANGES[OPP_RANGE_KITER + 1] = { + [OPP_NONE] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_TRUE_RANDOM] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PANICKING] = { RR(0.33,0.1), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_WEAK_RANDOM] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.10,0.05), RR(0.05,0.03), RR(0,0) }, + [OPP_SEMI_RANDOM] = { RR(0.50,0.1), RR(0.40,0.1), RR(0.05,0.03),RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_STICKY_PRAYER] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_RANDOM_EATER] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_PRAYER_ROOKIE] = { RR(0.30,0.1), RR(0.20,0.1), RR(0,0), RR(0.12,0.05), RR(0.08,0.04), RR(0,0) }, + [OPP_IMPROVED] = { RR(0.95,0.05),RR(0.95,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_EASY] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_MEDIUM] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_ONETICK] = { RR(0.97,0.03),RR(0.97,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.03,0.02) }, + [OPP_UNPREDICTABLE_IMPROVED]= { RR(0.92,0.05),RR(0.90,0.05),RR(0.75,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.08,0.04) }, + [OPP_UNPREDICTABLE_ONETICK] = { RR(0.95,0.03),RR(0.95,0.03),RR(0.85,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_HARD] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_HARD_BALANCED] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PFSP] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_NOVICE_NH] = { RR(0.60,0.10),RR(0.10,0.05),RR(0.10,0.05),RR(0.15,0.05), RR(0.10,0.05), RR(0.30,0.10) }, + [OPP_APPRENTICE_NH] = { RR(0.60,0.10),RR(0.20,0.08),RR(0.20,0.08),RR(0.12,0.05), RR(0.08,0.04), RR(0.30,0.10) }, + [OPP_COMPETENT_NH] = { RR(0.75,0.08),RR(0.25,0.08),RR(0.25,0.08),RR(0.10,0.04), RR(0.06,0.03), RR(0.20,0.08) }, + [OPP_INTERMEDIATE_NH] = { RR(0.85,0.05),RR(0.70,0.08),RR(0.50,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.20,0.08) }, + [OPP_ADVANCED_NH] = { RR(0.95,0.05),RR(0.90,0.05),RR(0.75,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_PROFICIENT_NH] = { RR(0.95,0.03),RR(0.92,0.04),RR(0.80,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_EXPERT_NH] = { RR(0.97,0.03),RR(0.95,0.03),RR(0.85,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.10,0.05) }, + [OPP_MASTER_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_SAVANT_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_NIGHTMARE_NH] = { RR(0.99,0.01),RR(0.98,0.02),RR(0.95,0.03),RR(0.01,0.01), RR(0.005,0.005),RR(0.01,0.01) }, + [OPP_VENG_FIGHTER] = { RR(0.92,0.05),RR(0.90,0.05),RR(0.85,0.10),RR(0.03,0.02), RR(0.02,0.01), RR(0.05,0.03) }, + [OPP_BLOOD_HEALER] = { RR(0.90,0.05),RR(0.88,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.04,0.02), RR(0.05,0.03) }, + [OPP_GMAUL_COMBO] = { RR(0.96,0.03),RR(0.95,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.02,0.01) }, + [OPP_RANGE_KITER] = { RR(0.93,0.04),RR(0.93,0.04),RR(0.85,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.04,0.02) }, +}; + +#undef RR + +static inline float rand_range(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; +} + +/* ========================================================================= + * Phase 2: probability constants for unpredictable policies + * ========================================================================= */ + +/* unpredictable_improved prayer delays: 70% instant, 20% 1-tick, 8% 2-tick, 2% 3-tick */ +static const float UNPREDICTABLE_IMP_PRAYER_CUM[] = {0.70f, 0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_IMP_PRAYER_CUM_LEN 4 + +/* unpredictable_improved action delays: 85% instant, 12% 1-tick, 3% 2-tick */ +static const float UNPREDICTABLE_IMP_ACTION_CUM[] = {0.85f, 0.97f, 1.00f}; +#define UNPREDICTABLE_IMP_ACTION_CUM_LEN 3 + +/* unpredictable_onetick prayer delays: 80% instant, 15% 1-tick, 4% 2-tick, 1% 3-tick */ +static const float UNPREDICTABLE_OT_PRAYER_CUM[] = {0.80f, 0.95f, 0.99f, 1.00f}; +#define UNPREDICTABLE_OT_PRAYER_CUM_LEN 4 + +/* unpredictable_onetick action delays: 90% instant, 8% 1-tick, 2% 2-tick */ +static const float UNPREDICTABLE_OT_ACTION_CUM[] = {0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_OT_ACTION_CUM_LEN 3 + +/* mistake probabilities */ +#define UNPREDICTABLE_IMP_WRONG_PRAYER 0.05f +#define UNPREDICTABLE_IMP_SUBOPTIMAL_ATTACK 0.03f +#define UNPREDICTABLE_OT_FAKE_FAIL 0.12f +#define UNPREDICTABLE_OT_WRONG_PREDICT 0.08f + +/* ========================================================================= + * Phase 2: helper functions for onetick + unpredictable policies + * ========================================================================= */ + +/* Weighted delay sampling from cumulative weight array */ +static inline int opp_sample_delay(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) { + /* Karambwan as fallback food (no sharks left) */ + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring (one restore undoes ~3 brews) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + (void)env; + return potion_used; +} + +/* Process pending prayer delay: decrement, apply if ready. Returns 1 if applied. */ +static inline int opp_process_pending_prayer(OpponentState* opp, int* actions) { + if (opp->pending_prayer_value == 0) return 0; + if (opp->pending_prayer_delay > 0) { + opp->pending_prayer_delay--; + if (opp->pending_prayer_delay > 0) return 0; + } + /* Delay reached 0 or was already 0: apply */ + actions[HEAD_OVERHEAD] = opp->pending_prayer_value; + opp->pending_prayer_value = 0; + return 1; +} + +/* Handle prayer switch with delay for unpredictable policies. + * Detects target gear changes, samples delay, stores in pending state. + * include_spec: if 1, also detect spec weapon (onetick/unpredictable_onetick). */ +static void opp_handle_delayed_prayer(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); +} + +/* ========================================================================= + * Policy implementations + * ========================================================================= */ + +/* --- 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)) { + actions[HEAD_OVERHEAD] = opp->chosen_prayer; + } + + /* Panic eat at 25% HP */ + int eating = 0; + if (hp_pct < 0.25f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 30% chance to attack */ + if (opp_attack_ready(self) && !eating && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, opp->chosen_style); + + if (opp->chosen_style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- WeakRandom: random style, unreliable eating (50% skip) --- */ +static void opp_weak_random(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}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 4)]; + + /* Unreliable eating at 30% with 50% skip chance */ + int eating = 0; + if (hp_pct < 0.30f && rand_float(env) > 0.50f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); /* 0=mage, 1=ranged, 2=melee */ + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- SemiRandom: reliable eating at 30%, random everything else --- */ +static void opp_semi_random(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}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 3)]; + + /* Reliable eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- StickyPrayer: sticky prayer (~12 tick avg), simple eating --- */ +static void opp_sticky_prayer(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)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* Simple eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- Beginner: sticky prayer, multi-threshold eating, random spec --- */ +static void opp_random_eater(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)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* 2. Multi-threshold eating */ + int potion_used = 0; + if (hp_pct < 0.35f) { + /* Emergency: eat everything */ + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + + /* 3. Restore if low prayer */ + if (!potion_used && prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack when ready with random style */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + /* 30% spec chance */ + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + + (void)target; +} + +/* --- BetterRandom: multi-threshold eating, random prayers, random spec --- */ +static void opp_prayer_rookie(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); + actions[HEAD_OVERHEAD] = def_prayer; + + /* 2. Multi-threshold eating */ + if (hp_pct < 0.35f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack with random style, random spec chance */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* --- Improved: full NH (correct prayer, off-prayer attacks, combo eating, + spec timing, offensive prayer, movement) --- */ +static void opp_improved(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables: triple/double/single eat */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating was queued (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay: skip offensive actions this tick */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack decision */ + if (opp_attack_ready(self) && !eating) { + /* Pick off-prayer style with bias */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Check spec */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low HP, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; /* ATK */ + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Attack action */ + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Novice NH: learning player — 60% correct prayer, random attacks, good eating + * Bridges easy opponents to intermediate. No off-prayer logic, no offensive + * prayers, no movement. Just consistent attacking and sometimes-correct prayer. + * ========================================================================= */ + +static void opp_novice_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: off-prayer based on off_prayer_rate. Random spec. Offensive prayer. */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.15f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + /* Anti-kite: cancel spec, use mage */ + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Apprentice NH: 60% correct prayer, 20% off-prayer attacks, 20% offensive + * prayer, random 30% spec, drain restore. Bridges novice_nh to competent_nh. + * ========================================================================= */ + +static void opp_apprentice_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (hp_pct < 0.30f) ? ATTACK_BLOOD : ATTACK_ICE; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Competent NH: 75% correct prayer, 25% off-prayer attacks, 25% offensive + * prayers, 50% conditional spec. Bridges apprentice_nh to intermediate_nh. + * ========================================================================= */ + +static void opp_competent_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: same condition as intermediate_nh but 50% trigger rate */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range && + rand_float(env) < 0.50f); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Intermediate NH: getting the hang of it — 85% correct prayer, 70% off-prayer + * attacks, 50% offensive prayers. No movement, no fakes. Bridges novice_nh + * to improved. + * ========================================================================= */ + +static void opp_intermediate_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: target HP < 60%, not on melee prayer, in range */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Advanced NH: near-improved — 100% correct prayer, 90% off-prayer attacks, + * 75% offensive prayers, same spec as improved, farcast 3 but no step under. + * Bridges intermediate_nh to improved. + * ========================================================================= */ + +static void opp_advanced_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved (no HP threshold, just not praying melee + in range) */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 only (no step under) */ + int mv_dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && mv_dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Proficient NH: 92% off-prayer, 80% offensive prayer, 25% step under. + * Introduces step under at low rate between advanced_nh (no step under) + * and expert_nh (50% step under). + * ========================================================================= */ + +static void opp_proficient_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 25% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.25f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Expert NH: 95% off-prayer, 85% offensive prayer, 50% step under. + * Introduces step under mechanic at reduced rate while keeping attack + * parameters between advanced_nh and improved. + * ========================================================================= */ + +static void opp_expert_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 50% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.50f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Phase 2 Policy: Onetick + * Fake switches, tank gear, smart spec, boost pots, 1-tick attacks. + * ========================================================================= */ + +static void opp_onetick(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same thresholds as improved) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + /* Check if eating was queued */ + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + /* Clear fake state when attack ready */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + /* Initiate fake switch */ + int current_style = (int)self->current_gear; + /* Don't fake melee if frozen at distance */ + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + /* Build fake options: off-prayer, not current style, melee only if credible */ + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + /* Fake switch: set loadout but no attack */ + opp_apply_fake_switch(actions, opp->fake_switch_style); + + /* Step under if target frozen */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + + /* Early return -- fake switch done this tick */ + return; + } + } + + /* 5. Determine attack style */ + /* If we just faked, anticipate opponent's prayer switch */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + /* If ranged/magic specs available, gate melee spec behind HP threshold too + * so the boss saves energy for ranged/magic finishing blows */ + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + int spec_loadout = LOADOUT_SPEC_MELEE; /* default, overridden below */ + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) + ? ((target->freeze_immunity_ticks <= 1 && target->frozen_ticks == 0) ? 0 : 1) + : 0; /* ice at full HP */ + /* Simplified: use opp_get_mage_attack for ice/blood decision */ + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + /* Target frozen or mage not off-prayer — choose based on fake anticipation */ + int can_use_preferred = preferred_style >= 0 && + (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); + + if (can_use_preferred) { + actual_style = preferred_style; + if (preferred_style == OPP_STYLE_MAGE) { + actual_attack = (hp_pct < 0.98f) ? 1 : 0; /* blood if not full HP */ + } else { + actual_attack = 2; /* ATK */ + } + } else if (off_mask & (1 << OPP_STYLE_MAGE)) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) ? 1 : 0; + } else { + /* Pick non-mage from off-prayer */ + int non_mage[2]; + int nm_count = 0; + for (int s = 1; s < 3; s++) { + if (off_mask & (1 << s)) non_mage[nm_count++] = s; + } + if (nm_count == 0) { + actual_style = OPP_STYLE_RANGED; + } else { + actual_style = non_mage[rand_int(env, nm_count)]; + } + actual_attack = 2; /* ATK */ + } + } + + /* 6. Boost potions (before attack) */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay: skip attack but keep prayer/eating/fakes */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + offensive prayer + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticImproved + * Improved with prayer delays, wrong prayer chance, attack delays. + * ========================================================================= */ + +static void opp_unpredictable_improved(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; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticOnetick + * Onetick + prayer delays + fake execution failures + wrong prediction. + * ========================================================================= */ + +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; + } + } +} + +/* ========================================================================= + * Helper: decode agent's pending action to extract attack style and prayer + * Used by boss opponents (master_nh, savant_nh) for "reading" ability. + * ========================================================================= */ + +static void opp_read_agent_action(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 */ + int overhead = agent_actions[HEAD_OVERHEAD]; + if (overhead == OVERHEAD_MAGE) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; + else if (overhead == OVERHEAD_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; + else if (overhead == OVERHEAD_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; + else if (overhead == OVERHEAD_SMITE) opp->read_agent_prayer = PRAYER_SMITE; + else if (overhead == OVERHEAD_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; + + /* Extract movement intent */ + opp->read_agent_moving = is_move_action(attack) ? 1 : 0; +} + +/* Get defensive prayer against agent's read attack style */ +static inline int opp_get_read_defensive_prayer(OpponentState* opp) { + if (opp->read_agent_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (opp->read_agent_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (opp->read_agent_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return -1; /* No read or unknown */ +} + +/* Check if a style would hit agent off-prayer (using read info) */ +static inline int opp_style_off_read_prayer(OpponentState* opp, int style) { + if (opp->read_agent_prayer == PRAYER_NONE) return 1; /* No read, assume off */ + if (style == OPP_STYLE_MAGE && opp->read_agent_prayer != PRAYER_PROTECT_MAGIC) return 1; + if (style == OPP_STYLE_RANGED && opp->read_agent_prayer != PRAYER_PROTECT_RANGED) return 1; + if (style == OPP_STYLE_MELEE && opp->read_agent_prayer != PRAYER_PROTECT_MELEE) return 1; + return 0; /* Would hit on-prayer */ +} + +/* ========================================================================= + * Boss Policy: Master NH + * Onetick-perfect mechanics + 10% chance to "read" agent's pending action. + * When read succeeds: prays correctly against incoming attack, attacks off-prayer. + * ========================================================================= */ + +static void opp_master_nh(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same as onetick) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask (normal) and check read info for better targeting */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic (same as onetick) */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + int current_style = (int)self->current_gear; + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + opp_apply_fake_switch(actions, opp->fake_switch_style); + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + return; + } + } + + /* 5. Determine attack style - use read info if available */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + /* If we read agent's prayer, pick a style they're NOT praying against */ + if (opp->has_read_this_tick && opp->read_agent_prayer != PRAYER_NONE) { + /* Find best off-prayer style using read info */ + int read_off_styles[3]; + int read_off_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (opp_style_off_read_prayer(opp, s)) { + read_off_styles[read_off_count++] = s; + } + } + if (read_off_count > 0) { + preferred_style = read_off_styles[rand_int(env, read_off_count)]; + } + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* With read, cancel specs the agent is praying against */ + if (opp->has_read_this_tick) { + if (should_melee_spec && opp->read_agent_prayer == PRAYER_PROTECT_MELEE) + should_melee_spec = 0; + if (should_ranged_spec && opp->read_agent_prayer == PRAYER_PROTECT_RANGED) + should_ranged_spec = 0; + if (should_magic_spec && opp->read_agent_prayer == PRAYER_PROTECT_MAGIC) + should_magic_spec = 0; + } + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + /* Read-based anti-kite: if agent about to move away, cancel melee spec */ + if (should_melee_spec && opp->has_read_this_tick && opp->read_agent_moving && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; + int spec_loadout = LOADOUT_SPEC_MELEE; + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (preferred_style >= 0) { + actual_style = preferred_style; + actual_attack = (preferred_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + actual_style = opp_pick_from_mask(env, off_mask); + actual_attack = (actual_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } + + /* 6. Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Spec: use spec_loadout directly; normal: gear switch with prayer miss */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Boss Policy: Savant NH + * Onetick-perfect mechanics + 25% chance to "read" agent's pending action. + * Same as master_nh but with higher read chance. + * ========================================================================= */ + +static void opp_savant_nh(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); +} + +/* ========================================================================= + * Boss Policy: Nightmare NH + * Same as master/savant but with 50% read chance - extremely difficult. + * ========================================================================= */ + +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); +} + +/* ========================================================================= + * Vengeance Fighter: lunar spellbook, melee/range only, no freeze/blood. + * Expert-level prayer/eating, veng on cooldown, melee spec only. + * ========================================================================= */ + +static void opp_veng_fighter(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as expert_nh) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* 3. Vengeance on cooldown */ + if (!self->veng_active && remaining_ticks(self->veng_cooldown) == 0) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack: melee/range only (no mage — lunar spellbook) */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + /* Off-prayer: pick melee or range based on what target ISN'T praying */ + int off_mask = opp_get_off_prayer_mask(self, target); + /* Remove mage from mask — lunar can't cast offensive spells */ + off_mask &= ~(1 << OPP_STYLE_MAGE); + if (off_mask == 0) off_mask = (1 << OPP_STYLE_MELEE) | (1 << OPP_STYLE_RANGED); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + /* Random: melee or range only (OPP_STYLE_RANGED=1, OPP_STYLE_MELEE=2) */ + attack_style = rand_int(env, 2) + 1; + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: melee spec only */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel spec if target fleeing */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + } + + if (should_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.40f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Blood Healer: sustain fighter using blood barrage as primary healing. + * Works at all tiers — ahrim staff can cast blood spells regardless of gear. + * Farcast-5, reduced food reliance, blood barrage heavy when damaged. + * ========================================================================= */ + +static void opp_blood_healer(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Reduced eating — relies on blood barrage for sustain above ~35%. + * Emergency triple-eat below 35%, otherwise only brew/food below 25%. */ + if (hp_pct < 0.25f && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && hp_pct < 0.50f && cons.can_brew) { + /* Brew-batch: blood_healer uses lower threshold (relies on blood barrage above 50%) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: blood barrage emphasis for sustain */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk */ + + if (hp_pct < 0.40f) { + /* Low HP: blood barrage for heal + triple-eat */ + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else if (hp_pct < 0.70f) { + /* Medium HP: strongly prefer blood barrage (~80%) for sustain */ + if (rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else { + /* Off-prayer attack with style bias */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + /* Ice to freeze, not blood (already handled blood above) */ + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood */ + } else { + actual_attack = 2; /* ATK */ + } + } + } else { + /* High HP: normal off-prayer targeting with ice barrage for freeze */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood for sustain */ + } else { + actual_attack = 2; /* ATK */ + } + } + + /* Apply boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + /* Tank gear when critically low and not casting blood */ + if (hp_pct < 0.35f && actual_attack != 1) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; + } + + /* Attack action */ + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5 distance */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4 && target->frozen_ticks == 0) { + /* Maintain distance from unfrozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (opp->target_fleeing_ticks >= 2 && dist > 5) { + /* Anti-kite: close to farcast-5 range */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Gmaul Combo: KO specialist with spec → gmaul instant follow-up. + * Degrades to improved-style at tier 0 (DDS spec only, no gmaul combo). + * At tier 1+ with gmaul available, fires spec→gmaul for burst KO. + * ========================================================================= */ + +#define COMBO_IDLE 0 +#define COMBO_SPEC_FIRED 1 + +static void opp_gmaul_combo(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Combo state machine: follow-up gmaul after spec fired */ + if (opp->combo_state == COMBO_SPEC_FIRED && has_gmaul && !eating) { + /* Gmaul follow-up — instant spec, bypasses attack timer */ + actions[HEAD_LOADOUT] = LOADOUT_GMAUL; + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_IDLE; + return; + } + /* Reset combo if we ate (can't follow up) or don't have gmaul */ + opp->combo_state = COMBO_IDLE; + + /* 4. Attack decision */ + if (opp_attack_ready(self) && !eating) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + + /* KO opportunity: target in KO range and we have enough spec energy */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int gmaul_cost = 50; /* granite maul always 50% */ + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_combo = (has_gmaul && + target_hp_pct < opp->ko_threshold && + self->special_energy >= melee_spec_cost + gmaul_cost && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Also check ranged spec for variety (no gmaul follow-up, just raw spec) */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int should_ranged_spec = (ranged_spec != 0 && + target_hp_pct < opp->ko_threshold && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + rand_float(env) < 0.25f); /* 25% chance to use ranged spec */ + + /* Anti-kite: cancel melee combo if target fleeing */ + if ((should_combo || should_ranged_spec) && + opp->target_fleeing_ticks >= 2 && dist > 1) { + should_combo = 0; + should_ranged_spec = 0; + } + + if (should_combo) { + /* Fire melee spec → next tick gmaul follows */ + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_SPEC_FIRED; + } else if (should_ranged_spec) { + /* Ranged spec (no gmaul follow-up) */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Normal improved-style play */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Regular melee spec (DDS at tier 0, better at higher tiers) — no gmaul combo */ + int should_regular_spec = (!has_gmaul && + self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + target_hp_pct < 0.50f && + can_spec_range); + if (should_regular_spec && opp->target_fleeing_ticks < 2) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (attack_style == OPP_STYLE_MAGE) { + actions[HEAD_COMBAT] = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? ATTACK_ICE : ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target, farcast-3 for anti-kite */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Range Kiter: ranged-dominant fighter who maintains distance. + * Works at all tiers — rune crossbow does ranged damage at tier 0. + * Gains spec capability at higher tiers (ACB/ZCB/dark bow/morr jav). + * Maintains farcast-5, ice barrage to freeze, ranged primary (~60-70%). + * ========================================================================= */ + +static void opp_range_kiter(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)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + emergency blood barrage sustain */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: ranged-dominant with freeze support and ranged specs */ + if (opp_attack_ready(self) && !eating) { + /* Check ranged spec availability */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int has_ranged_spec = (ranged_spec != 0); + int ranged_spec_cost = has_ranged_spec + ? get_ranged_spec_cost(self->ranged_spec_weapon) : 100; + + /* Ranged spec: freeze → spec from distance is primary KO pattern */ + int should_ranged_spec = (has_ranged_spec && + self->special_energy >= ranged_spec_cost && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f); + + /* Anti-kite not needed for ranged — we WANT distance */ + + if (should_ranged_spec && (target->frozen_ticks > 0 || dist >= 3)) { + /* Fire ranged spec from distance */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Style selection: ranged-biased via style_bias (initialized with range preference) + * + force ranged at distance, force melee only when adjacent and frozen */ + int attack_style; + int force_melee = (self->frozen_ticks > 0 && dist <= 1); + int prefer_ranged = (dist >= 3 || target->frozen_ticks > 0); + + if (force_melee) { + attack_style = OPP_STYLE_MELEE; + } else if (prefer_ranged && rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_RANGED; + } else if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Emergency blood barrage healing */ + int actual_attack; + if (hp_pct < 0.30f && attack_style == OPP_STYLE_MAGE) { + actual_attack = 1; /* blood */ + } else if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? 0 : 2; /* ice if can freeze, else just ATK (ranged fallback) */ + if (actual_attack == 2) attack_style = OPP_STYLE_RANGED; /* fallback to ranged */ + } else { + actual_attack = 2; /* ATK */ + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Melee spec (DDS etc) when close — fallback */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int can_melee_spec = (self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + dist <= 1 && self->frozen_ticks == 0); + if (can_melee_spec && target_hp_pct < 0.40f && !has_ranged_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5, step back after freeze */ + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4) { + /* Maintain distance from approaching target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist > 7) { + /* Don't let them get too far — close to farcast-5 */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Mixed policy selection (MixedEasy/MixedMedium/MixedHard/MixedHardBalanced) + * ========================================================================= */ + +/* MixedEasy weights: panicking=0.18, true_random=0.18, weak_random=0.18, + semi_random=0.15, sticky_prayer=0.10, random_eater=0.10, prayer_rookie=0.06, + improved=0.05 */ +static const OpponentType MIXED_EASY_POOL[] = { + OPP_PANICKING, OPP_TRUE_RANDOM, OPP_WEAK_RANDOM, OPP_SEMI_RANDOM, + OPP_STICKY_PRAYER, OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_IMPROVED, +}; +/* Cumulative weights * 100 for integer comparison */ +static const int MIXED_EASY_CUM_WEIGHTS[] = {18, 36, 54, 69, 79, 89, 95, 100}; +#define MIXED_EASY_POOL_SIZE 8 + +/* MixedMedium weights: random_eater=0.25, prayer_rookie=0.20, sticky_prayer=0.20, + semi_random=0.15, improved=0.10, (patient deferred to Python) */ +static const OpponentType MIXED_MEDIUM_POOL[] = { + OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_STICKY_PRAYER, + OPP_SEMI_RANDOM, OPP_IMPROVED, +}; +static const int MIXED_MEDIUM_CUM_WEIGHTS[] = {25, 45, 65, 80, 100}; +#define MIXED_MEDIUM_POOL_SIZE 5 + +/* MixedHard: uniform over 5 policies (20% each) */ +static const OpponentType MIXED_HARD_POOL[] = { + OPP_IMPROVED, OPP_ONETICK, OPP_UNPREDICTABLE_IMPROVED, + OPP_UNPREDICTABLE_ONETICK, OPP_RANDOM_EATER, +}; +static const int MIXED_HARD_CUM_WEIGHTS[] = {20, 40, 60, 80, 100}; +#define MIXED_HARD_POOL_SIZE 5 + +/* MixedHardBalanced: random_eater=25%, improved=30%, unpredictable_improved=20%, + onetick=15%, unpredictable_onetick=10% */ +static const OpponentType MIXED_HARD_BALANCED_POOL[] = { + OPP_RANDOM_EATER, OPP_IMPROVED, OPP_UNPREDICTABLE_IMPROVED, + OPP_ONETICK, OPP_UNPREDICTABLE_ONETICK, +}; +static const int MIXED_HARD_BALANCED_CUM_WEIGHTS[] = {25, 55, 75, 90, 100}; +#define MIXED_HARD_BALANCED_POOL_SIZE 5 + +static OpponentType opp_select_from_pool( + 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]; +} + +/* ========================================================================= + * Main entry point: generate opponent action + * ========================================================================= */ + +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; + + /* Phase 2 state reset */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + opp->opponent_prayer_at_fake = -1; + opp->fake_switch_failed = 0; + opp->pending_prayer_value = 0; + opp->pending_prayer_delay = 0; + opp->last_target_gear_style = -1; + + /* Per-episode eating thresholds with noise */ + opp->eat_triple_threshold = 0.30f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_double_threshold = 0.50f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_brew_threshold = 0.70f + (rand_float(env) * 0.10f - 0.05f); + + /* Boss opponent reading ability — reset per-tick state */ + opp->has_read_this_tick = 0; + opp->read_agent_style = ATTACK_STYLE_NONE; + opp->read_agent_prayer = PRAYER_NONE; + opp->read_chance = 0.0f; + opp->read_agent_moving = 0; + opp->prev_dist_to_target = 0; + opp->target_fleeing_ticks = 0; + + /* Per-episode resets for specific policies */ + if (opp->type == OPP_PANICKING) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->chosen_prayer = prayers[rand_int(env, 3)]; + opp->chosen_style = rand_int(env, 3); + } + + /* Mixed policies: select sub-policy */ + if (opp->type == OPP_MIXED_EASY) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_EASY_POOL, MIXED_EASY_CUM_WEIGHTS, MIXED_EASY_POOL_SIZE); + } else if (opp->type == OPP_MIXED_MEDIUM) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_MEDIUM_POOL, MIXED_MEDIUM_CUM_WEIGHTS, MIXED_MEDIUM_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_POOL, MIXED_HARD_CUM_WEIGHTS, MIXED_HARD_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD_BALANCED) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_BALANCED_POOL, MIXED_HARD_BALANCED_CUM_WEIGHTS, + MIXED_HARD_BALANCED_POOL_SIZE); + } else if (opp->type == OPP_PFSP && env->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/src/osrs/osrs_render.h b/src/osrs/osrs_render.h new file mode 100644 index 0000000000..4097c65a43 --- /dev/null +++ b/src/osrs/osrs_render.h @@ -0,0 +1,4380 @@ +/** + * @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_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 + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define RENDER_TILE_SIZE 20 +#define RENDER_PANEL_WIDTH 320 +#define RENDER_HEADER_HEIGHT 40 +#define RENDER_SPLATS_PER_PLAYER 4 /* OSRS max: 4 simultaneous splats per entity */ +#define RENDER_HISTORY_SIZE 2000 /* max ticks of rewind history */ +#define MAX_RENDER_ENTITIES 64 /* max entities rendered (players + NPCs/bosses/adds) */ + +#define RENDER_GRID_W (FIGHT_AREA_WIDTH * RENDER_TILE_SIZE) +#define RENDER_GRID_H (FIGHT_AREA_HEIGHT * RENDER_TILE_SIZE) +#define RENDER_WINDOW_W (RENDER_GRID_W + RENDER_PANEL_WIDTH) +#define RENDER_WINDOW_H (RENDER_GRID_H + RENDER_HEADER_HEIGHT) + +/* colors */ +#define COLOR_BG CLITERAL(Color){ 20, 20, 25, 255 } +#define COLOR_GRID CLITERAL(Color){ 45, 45, 55, 255 } +#define COLOR_HEADER_BG CLITERAL(Color){ 30, 30, 40, 255 } +#define COLOR_PANEL_BG CLITERAL(Color){ 25, 25, 35, 255 } +#define COLOR_P0 CLITERAL(Color){ 80, 140, 255, 255 } +#define COLOR_P1 CLITERAL(Color){ 255, 90, 90, 255 } +#define COLOR_P0_LIGHT CLITERAL(Color){ 80, 140, 255, 60 } +#define COLOR_P1_LIGHT CLITERAL(Color){ 255, 90, 90, 60 } +#define COLOR_FREEZE CLITERAL(Color){ 100, 170, 255, 90 } +#define COLOR_VENG CLITERAL(Color){ 255, 220, 50, 255 } +#define COLOR_BLOCKED CLITERAL(Color){ 200, 50, 50, 50 } +#define COLOR_WALL CLITERAL(Color){ 220, 150, 40, 50 } +#define COLOR_BRIDGE CLITERAL(Color){ 50, 120, 220, 50 } +#define COLOR_WALL_LINE CLITERAL(Color){ 255, 180, 50, 180 } +#define COLOR_HP_GREEN CLITERAL(Color){ 50, 200, 50, 255 } +#define COLOR_HP_RED CLITERAL(Color){ 200, 50, 50, 255 } +#define COLOR_HP_BG CLITERAL(Color){ 40, 40, 40, 200 } +#define COLOR_SPEC_BAR CLITERAL(Color){ 230, 170, 30, 255 } +#define COLOR_TEXT CLITERAL(Color){ 200, 200, 210, 255 } +#define COLOR_TEXT_DIM CLITERAL(Color){ 130, 130, 140, 255 } +#define COLOR_LABEL CLITERAL(Color){ 170, 170, 180, 255 } + +/* ======================================================================== */ +/* active projectile flights (sub-tick interpolation at 50 Hz) */ +/* ======================================================================== */ + +/* OSRS projectile flight parameters (from deob client Projectile.java): + * x/y: linear interpolation from source to target + * height: parabolic arc — initial slope from 'curve' param, + * quadratic correction to hit end_height exactly. + * Zulrah attacks: delay=1, duration=35 client ticks, startH=85, endH=40, + * curve=16 (~22.5 degree launch angle). + * 1 client tick = 20ms, 1 server tick = 600ms = 30 client ticks. + */ + +#define MAX_FLIGHT_PROJECTILES 16 +#define PROJ_OSRS_SLOPE_TO_RAD 0.02454369f /* pi/128, converts OSRS slope units to radians */ + +typedef struct { + int active; + float src_x, src_y; /* source tile position */ + float dst_x, dst_y; /* target tile position (updated each tick if tracking) */ + float x, y; /* current interpolated position */ + float progress; /* 0.0 (spawned) → 1.0 (arrived) */ + float speed; /* progress per client tick (1.0/duration) */ + float start_height; /* height at source (tiles above ground) */ + float end_height; /* height at target (tiles above ground) */ + float curve; /* OSRS slope param (16 = ~22.5 degrees) */ + int style; /* 0=ranged, 1=magic, 2=melee, 3=cloud */ + int damage; /* hit splat value at arrival */ + + /* OSRS tracking: projectiles re-aim toward target each sub-tick */ + float vel_x, vel_y; /* current horizontal velocity (tiles per progress unit) */ + float height_vel; /* current vertical velocity */ + float height_accel; /* quadratic height correction */ + float yaw; /* current facing direction (radians) */ + float pitch; /* current vertical tilt (radians) */ + float arc_height; /* sinusoidal arc peak in tiles (0 = use quadratic) */ + int tracks_target; /* 1 = re-aim toward target each tick */ + int start_delay; /* client ticks before projectile becomes visible/moves */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ +} FlightProjectile; + +/* ======================================================================== */ +/* per-player composite model */ +/* ======================================================================== */ + +/* OSRS composites all body parts + equipment into a single merged model + * before animating. this ensures vertex skin label groups span the full + * body, so origin/pivot transforms compute correct centroids. + * we replicate that here: one composite mesh per player. */ + +#define COMPOSITE_MAX_BASE_VERTS 12000 /* ~16 models * ~750 base verts each */ +#define COMPOSITE_MAX_FACES 8000 /* ~16 models * ~500 faces each */ +#define COMPOSITE_MAX_EXP_VERTS (COMPOSITE_MAX_FACES * 3) + +typedef struct { + /* merged base geometry for animation */ + int16_t base_vertices[COMPOSITE_MAX_BASE_VERTS * 3]; + uint8_t vertex_skins[COMPOSITE_MAX_BASE_VERTS]; + uint16_t face_indices[COMPOSITE_MAX_FACES * 3]; + uint8_t face_pri_delta[COMPOSITE_MAX_FACES]; /* per-face priority delta relative to source model min */ + int base_vert_count; + int face_count; + + /* raylib mesh (pre-allocated at max capacity, updated per frame) */ + Mesh mesh; + Model model; + int gpu_ready; + + /* animation working state (rebuilt on equipment change) */ + AnimModelState* anim_state; + + /* change detection: last-seen equipment (players) or NPC def ID (NPCs) */ + uint8_t last_equipped[NUM_GEAR_SLOTS]; + int last_npc_def_id; + int needs_rebuild; +} PlayerComposite; + +/* ======================================================================== */ +/* convex hull click detection (ported from RuneLite Jarvis.java) */ +/* ======================================================================== */ + +#define HULL_MAX_POINTS 256 /* max hull vertices (models rarely exceed 100) */ + +typedef struct { + int xs[HULL_MAX_POINTS]; + int ys[HULL_MAX_POINTS]; + int count; +} ConvexHull2D; + +/** Jarvis march: compute 2D convex hull from screen-space points. + xs/ys are input arrays of length n. out is populated with the hull. + ported from RuneLite Jarvis.java. */ +static void hull_compute(const int* xs, const int* ys, int n, ConvexHull2D* out) { + out->count = 0; + if (n < 3) return; + + /* find leftmost point */ + int left = 0; + for (int i = 1; i < n; i++) { + if (xs[i] < xs[left] || (xs[i] == xs[left] && ys[i] < ys[left])) + left = i; + } + + int current = left; + do { + int cx = xs[current], cy = ys[current]; + if (out->count >= HULL_MAX_POINTS) return; + out->xs[out->count] = cx; + out->ys[out->count] = cy; + out->count++; + + /* safety: hull can't have more points than input */ + if (out->count > n) { out->count = 0; return; } + + int next = 0; + int nx = xs[0], ny = ys[0]; + for (int i = 1; i < n; i++) { + /* cross product: positive means i is to the left of current→next */ + long long cp = (long long)(ys[i] - cy) * (nx - xs[i]) + - (long long)(xs[i] - cx) * (ny - ys[i]); + if (cp > 0) { + next = i; nx = xs[i]; ny = ys[i]; + } else if (cp == 0) { + /* collinear: pick the farther point */ + long long d_i = (long long)(cx - xs[i]) * (cx - xs[i]) + + (long long)(cy - ys[i]) * (cy - ys[i]); + long long d_n = (long long)(cx - nx) * (cx - nx) + + (long long)(cy - ny) * (cy - ny); + if (d_i > d_n) { next = i; nx = xs[i]; ny = ys[i]; } + } + } + current = next; + } while (current != left); +} + +/** Point-in-polygon test (ray casting method). + returns 1 if (px, py) is inside the convex hull. */ +static int hull_contains(const ConvexHull2D* hull, int px, int py) { + if (hull->count < 3) return 0; + int inside = 0; + for (int i = 0, j = hull->count - 1; i < hull->count; j = i++) { + int xi = hull->xs[i], yi = hull->ys[i]; + int xj = hull->xs[j], yj = hull->ys[j]; + if (((yi > py) != (yj > py)) && + (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) + inside = !inside; + } + return inside; +} + +/* ======================================================================== */ +/* render client */ +/* ======================================================================== */ + +/* per-entity hitsplat slot matching OSRS Entity.java exactly: + - hitmarkMove starts at +5.0, decreases by 0.25/client-tick, clamps at -5.0 + - hitmarkTrans (opacity) starts at 230, stays there (mode 2 never fades) + - hitsLoopCycle: expires after 70 client ticks + - slot layout from Client.java:6052: slot 0=center, 1=up20, 2=left15+up10, 3=right15+up10 */ +typedef struct { + int active; + int damage; + double hitmark_move; /* OSRS hitmarkMove: starts +5, decrements to -5 */ + int hitmark_trans; /* OSRS hitmarkTrans: opacity 0-230, starts 230 */ + int ticks_remaining; /* counts down from 70 client ticks */ +} HitSplat; + +/* ======================================================================== */ +/* right-click context menu (OSRS-style) */ +/* ======================================================================== */ + +#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]; + + /* 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 16 + struct { uint32_t id; Model model; int ready; } proj_models[MAX_PROJ_MODELS]; + int proj_model_count; + + /* collision map: pointer to env's CollisionMap (shared, not owned). + world offset translates arena coords to collision map world coords. */ + const CollisionMap* collision_map; + int collision_world_offset_x; + int collision_world_offset_y; + + /* tick pacing */ + double last_tick_time; + + /* rewind history: ring buffer of env snapshots */ + 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; +} + +/* ======================================================================== */ +/* coordinate helpers */ +/* ======================================================================== */ + +static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x) { + return (world_x - rc->arena_base_x) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y_rc(RenderClient* rc, int world_y) { + /* flip Y: OSRS Y increases north, screen Y increases down */ + int local_y = world_y - rc->arena_base_y; + int flipped = (rc->arena_height - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* legacy wrappers using default FIGHT_AREA bounds */ +static inline int render_world_to_screen_x(int world_x) { + return (world_x - FIGHT_AREA_BASE_X) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y(int world_y) { + int local_y = world_y - FIGHT_AREA_BASE_Y; + int flipped = (FIGHT_AREA_HEIGHT - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* forward declarations for composite model system (defined after lifecycle) */ +static void composite_free(PlayerComposite* comp); +static int render_select_secondary(RenderClient* rc, int player_idx); + +/* forward declaration: inferno_npc_name is defined later in drawing section */ +static const char* inferno_npc_name(int npc_def_id); + +/* ======================================================================== */ +/* right-click context menu helpers */ +/* ======================================================================== */ + +/** 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); +} + +/** 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, int mx, int my) { + ContextMenu* cm = &rc->context_menu; + 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++) { + if (ei == rc->gui.gui_entity_idx) continue; /* skip self */ + RenderEntity* ent = &rc->entities[ei]; + if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) 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++) { + if (ei == rc->gui.gui_entity_idx) continue; + RenderEntity* ent = &rc->entities[ei]; + if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) 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_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; + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + 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); + } +} + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static RenderClient* render_make_client(void) { + RenderClient* rc = (RenderClient*)calloc(1, sizeof(RenderClient)); + rc->ticks_per_second = 1.667f; /* OSRS game tick = 600ms */ + rc->last_tick_time = 0.0; + rc->model_scale = 0.15f; /* ~20px tile / ~150 model units */ + rc->zoom = 1.0f; + rc->arena_base_x = FIGHT_AREA_BASE_X; + rc->arena_base_y = FIGHT_AREA_BASE_Y; + rc->arena_width = FIGHT_AREA_WIDTH; + rc->arena_height = FIGHT_AREA_HEIGHT; + rc->mode_3d = 1; + rc->show_safe_spots = 0; + rc->show_debug = 0; + rc->cam_yaw = 0.0f; + rc->cam_pitch = 0.6f; /* ~34 degrees, similar to OSRS default */ + rc->cam_dist = 40.0f; + /* fight area center (Z negated: OSRS +Y = north maps to -Z) */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + rc->history = (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 */ + rc->gui.inv_dim_slot = -1; + rc->gui.inv_drag_src_slot = -1; + rc->gui.inv_drag_active = 0; + rc->gui.inv_grid_dirty = 1; + rc->gui.human_clicked_inv_slot = -1; + + /* human input control */ + human_input_init(&rc->human_input); + + /* 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 a projectile model by GFX model ID. + * Searches both model_cache and npc_model_cache. Returns NULL if not found + * or if model_id is 0 (style-based fallback). */ +static Model* render_get_proj_model(RenderClient* rc, uint32_t model_id) { + if (model_id == 0) return NULL; + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].id == model_id) + return rc->proj_models[i].ready ? &rc->proj_models[i].model : NULL; + } + if (rc->proj_model_count >= MAX_PROJ_MODELS) return NULL; + int idx = rc->proj_model_count++; + rc->proj_models[idx].id = model_id; + rc->proj_models[idx].ready = render_build_static_model( + rc->model_cache, model_id, &rc->proj_models[idx].model); + if (!rc->proj_models[idx].ready && rc->npc_model_cache) { + rc->proj_models[idx].ready = render_build_static_model( + rc->npc_model_cache, model_id, &rc->proj_models[idx].model); + } + return rc->proj_models[idx].ready ? &rc->proj_models[idx].model : NULL; +} + +/** + * Build all overlay models (clouds, projectiles, snakelings) from the model cache. + * Call after model_cache is loaded. + */ +static void render_init_overlay_models(RenderClient* rc) { + if (!rc->model_cache) return; + + rc->cloud_model_ready = render_build_static_model( + rc->model_cache, GFX_TOXIC_CLOUD_MODEL, &rc->cloud_model); + rc->snakeling_model_ready = render_build_static_model( + rc->model_cache, SNAKELING_MODEL_ID, &rc->snakeling_model); + rc->ranged_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_RANGED_PROJ_MODEL, &rc->ranged_proj_model); + rc->magic_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_MAGIC_PROJ_MODEL, &rc->magic_proj_model); + + rc->cloud_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_CLOUD_PROJ_MODEL, &rc->cloud_proj_model); + { + uint32_t pillar_ids[4] = { INF_PILLAR_MODEL_100, INF_PILLAR_MODEL_75, + INF_PILLAR_MODEL_50, INF_PILLAR_MODEL_25 }; + rc->pillar_models_ready = 1; + for (int i = 0; i < 4; i++) { + if (!render_build_static_model(rc->model_cache, pillar_ids[i], &rc->pillar_models[i])) + rc->pillar_models_ready = 0; + } + } + + if (rc->cloud_model_ready) printf("overlay: cloud model loaded\n"); + if (rc->pillar_models_ready) printf("overlay: pillar models loaded (4 HP levels)\n"); + if (rc->snakeling_model_ready) printf("overlay: snakeling model loaded\n"); + if (rc->ranged_proj_model_ready) printf("overlay: ranged projectile model loaded\n"); + if (rc->magic_proj_model_ready) printf("overlay: magic projectile model loaded\n"); + if (rc->cloud_proj_model_ready) printf("overlay: cloud projectile model loaded\n"); +} + +/* ======================================================================== */ +/* projectile flight system */ +/* ======================================================================== */ + +/** + * Spawn a flight projectile with OSRS-accurate parabolic arc and target tracking. + * + * Matches Projectile.java setDestination(): + * - position re-computed each sub-tick toward current target + * - yaw/pitch updated from velocity vector each tick + * - height follows parabolic arc with quadratic correction + */ +static void flight_spawn(RenderClient* rc, + float src_x, float src_y, float dst_x, float dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, uint32_t model_id, + int start_delay) { + 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->start_delay = start_delay; + + /* height arc: OSRS SceneProjectile.calculateIncrements + skip quadratic computation when using sinusoidal arc */ + float dx = dst_x - src_x, dy = dst_y - src_y; + float dist = sqrtf(dx * dx + dy * dy); + if (dist < 0.01f) dist = 1.0f; + if (arc_height > 0.0f) { + fp->height_vel = 0.0f; + fp->height_accel = 0.0f; + } else { + fp->height_vel = -dist * tanf(curve * PROJ_OSRS_SLOPE_TO_RAD); + fp->height_accel = 2.0f * (fp->end_height - fp->start_height - fp->height_vel); + } + + /* initial facing */ + fp->yaw = atan2f(dx, dy); + fp->pitch = (arc_height > 0.0f) ? 0.0f : atan2f(fp->height_vel, dist); +} + +/** + * Advance all active flights by one client tick (20ms). + * + * Matches OSRS Projectile.setDestination() tracking: + * remaining = (cycleEnd - currentCycle) + * vel = (target - current) / remaining + * orientation = atan2(vel_x, vel_y) + * pitch = atan2(height_vel, horiz_speed) + */ +static void flight_client_tick(RenderClient* rc) { + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + + /* start delay: count down before projectile becomes visible/moves */ + if (fp->start_delay > 0) { + fp->start_delay--; + continue; + } + + /* remaining sub-ticks (avoid div by zero) */ + float remaining = (1.0f - fp->progress) / fp->speed; + if (remaining < 0.5f) remaining = 0.5f; + + /* re-aim velocity toward current target (OSRS tracking) */ + float vx = (fp->dst_x - fp->x) / remaining; + float vy = (fp->dst_y - fp->y) / remaining; + float horiz_speed = sqrtf(vx * vx + vy * vy); + + fp->x += vx; + fp->y += vy; + + /* update facing from velocity vector */ + if (horiz_speed > 0.001f) { + fp->yaw = atan2f(vx, vy); + float h_vel = fp->height_vel + fp->height_accel * fp->progress; + fp->pitch = atan2f(h_vel, horiz_speed); + } + + fp->progress += fp->speed; + if (fp->progress >= 1.0f) { + fp->active = 0; + } + } +} + +/** + * Get the interpolated world position of a flight projectile. + */ +static Vector3 flight_get_position(const FlightProjectile* fp, float src_ground, float dst_ground) { + float t = fp->progress; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + + float ground = src_ground + (dst_ground - src_ground) * t; + float h; + if (fp->arc_height > 0.0f) { + /* sinusoidal arc (from InfernoTrainer ArcProjectileMotionInterpolator) */ + h = sinf(t * 3.14159265f) * fp->arc_height + + fp->start_height + (fp->end_height - fp->start_height) * t; + } else { + /* quadratic arc (OSRS SceneProjectile) */ + h = fp->start_height + fp->height_vel * t + 0.5f * fp->height_accel * t * t; + } + + return (Vector3){ fp->x + 0.5f, ground + h, -(fp->y + 1.0f) + 0.5f }; +} + +static void render_destroy_client(RenderClient* rc) { + /* free GUI panel sprites */ + gui_unload_sprites(&rc->gui); + /* free prayer icon textures */ + if (rc->prayer_icons_loaded) { + for (int i = 0; i < 6; i++) { + UnloadTexture(rc->prayer_icons[i]); + } + } + /* free hitsplat sprite textures */ + if (rc->hitmark_sprites_loaded) { + for (int i = 0; i < 5; i++) { + UnloadTexture(rc->hitmark_sprites[i]); + } + } + /* free click cross sprite textures */ + if (rc->click_cross_loaded) { + for (int i = 0; i < 8; i++) { + UnloadTexture(rc->click_cross_sprites[i]); + } + } + /* free overlay models */ + if (rc->cloud_model_ready) UnloadModel(rc->cloud_model); + if (rc->snakeling_model_ready) UnloadModel(rc->snakeling_model); + if (rc->ranged_proj_model_ready) UnloadModel(rc->ranged_proj_model); + if (rc->magic_proj_model_ready) UnloadModel(rc->magic_proj_model); + if (rc->cloud_proj_model_ready) UnloadModel(rc->cloud_proj_model); + if (rc->pillar_models_ready) { + for (int i = 0; i < 4; i++) UnloadModel(rc->pillar_models[i]); + } + /* free dynamic projectile model cache */ + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].ready) UnloadModel(rc->proj_models[i].model); + } + /* free per-entity composite models */ + for (int p = 0; p < MAX_RENDER_ENTITIES; p++) { + composite_free(&rc->composites[p]); + } + if (rc->model_cache) { + model_cache_free(rc->model_cache); + rc->model_cache = NULL; + } + if (rc->anim_cache) { + anim_cache_free(rc->anim_cache); + rc->anim_cache = NULL; + } + if (rc->terrain) { + terrain_free(rc->terrain); + rc->terrain = NULL; + } + if (rc->objects) { + objects_free(rc->objects); + rc->objects = NULL; + } + if (rc->objects_zuk) { + objects_free(rc->objects_zuk); + rc->objects_zuk = NULL; + } + if (rc->npcs) { + objects_free(rc->npcs); + rc->npcs = NULL; + } + CloseWindow(); + free(rc->history); + free(rc); +} + +/* ======================================================================== */ +/* input */ +/* ======================================================================== */ + +static void render_handle_input(RenderClient* rc, OsrsEnv* 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 */ + 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) { + 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++) { + if (ei == rc->gui.gui_entity_idx) continue; + RenderEntity* ent = &rc->entities[ei]; + if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + if (hull_contains(&rc->entity_hulls[ei], mx, my)) { + rc->human_input.pending_attack = 1; + rc->human_input.pending_target_idx = rc->entities[ei].npc_slot; + /* attack cancels movement — server stops walking to old dest */ + rc->human_input.pending_move_x = -1; + rc->human_input.pending_move_y = -1; + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.pending_spell = rc->human_input.selected_spell; + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(&rc->human_input, mx, my, 1); + entity_hit = 1; + break; + } + } + + if (entity_hit) { /* already handled */ } + else { + /* 3D ground click: ray-box intersection against actual tile cube geometry */ + Camera3D cam = render_build_3d_camera(rc); + Ray ray = GetScreenToWorldRay((Vector2){ (float)mx, (float)my }, cam); + rc->debug_ray_origin = ray.position; + rc->debug_ray_dir = ray.direction; + + /* ray-box intersection against tile cubes at actual ground height. + when terrain is loaded, each tile sits at terrain_height_avg(). + when no terrain (flat encounters), tiles sit at plat_y=2.0. */ + float best_dist = 1e30f; + int best_wx = -1, best_wy = -1; + for (int dy = 0; dy < rc->arena_height; dy++) { + for (int dx = 0; dx < rc->arena_width; dx++) { + int wx = rc->arena_base_x + dx; + int wy = rc->arena_base_y + dy; + float tx = (float)wx; + float tz = -(float)(wy + 1); + float ground_y = rc->terrain + ? terrain_height_avg(rc->terrain, wx, wy) + : 2.0f; + BoundingBox box = { + .min = { tx, ground_y - 0.1f, tz }, + .max = { tx + 1.0f, ground_y, tz + 1.0f }, + }; + RayCollision col = GetRayCollisionBox(ray, box); + if (col.hit && col.distance < best_dist) { + best_dist = col.distance; + best_wx = wx; + best_wy = wy; + rc->debug_ray_hit_x = col.point.x; + rc->debug_ray_hit_y = col.point.y; + rc->debug_ray_hit_z = col.point.z; + } + } + } + rc->debug_hit_wx = best_wx; + rc->debug_hit_wy = best_wy; + rc->debug_plane_wx = -1; + rc->debug_plane_wy = -1; + if (best_wx >= 0) { + /* ground click: only movement, skip entity check (hull handles that) */ + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } else { + rc->human_input.pending_move_x = best_wx; + rc->human_input.pending_move_y = best_wy; + human_set_click_cross(&rc->human_input, mx, my, 0); + } + } + } /* end else (ground click) */ + } else { + human_handle_ground_click(&rc->human_input, mx, my, + rc->arena_base_x, rc->arena_base_y, + rc->arena_width, rc->arena_height, + rc->entities, rc->entity_count, + rc->gui.gui_entity_idx, + RENDER_TILE_SIZE, RENDER_HEADER_HEIGHT); + } + } + } + + /* right-click: 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, 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; + } + } + } + } + } +} + +/* ======================================================================== */ +/* rewind history */ +/* ======================================================================== */ + +/* 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); + +/* ======================================================================== */ +/* entity population */ +/* ======================================================================== */ + +/* populate rc->entities from env->players (legacy) or encounter vtable. + call before render_post_tick and pvp_render so all draw code uses rc->entities. + uses fill_render_entities when available, falls back to get_entity + cast. */ +static void render_populate_entities(RenderClient* rc, 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; } + } + /* debug: print entity info on first populate */ + static int debug_once = 1; + if (debug_once && count > 0) { + debug_once = 0; + fprintf(stderr, "render_populate: %d entities\n", count); + for (int di = 0; di < count && di < 5; di++) { + fprintf(stderr, " [%d] type=%d npc_id=%d visible=%d size=%d pos=(%d,%d) hp=%d/%d\n", + di, rc->entities[di].entity_type, rc->entities[di].npc_def_id, + rc->entities[di].npc_visible, rc->entities[di].npc_size, + rc->entities[di].x, rc->entities[di].y, + rc->entities[di].current_hitpoints, rc->entities[di].base_hitpoints); + } + } + } else { + /* legacy fallback: cast get_entity to Player* */ + int count = def->get_entity_count(env->encounter_state); + if (count > MAX_RENDER_ENTITIES) count = MAX_RENDER_ENTITIES; + rc->entity_count = count; + for (int i = 0; i < count; i++) { + Player* p = (Player*)def->get_entity(env->encounter_state, i); + if (p) render_entity_from_player(p, &rc->entities[i]); + } + } + /* override arena bounds from encounter if set */ + if (def->arena_width > 0 && def->arena_height > 0) { + rc->arena_base_x = def->arena_base_x; + rc->arena_base_y = def->arena_base_y; + rc->arena_width = def->arena_width; + rc->arena_height = def->arena_height; + } + } else { + rc->entity_count = NUM_AGENTS; + for (int i = 0; i < NUM_AGENTS; i++) { + render_entity_from_player(&env->players[i], &rc->entities[i]); + } + } +} + +/* ======================================================================== */ +/* tick notification: position tracking, facing, effects */ +/* ======================================================================== */ + +/** + * Call BEFORE pvp_step to record pre-tick positions for movement direction. + */ +static void render_pre_tick(RenderClient* rc, 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]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) { + /* trident/sang/ayak: powered staff projectile */ + effect_spawn_projectile(rc->effects, GFX_TRIDENT_PROJ, + p->x, p->y, t->x, t->y, + 0, 40, 40 * 4, 30 * 4, 16, ct, rc->model_cache); + } else if (p->magic_type_this_tick == 1) { + /* ice barrage: projectile orb rises from target tile + heights *4 per reference (stream.readUnsignedByte() * 4) */ + effect_spawn_projectile(rc->effects, GFX_ICE_BARRAGE_PROJ, + t->x, t->y, t->x, t->y, /* src=dst (rises in place) */ + 0, 56, 43 * 4, 0, 16, ct, rc->model_cache); + } + /* blood barrage: no projectile, impact spawns on hit */ + } + + /* attacker fired a ranged attack this tick */ + if (p->attack_style_this_tick == ATTACK_STYLE_RANGED) { + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + int gfx; + if (wpn == ITEM_TOXIC_BLOWPIPE) { + gfx = GFX_DRAGON_DART; + } else if (wpn == ITEM_MAGIC_SHORTBOW_I || wpn == ITEM_DARK_BOW || + wpn == ITEM_BOW_OF_FAERDHINEN || wpn == ITEM_TWISTED_BOW) { + gfx = GFX_RUNE_ARROW; + } else { + gfx = GFX_BOLT; /* crossbows, default */ + } + /* heights *4 per reference: 43*4=172 start, 31*4=124 end */ + effect_spawn_projectile(rc->effects, gfx, + p->x, p->y, t->x, t->y, + 0, 40, 43 * 4, 31 * 4, 16, ct, rc->model_cache); + } + } + + /* defender: check what landed on entity p this tick. + for NPC defenders, the attacker is entity 0 (the player). + for player (entity 0), attacker is the current target entity. */ + if (p->hit_landed_this_tick) { + RenderEntity* att; + if (i == 0) { + att = t; /* player was hit — attacker is target entity */ + } else { + att = &rc->entities[0]; /* NPC was hit — attacker is player */ + } + + /* check if attacker used a powered staff (trident/sang/ayak) */ + uint8_t att_wpn = att->equipped[GEAR_SLOT_WEAPON]; + int att_is_powered_staff = (att_wpn == ITEM_TRIDENT_OF_SWAMP || + att_wpn == ITEM_SANGUINESTI_STAFF || att_wpn == ITEM_EYE_OF_AYAK); + + if (att_is_powered_staff && att->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staff hit: trident impact splash */ + if (p->hit_was_successful) { + effect_spawn_spotanim(rc->effects, GFX_TRIDENT_IMPACT, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim(rc->effects, GFX_SPLASH, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } + } else { + /* barrage impact: use hit_spell_type (set when pending hit resolves) + instead of magic_type_this_tick (stale by deferred hit landing). + ENCOUNTER_SPELL_ICE=1 -> ice barrage, ENCOUNTER_SPELL_BLOOD=2 -> blood. */ + /* use hit_spell_type from pending hit resolution only. the magic_type_this_tick + fallback caused blood/ice effects on tbow hits when barrage fired same tick. */ + int spell = p->hit_spell_type; + if (spell > 0) { + /* center effect on NPC footprint center using sub-tile precision. + for size 2: center at (x*128 + 128, y*128 + 128) = between 4 tiles. + for size 3: center at (x*128 + 192, y*128 + 192) = middle tile center. */ + float fx = (float)p->x * 128.0f + (float)p->npc_size * 64.0f; + float fy = (float)p->y * 128.0f + (float)p->npc_size * 64.0f; + if (p->hit_was_successful) { + int gfx = (spell == 1) /* ENCOUNTER_SPELL_ICE */ + ? GFX_ICE_BARRAGE_HIT : GFX_BLOOD_BARRAGE_HIT; + effect_spawn_spotanim_subtile(rc->effects, gfx, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim_subtile(rc->effects, GFX_SPLASH, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } + } + } + } + } + + /* update encounter overlay (clouds, boss state) */ + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + if (edef->render_post_tick) { + edef->render_post_tick(env->encounter_state, &rc->encounter_overlay); + + /* spawn flight projectiles from overlay events. + per-projectile params with backward-compat defaults. */ + EncounterOverlay* ov = &rc->encounter_overlay; + for (int i = 0; i < ov->projectile_count; i++) { + if (!ov->projectiles[i].active) continue; + int src_sz = ov->projectiles[i].src_size > 0 ? ov->projectiles[i].src_size : ov->boss_size; + int dst_sz = ov->projectiles[i].dst_size > 0 ? ov->projectiles[i].dst_size : 1; + float sx = (float)ov->projectiles[i].src_x + (float)(src_sz - 1) / 2.0f + 0.5f; + float sy = (float)ov->projectiles[i].src_y + (float)(src_sz - 1) / 2.0f + 0.5f; + float dx = (float)ov->projectiles[i].dst_x + (float)(dst_sz - 1) / 2.0f + 0.5f; + float dy = (float)ov->projectiles[i].dst_y + (float)(dst_sz - 1) / 2.0f + 0.5f; + + /* use per-projectile params, with defaults for backward compat */ + int dur = ov->projectiles[i].duration_ticks > 0 ? ov->projectiles[i].duration_ticks : 35; + int sh = ov->projectiles[i].start_h > 0 ? ov->projectiles[i].start_h : 85; + int eh = ov->projectiles[i].end_h > 0 ? ov->projectiles[i].end_h : 40; + int cv = ov->projectiles[i].curve > 0 ? ov->projectiles[i].curve : 16; + float arc = ov->projectiles[i].arc_height; + int trk = ov->projectiles[i].tracks_target; + + /* cloud/orb styles: offset dst to tile center */ + if (ov->projectiles[i].style == 3 || ov->projectiles[i].style == 4) { + dx += 0.5f; + dy += 0.5f; + } + + flight_spawn(rc, sx, sy, dx, dy, + ov->projectiles[i].style, ov->projectiles[i].damage, + dur, sh, eh, cv, arc, trk, ov->projectiles[i].model_id, + ov->projectiles[i].start_delay); + } + + /* 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; + } +} + +/* ======================================================================== */ +/* hit splats */ +/* ======================================================================== */ + +/* advance splat animation by one client tick (20ms). + exact OSRS logic from Client.java:6107-6143 (mode 2 animated): + - hitmarkMove starts at +5.0, decrements by 0.25 until -5.0 (40 ticks to settle) + - hitmarkTrans starts at 230, stays there (mode 2 clamp means fade at -26 never fires) + - hitsLoopCycle expires after 70 client ticks → splat just disappears */ +static void render_update_splats_client_tick(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + /* OSRS splats stay in place — no vertical drift */ + s->ticks_remaining--; + if (s->ticks_remaining <= 0) { + s->active = 0; + } + } + } +} + +/* OSRS Entity.damage(): find first expired slot, init with standard values */ +static void render_push_splat(RenderClient* rc, int damage, int pidx) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (!rc->splats[pidx][i].active) { + rc->splats[pidx][i] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; + return; + } + } + /* all 4 slots full: overwrite the one closest to expiry */ + int oldest = 0; + for (int i = 1; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (rc->splats[pidx][i].ticks_remaining < rc->splats[pidx][oldest].ticks_remaining) + oldest = i; + } + rc->splats[pidx][oldest] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; +} + + +/* ======================================================================== */ +/* drawing: grid */ +/* ======================================================================== */ + +static void render_draw_grid(RenderClient* rc, 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) continue; + float t = fp->progress; + float cur_x = fp->src_x + (fp->dst_x - fp->src_x) * t; + float cur_y = fp->src_y + (fp->dst_y - fp->src_y) * t; + int psx = render_world_to_screen_x_rc(rc, (int)fp->src_x) + ts / 2; + int psy = render_world_to_screen_y_rc(rc, (int)fp->src_y) + ts / 2; + int pcx = render_world_to_screen_x_rc(rc, (int)cur_x) + ts / 2; + int pcy = render_world_to_screen_y_rc(rc, (int)cur_y) + ts / 2; + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 255 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 255 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 255 }; break; + default: pc = WHITE; break; + } + DrawLine(psx, psy, pcx, pcy, pc); + DrawCircle(pcx, pcy, 4.0f, pc); + } + } +} + +/* ======================================================================== */ +/* drawing: players */ +/* ======================================================================== */ + +static const char* render_prayer_label(OverheadPrayer p) { + switch (p) { + case PRAYER_PROTECT_MAGIC: return "Ma"; + case PRAYER_PROTECT_RANGED: return "Ra"; + case PRAYER_PROTECT_MELEE: return "Me"; + case PRAYER_SMITE: return "Sm"; + case PRAYER_REDEMPTION: return "Re"; + default: return NULL; + } +} + +static const char* render_gear_label(GearSet g) { + switch (g) { + case GEAR_MELEE: return "M"; + case GEAR_RANGED: return "R"; + case GEAR_MAGE: return "Ma"; + case GEAR_SPEC: return "S"; + case GEAR_TANK: return "T"; + default: return "?"; + } +} + +/* draw zulrah safe spot markers on the 2D grid. + S key toggles. shows all 15 stand locations as colored diamonds, + with the current phase's active stand/stall highlighted. */ +static void render_draw_safe_spots(RenderClient* rc, 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); + } + } +} + +/* ======================================================================== */ +/* drawing: destination markers */ +/* ======================================================================== */ + +static void render_draw_dest_markers(RenderClient* rc) { + int ts = RENDER_TILE_SIZE; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + Color dest_color = (i == 0) ? COLOR_P0_LIGHT : COLOR_P1_LIGHT; + if (p->dest_x != p->x || p->dest_y != p->y) { + int sx = render_world_to_screen_x_rc(rc, p->dest_x); + int sy = render_world_to_screen_y_rc(rc, p->dest_y); + DrawRectangleLines(sx + 1, sy + 1, ts - 2, ts - 2, dest_color); + } + } +} + +/* ======================================================================== */ +/* drawing: splats */ +/* ======================================================================== */ + +/* draw a hitsplat using the actual cache sprites (317 mode 0). + Client.java:6052-6073: hitMarks[type].drawSprite(spriteDrawX - 12, spriteDrawY - 12) + then smallFont.drawText centered on the sprite. + sprite index: 0=blue(miss), 1=red(regular hit). sprites are 24x23px. */ +static void render_draw_hitmark(RenderClient* rc, int cx, int cy, int damage, int opacity) { + unsigned char a = (unsigned char)(opacity > 255 ? 255 : (opacity < 0 ? 0 : opacity)); + int sprite_idx = (damage > 0) ? 1 : 0; /* red for hits, blue for misses */ + + if (rc->hitmark_sprites_loaded) { + /* draw the actual cache sprite, centered at (cx, cy). + OSRS draws at spriteDrawX-12, spriteDrawY-12 (centering a 24x23 sprite) */ + Texture2D tex = rc->hitmark_sprites[sprite_idx]; + float draw_x = (float)cx - (float)tex.width / 2.0f; + float draw_y = (float)cy - (float)tex.height / 2.0f; + DrawTexture(tex, (int)draw_x, (int)draw_y, (Color){ 255, 255, 255, a }); + } else { + /* fallback: colored circle if sprites missing */ + Color bg = (damage > 0) ? (Color){ 175, 25, 25, a } : (Color){ 65, 105, 225, a }; + DrawCircle(cx, cy, 12.0f, bg); + } + + /* damage number: white text with black shadow, centered on the sprite. + OSRS Client.java:6070-6071: smallFont.drawText at spriteDrawY+5, spriteDrawX */ + const char* txt = TextFormat("%d", damage); + int tw = MeasureText(txt, 10); + DrawText(txt, cx - tw / 2 + 1, cy - 4, 10, (Color){ 0, 0, 0, a }); + DrawText(txt, cx - tw / 2, cy - 5, 10, (Color){ 255, 255, 255, a }); +} + +/* slot offset layout from Client.java:6052-6072 (mode 0, used across modes): + slot 0: center + slot 1: up 20px + slot 2: left 15px, up 10px + slot 3: right 15px, up 10px */ +static void render_splat_slot_offset(int slot, int* dx, int* dy) { + switch (slot) { + case 0: *dx = 0; *dy = 0; break; + case 1: *dx = 0; *dy = -20; break; + case 2: *dx = -15; *dy = -10; break; + case 3: *dx = 15; *dy = -10; break; + default: *dx = 0; *dy = 0; break; + } +} + +/* 2D mode: draw splats at entity tile positions */ +static void render_draw_splats_2d(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + RenderEntity* pl = &rc->entities[p]; + int base_x = render_world_to_screen_x_rc(rc, pl->x) + RENDER_TILE_SIZE / 2; + int base_y = render_world_to_screen_y_rc(rc, pl->y) + RENDER_TILE_SIZE / 2; + + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + int slot_dx, slot_dy; + render_splat_slot_offset(i, &slot_dx, &slot_dy); + int sx = base_x + slot_dx; + int sy = base_y + slot_dy + (int)s->hitmark_move; + render_draw_hitmark(rc, sx, sy, s->damage, s->hitmark_trans); + } + } +} + +/* ======================================================================== */ +/* drawing: header */ +/* ======================================================================== */ + +static void render_draw_header(RenderClient* rc, 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); + } +} + +/* ======================================================================== */ +/* drawing: NPC/boss info panel (below GUI tabs) */ +/* ======================================================================== */ + +/** Look up inferno NPC name from npc_def_id. returns NULL if not an inferno NPC. */ +static const char* inferno_npc_name(int npc_def_id) { + switch (npc_def_id) { + case 7691: return "Jal-Nib"; + case 7692: return "Jal-MejRah"; + case 7693: return "Jal-Ak"; + case 7694: return "Jal-AkRek-Ket"; + case 7695: return "Jal-AkRek-Xil"; + case 7696: return "Jal-AkRek-Mej"; + case 7697: return "Jal-ImKot"; + case 7698: return "Jal-Xil"; + case 7699: return "Jal-Zek"; + case 7700: return "JalTok-Jad"; + case 7701: return "Yt-HurKot"; + case 7706: return "TzKal-Zuk"; + case 7707: return "Ancestral Glyph"; + case 7708: return "Jal-MejJak"; + default: return NULL; + } +} + +static void render_draw_panel_npc(int x, int y, RenderEntity* p, 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 */ + +/* ======================================================================== */ +/* drawing: 3D world mode */ +/* ======================================================================== */ + +static Camera3D render_build_3d_camera(RenderClient* rc) { + Camera3D cam = { 0 }; + float cx = rc->cam_target_x; + float cz = rc->cam_target_z; + /* sample terrain height at camera target (heightmap uses OSRS coords, negate Z back) */ + float cy = (rc->terrain) ? terrain_height_at(rc->terrain, (int)cx, (int)(-cz)) : 2.0f; + + float d = rc->cam_dist; + float px = cx + d * cosf(rc->cam_pitch) * sinf(rc->cam_yaw); + float py = cy + d * sinf(rc->cam_pitch); + float pz = cz + d * cosf(rc->cam_pitch) * cosf(rc->cam_yaw); + + cam.position = (Vector3){ px, py, pz }; + cam.target = (Vector3){ cx, cy, cz }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = 60.0f; + cam.projection = CAMERA_PERSPECTIVE; + return cam; +} + +/* ======================================================================== */ +/* animation selection */ +/* ======================================================================== */ + +/* animation sequence IDs (from OSRS 317 cache via export_animations.py) */ +#define ANIM_SEQ_IDLE 808 +#define ANIM_SEQ_WALK 819 +#define ANIM_SEQ_RUN 824 +#define ANIM_SEQ_EAT 829 +#define ANIM_SEQ_DEATH 836 +#define ANIM_SEQ_CAST_STANDARD 1162 +#define ANIM_SEQ_CAST_BARRAGE 1979 +#define ANIM_SEQ_CAST_VENG 4410 +#define ANIM_SEQ_BLOCK_SHIELD 1156 +#define ANIM_SEQ_BLOCK_MELEE 424 + +/** + * Get the attack animation ID for a weapon item (database index). + * Returns normal attack anim, or special attack anim if is_special. + */ +static int render_get_attack_anim(uint8_t weapon_db_idx, int is_special) { + if (weapon_db_idx >= NUM_ITEMS) return 422; /* generic punch */ + + switch (weapon_db_idx) { + case ITEM_WHIP: return 1658; + case ITEM_GHRAZI_RAPIER: return 8145; + case ITEM_INQUISITORS_MACE: return is_special ? 1060 : 400; + case ITEM_STAFF_OF_DEAD: return 414; + case ITEM_KODAI_WAND: return 414; + case ITEM_VOLATILE_STAFF: return is_special ? 8532 : 414; + case ITEM_AHRIM_STAFF: return 393; + case ITEM_ZURIELS_STAFF: return 393; + case ITEM_DRAGON_DAGGER: return is_special ? 1062 : 376; + case ITEM_DRAGON_CLAWS: return is_special ? 7514 : 393; + case ITEM_AGS: return is_special ? 7644 : 7045; + case ITEM_ANCIENT_GS: return is_special ? 7644 : 7045; + case ITEM_GRANITE_MAUL: return is_special ? 1667 : 1665; + case ITEM_ELDER_MAUL: return 7516; + case ITEM_STATIUS_WARHAMMER: return is_special ? 1378 : 401; + case ITEM_VOIDWAKER: return is_special ? 1378 : 401; + case ITEM_VESTAS: return is_special ? 7515 : 390; + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: return 4230; + case ITEM_DARK_BOW: return 426; + case ITEM_HEAVY_BALLISTA: return 7218; + case ITEM_MORRIGANS_JAVELIN: return 806; + /* zulrah encounter weapons */ + case ITEM_TRIDENT_OF_SWAMP: return 1167; /* HUMAN_CASTWAVE_STAFF */ + case ITEM_SANGUINESTI_STAFF: return 1167; + case ITEM_EYE_OF_AYAK: return 1167; + case ITEM_MAGIC_SHORTBOW_I: return is_special ? 1074 : 426; /* snapshot / bow */ + case ITEM_BOW_OF_FAERDHINEN: return 426; /* shortbow */ + case ITEM_TWISTED_BOW: return 426; /* shortbow */ + case ITEM_TOXIC_BLOWPIPE: return is_special ? 5061 : 5061; /* blowpipe */ + default: return 422; + } +} + +/** + * Determine the primary (action) animation for this tick. + * Returns -1 if no action animation should play. + * Primary animations are server-driven in the real client: attacks, casts, etc. + * They play once then auto-expire (loopCount=1 effectively). + */ +static int render_select_primary(RenderEntity* p) { + if (p->current_hitpoints <= 0) return ANIM_SEQ_DEATH; + + if (p->attack_style_this_tick != ATTACK_STYLE_NONE) { + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staves (trident/sang/ayak) use their own cast anim */ + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) + return 1167; /* HUMAN_CASTWAVE_STAFF */ + return ANIM_SEQ_CAST_BARRAGE; + } + return render_get_attack_anim( + p->equipped[GEAR_SLOT_WEAPON], p->used_special_this_tick); + } + + if (p->ate_food_this_tick || p->ate_karambwan_this_tick) { + return ANIM_SEQ_EAT; + } + + if (p->cast_veng_this_tick) { + return ANIM_SEQ_CAST_VENG; + } + + if (p->hit_landed_this_tick && p->equipped[GEAR_SLOT_SHIELD] < NUM_ITEMS) { + return ANIM_SEQ_BLOCK_SHIELD; + } + + return -1; /* no action this tick */ +} + +/** + * Determine the secondary (pose) animation based on VISUAL movement state. + * + * In the real client (nextStep), this is set based on the entity's sub-tile + * movement: idle when not moving, walk or run based on moveSpeed. We use the + * visual_moving/visual_running flags set by the client-tick loop. + */ +static int render_select_secondary(RenderClient* rc, int player_idx) { + if (!rc->visual_moving[player_idx]) return ANIM_SEQ_IDLE; + if (rc->visual_running[player_idx]) return ANIM_SEQ_RUN; + return ANIM_SEQ_WALK; +} + +/* ======================================================================== */ +/* composite model building */ +/* ======================================================================== */ + +/** + * Append a single OsrsModel's geometry into the player composite. + * Offsets face indices by the current base vertex count so the merged + * index buffer references the correct vertices. + */ +static void composite_add_model(PlayerComposite* comp, OsrsModel* om) { + if (!om->base_vertices || om->base_vert_count == 0) return; + + int bv_off = comp->base_vert_count; + int fc_off = comp->face_count; + + /* bounds check */ + if (bv_off + om->base_vert_count > COMPOSITE_MAX_BASE_VERTS) return; + if (fc_off + om->mesh.triangleCount > COMPOSITE_MAX_FACES) return; + + /* append base vertices */ + memcpy(comp->base_vertices + bv_off * 3, + om->base_vertices, om->base_vert_count * 3 * sizeof(int16_t)); + + /* append vertex skins */ + memcpy(comp->vertex_skins + bv_off, + om->vertex_skins, om->base_vert_count); + + /* append face indices (offset by base vertex count) */ + int nfi = om->mesh.triangleCount * 3; + for (int f = 0; f < nfi; f++) { + comp->face_indices[fc_off * 3 + f] = om->face_indices[f] + (uint16_t)bv_off; + } + + /* append face priority deltas (relative to this model's min, not composite global). + * prevents adjacent faces within a uniform-priority model from getting offset. */ + if (om->face_priorities) { + for (int f = 0; f < om->mesh.triangleCount; f++) { + int delta = (int)om->face_priorities[f] - (int)om->min_priority; + comp->face_pri_delta[fc_off + f] = (uint8_t)(delta > 0 ? delta : 0); + } + } else { + memset(comp->face_pri_delta + fc_off, 0, om->mesh.triangleCount); + } + + /* append expanded colors into the composite mesh color buffer */ + int exp_off = fc_off * 3; + memcpy(comp->mesh.colors + exp_off * 4, + om->mesh.colors, om->mesh.triangleCount * 3 * 4); + + comp->base_vert_count += om->base_vert_count; + comp->face_count += om->mesh.triangleCount; +} + +/** + * Rebuild a player's composite model from their visible body parts + equipment. + * Called when equipment changes or on first frame. + */ +static void composite_rebuild( + PlayerComposite* comp, ModelCache* cache, RenderEntity* p +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* add visible body parts (hide parts covered by equipment) */ + for (int bp = 0; bp < BODY_PART_COUNT; bp++) { + int hide = 0; + if (bp == BODY_PART_HEAD) { + hide = (p->equipped[GEAR_SLOT_HEAD] < NUM_ITEMS); + } else if (bp == BODY_PART_TORSO) { + hide = (p->equipped[GEAR_SLOT_BODY] < NUM_ITEMS); + } else if (bp == BODY_PART_ARMS) { + uint8_t body_idx = p->equipped[GEAR_SLOT_BODY]; + if (body_idx < NUM_ITEMS) { + hide = item_has_sleeves(ITEM_DATABASE[body_idx].item_id); + } + } else if (bp == BODY_PART_LEGS) { + hide = (p->equipped[GEAR_SLOT_LEGS] < NUM_ITEMS); + } else if (bp == BODY_PART_HANDS) { + hide = (p->equipped[GEAR_SLOT_HANDS] < NUM_ITEMS); + } else if (bp == BODY_PART_FEET) { + hide = (p->equipped[GEAR_SLOT_FEET] < NUM_ITEMS); + } + if (hide) continue; + + OsrsModel* om = model_cache_get(cache, DEFAULT_BODY_MODELS[bp]); + if (om) composite_add_model(comp, om); + } + + /* add equipped item wield models */ + static const int VISIBLE_SLOTS[] = { + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, + GEAR_SLOT_LEGS, GEAR_SLOT_HANDS, GEAR_SLOT_FEET, + }; + for (int s = 0; s < 9; s++) { + int slot = VISIBLE_SLOTS[s]; + uint8_t db_idx = p->equipped[slot]; + if (db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_wield_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(cache, model_id); + if (om) composite_add_model(comp, om); + } + + /* rebuild animation state for the new composite geometry */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + /* save equipment state for change detection */ + memcpy(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS); + comp->needs_rebuild = 0; +} + +/** + * Rebuild an NPC's composite from a single cache model (no equipment composition). + * Used for Zulrah forms, snakelings, and other encounter NPCs. + */ +static void composite_rebuild_npc( + PlayerComposite* comp, ModelCache* cache, ModelCache* npc_cache, int npc_def_id +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* zero mesh buffers to prevent stale GPU data from showing as garbled geometry + if the model fails to load or exceeds composite limits */ + if (comp->mesh.vertices) + memset(comp->mesh.vertices, 0, COMPOSITE_MAX_EXP_VERTS * 3 * sizeof(float)); + if (comp->mesh.colors) + memset(comp->mesh.colors, 0, COMPOSITE_MAX_EXP_VERTS * 4); + + /* look up model ID from NPC definition */ + uint32_t model_id = 0; + const NpcModelMapping* mapping = npc_model_lookup((uint16_t)npc_def_id); + if (mapping) { + model_id = mapping->model_id; + } else { + /* snakelings and other NPCs without a mapping — try snakeling */ + model_id = SNAKELING_MODEL_ID; + } + + OsrsModel* om = model_cache_get(cache, model_id); + /* fallback: check secondary NPC model cache (inferno etc.) */ + if (!om && npc_cache) + om = model_cache_get(npc_cache, model_id); + if (om) composite_add_model(comp, om); + + /* rebuild animation state */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + comp->last_npc_def_id = npc_def_id; + comp->needs_rebuild = 0; +} + +/** + * Initialize the composite's GPU resources (once, at max capacity). + * Uses dynamic=true since we update vertices every frame. + */ +static void composite_init_gpu(PlayerComposite* comp) { + if (comp->gpu_ready) return; + + comp->mesh.vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->mesh.triangleCount = COMPOSITE_MAX_FACES; + comp->mesh.vertices = (float*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS * 3, sizeof(float)); + comp->mesh.colors = (unsigned char*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS, 4); + + UploadMesh(&comp->mesh, true); /* dynamic VBO for per-frame updates */ + comp->model = LoadModelFromMesh(comp->mesh); + comp->gpu_ready = 1; +} + +/** + * Apply per-face render priority offset to prevent z-fighting on coplanar faces. + * Pushes higher-priority faces slightly along their face normal (toward camera). + * Uses pre-computed per-face deltas (relative to each source model's min priority) + * so uniform-priority models get zero offset. + */ +static void composite_apply_priority_offset( + float* mesh_verts, const uint8_t* pri_deltas, int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int delta = (int)pri_deltas[fi]; + if (delta <= 0) continue; + + int vi = fi * 9; + float ax = mesh_verts[vi], ay = mesh_verts[vi+1], az = mesh_verts[vi+2]; + float bx = mesh_verts[vi+3], by = mesh_verts[vi+4], bz = mesh_verts[vi+5]; + float cx = mesh_verts[vi+6], cy = mesh_verts[vi+7], cz = mesh_verts[vi+8]; + + /* face normal via cross product */ + float e1x = bx-ax, e1y = by-ay, e1z = bz-az; + float e2x = cx-ax, e2y = cy-ay, e2z = cz-az; + float nx = e1y*e2z - e1z*e2y; + float ny = e1z*e2x - e1x*e2z; + float nz = e1x*e2y - e1y*e2x; + float len = sqrtf(nx*nx + ny*ny + nz*nz); + if (len < 0.001f) continue; + + /* 0.15 OSRS units per priority level (matches Python exporter) */ + float bias = (float)delta * 0.15f / len; + nx *= bias; ny *= bias; nz *= bias; + + /* push along -normal (matches exporter convention for OSRS CW winding) */ + mesh_verts[vi] -= nx; mesh_verts[vi+1] -= ny; mesh_verts[vi+2] -= nz; + mesh_verts[vi+3] -= nx; mesh_verts[vi+4] -= ny; mesh_verts[vi+5] -= nz; + mesh_verts[vi+6] -= nx; mesh_verts[vi+7] -= ny; mesh_verts[vi+8] -= nz; + } +} + +/** + * Animate composite model, upload to GPU, and draw. + */ +/** + * Apply animation(s), re-expand vertices, upload to GPU, and draw. + * + * When both primary and secondary are provided with an interleave_order, + * uses interleaved application (upper body from primary, legs from secondary). + * Otherwise falls back to single-frame application. + */ +static void composite_animate_and_draw( + PlayerComposite* comp, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count, + Matrix transform +) { + if (!comp->anim_state || comp->face_count == 0) return; + + /* apply animation transforms to base vertices */ + if (primary_frame && secondary_frame && interleave_order && interleave_count > 0) { + /* two-track: primary owns upper body, secondary owns legs */ + anim_apply_frame_interleaved( + comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb, + primary_frame, primary_fb, + interleave_order, interleave_count); + } else if (primary_frame) { + /* primary only (death, or anims without interleave_order) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + primary_frame, primary_fb); + } else if (secondary_frame) { + /* secondary only (walk/idle, no action) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb); + } + + /* re-expand animated base verts into mesh vertex buffer */ + anim_update_mesh(comp->mesh.vertices, comp->anim_state, + comp->face_indices, comp->face_count); + + /* apply face priority offset to prevent z-fighting on coplanar faces */ + composite_apply_priority_offset( + comp->mesh.vertices, comp->face_pri_delta, comp->face_count); + + /* sanity clamp: catch degenerate animation frames that produce extreme + vertex positions (int16_t overflow in animation math). without this, + a single bad frame can create a screen-filling triangle. OSRS model + coords are typically ±2000; 10000 is already way beyond any real model. */ + { + int nv = comp->face_count * 3 * 3; + for (int i = 0; i < nv; i++) { + if (comp->mesh.vertices[i] > 10000.0f) comp->mesh.vertices[i] = 10000.0f; + else if (comp->mesh.vertices[i] < -10000.0f) comp->mesh.vertices[i] = -10000.0f; + } + } + + /* upload updated vertices and colors to GPU */ + int exp_verts = comp->face_count * 3; + UpdateMeshBuffer(comp->mesh, 0, comp->mesh.vertices, + exp_verts * 3 * sizeof(float), 0); + UpdateMeshBuffer(comp->mesh, 3, comp->mesh.colors, + exp_verts * 4, 0); + + /* draw with the current face count. CRITICAL: must set vertexCount on + model.meshes[0], NOT comp->mesh — LoadModelFromMesh copies the mesh + struct by value, so comp->mesh and model.meshes[0] are independent. + DrawModel reads model.meshes[0].vertexCount for glDrawArrays count. */ + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* restore max counts so the VBO stays valid for next UpdateMeshBuffer */ + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; +} + +static void composite_free(PlayerComposite* comp) { + if (comp->gpu_ready) { + UnloadModel(comp->model); + comp->gpu_ready = 0; + } + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; +} + +/* ======================================================================== */ +/* per-player animation + composite orchestration */ +/* ======================================================================== */ + +/** + * Rebuild composite if equipment changed, run two-track animation, draw. + * + * Two-track animation system (matches OSRS client): + * - secondary: always running (idle/walk/run), loops forever + * - primary: triggered by actions (attack/cast/eat/block/death), plays + * once then expires. when active with interleave_order, overrides + * secondary for upper body groups. + */ +static void render_player_composite( + RenderClient* rc, int player_idx, Matrix transform +) { + if (!rc->model_cache) return; + + PlayerComposite* comp = &rc->composites[player_idx]; + RenderEntity* p = &rc->entities[player_idx]; + + composite_init_gpu(comp); + + /* branch on entity type: NPCs use single-model composites */ + if (p->entity_type == ENTITY_NPC) { + if (comp->needs_rebuild || comp->last_npc_def_id != p->npc_def_id) { + composite_rebuild_npc(comp, rc->model_cache, rc->npc_model_cache, p->npc_def_id); + } + } else { + if (comp->needs_rebuild || + memcmp(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS) != 0) { + composite_rebuild(comp, rc->model_cache, p); + } + } + + if (!rc->anim_cache || !comp->anim_state) { + /* no animation: draw static */ + if (comp->face_count > 0) { + int exp_verts = comp->face_count * 3; + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; + } + return; + } + + /* --- primary track: trigger new actions and expire finished ones --- + primary is triggered per game tick (render_post_tick sets flags), + but frame advancement happens in render_client_tick at 50 Hz. + + bug fix: when the same anim fires again after expiry (e.g. two + consecutive whip attacks), we must restart it. check both seq_id + change AND whether the current one has already finished (loops > 0). */ + int new_primary; + if (p->entity_type == ENTITY_NPC) { + /* NPCs set their animation via npc_anim_id from the encounter. + idle is secondary (looping), attack/dive/surface are primary (play-once). */ + const NpcModelMapping* nm = npc_model_lookup((uint16_t)p->npc_def_id); + int idle = nm ? (int)nm->idle_anim : -1; + new_primary = (p->npc_anim_id >= 0 && p->npc_anim_id != idle) + ? p->npc_anim_id : -1; + } else { + new_primary = render_select_primary(p); + } + if (new_primary >= 0) { + int need_restart = (rc->anim[player_idx].primary_seq_id != new_primary) || + (rc->anim[player_idx].primary_loops > 0); + if (need_restart) { + rc->anim[player_idx].primary_seq_id = new_primary; + rc->anim[player_idx].primary_frame_idx = 0; + rc->anim[player_idx].primary_ticks = 0; + rc->anim[player_idx].primary_loops = 0; + } + } + + /* expire primary after one loop (death never expires) */ + if (rc->anim[player_idx].primary_seq_id >= 0 && + rc->anim[player_idx].primary_loops > 0 && + rc->anim[player_idx].primary_seq_id != ANIM_SEQ_DEATH) { + rc->anim[player_idx].primary_seq_id = -1; + } + + /* --- read current frame data (set by render_client_tick at 50 Hz) --- */ + AnimSequenceFrame *sec_sf = NULL, *pri_sf = NULL; + AnimFrameBase *sec_fb = NULL, *pri_fb = NULL; + + /* secondary frame */ + if (rc->anim[player_idx].secondary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].secondary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].secondary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { sec_sf = sf; sec_fb = fb; } + } + } + } + + /* primary frame */ + if (rc->anim[player_idx].primary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].primary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { pri_sf = sf; pri_fb = fb; } + } + } + } + + /* --- resolve interleave_order from the primary sequence --- */ + const uint8_t* interleave = NULL; + int interleave_count = 0; + if (pri_sf) { + AnimSequence* prim_seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (prim_seq && prim_seq->interleave_order) { + interleave = prim_seq->interleave_order; + interleave_count = prim_seq->interleave_count; + } + } + + /* --- animate and draw --- */ + composite_animate_and_draw( + comp, + sec_sf ? &sec_sf->frame : NULL, sec_fb, + pri_sf ? &pri_sf->frame : NULL, pri_fb, + interleave, interleave_count, + transform); +} + +static void render_draw_3d_world(RenderClient* rc) { + /* tighten near/far clip planes for depth buffer precision. + default 0.01/1000 = 100,000:1 ratio wastes precision and causes + z-fighting across the entire scene. 0.5/500 = 1000:1 is sufficient + for our tile-scale world (camera is never closer than ~1 tile). */ + rlSetClipPlanes(0.5, 500.0); + + Camera3D cam = render_build_3d_camera(rc); + BeginMode3D(cam); + + /* terrain mesh (PvP wilderness) or flat ground plane (encounters) */ + if (rc->terrain && rc->terrain->loaded) { + DrawModel(rc->terrain->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* 3D collision overlay on terrain: semi-transparent quads at tile height */ + if (rc->show_collision && rc->collision_map) { + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + int flags = collision_get_flags(rc->collision_map, 0, wx, wy); + + Color col = { 0, 0, 0, 0 }; + if (flags & COLLISION_BLOCKED) { + col = CLITERAL(Color){ 200, 50, 50, 80 }; + } else if (flags & COLLISION_BRIDGE) { + col = CLITERAL(Color){ 50, 120, 220, 80 }; + } else if (flags & 0x0FF) { + col = CLITERAL(Color){ 220, 150, 40, 60 }; + } else { + col = CLITERAL(Color){ 50, 200, 50, 40 }; + } + + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + /* sample terrain height at tile */ + float ground = terrain_height_avg(rc->terrain, + rc->arena_base_x + dx, rc->arena_base_y + dy); + DrawCube((Vector3){ tx + 0.5f, ground + 0.05f, tz + 0.5f }, + 1.0f, 0.02f, 1.0f, col); + } + } + } + } else if (rc->npc_model_cache) { + /* inferno: dark cave floor. all tiles are walkable ground. */ + float plat_y = 2.0f; + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* volcanic rock with subtle variation — bright enough to distinguish from background */ + int shade = 45 + ((dx * 7 + dy * 13) % 15); + int r = shade + ((dx * 3 + dy * 11) % 10); /* slight reddish tint */ + Color c = { (unsigned char)r, (unsigned char)(shade - 3), (unsigned char)(shade - 6), 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } else { + /* zulrah / generic encounter: raised green platform over blue water. + the real arena is instanced so it can't be exported from the cache. */ + float water_y = 1.5f; + float plat_y = 2.0f; + + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* determine platform vs water: use collision map if available, + otherwise fall back to hardcoded platform bounds */ + int on_plat; + if (rc->collision_map) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + on_plat = collision_tile_walkable(rc->collision_map, 0, wx, wy); + } else { + on_plat = (dx >= ZUL_PLATFORM_MIN && dx <= ZUL_PLATFORM_MAX && + dy >= ZUL_PLATFORM_MIN && dy <= ZUL_PLATFORM_MAX); + } + + if (on_plat) { + int shade = 35 + ((dx * 7 + dy * 13) % 15); + Color c = { (unsigned char)shade, (unsigned char)(shade * 2), (unsigned char)shade, 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } else { + int shade = 15 + ((dx * 3 + dy * 5) % 10); + Color c = { (unsigned char)(shade / 2), (unsigned char)shade, (unsigned char)(shade * 3), 255 }; + DrawCube((Vector3){ tx + 0.5f, water_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } + } + + /* inferno pillars: "Rocky support" objects with 4 HP-level models. + dynamically spawned (not in static objects file). */ + if (rc->npc_model_cache && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ms = 1.0f / 128.0f; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!is->pillars[p].active) continue; + float hp_frac = (float)is->pillars[p].hp / (float)INF_PILLAR_HP; + + float cx = (float)is->pillars[p].x + INF_PILLAR_SIZE / 2.0f; + float cz = -(float)(is->pillars[p].y + INF_PILLAR_SIZE / 2) - 0.5f; + + if (rc->pillar_models_ready) { + /* select model by HP: 100%, 75%, 50%, 25% */ + int mi = 0; + if (hp_frac <= 0.25f) mi = 3; + else if (hp_frac <= 0.50f) mi = 2; + else if (hp_frac <= 0.75f) mi = 1; + + rlDisableBackfaceCulling(); + rc->pillar_models[mi].transform = MatrixMultiply( + MatrixScale(-ms, ms, ms), + MatrixTranslate(cx, plat_y, cz)); + DrawModel(rc->pillar_models[mi], (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + /* fallback: colored DrawCube blocks */ + int base_r = (int)(140 * hp_frac + 180 * (1.0f - hp_frac)); + int base_g = (int)(130 * hp_frac + 40 * (1.0f - hp_frac)); + int base_b = (int)(100 * hp_frac + 20 * (1.0f - hp_frac)); + Color pillar_col = { (unsigned char)base_r, (unsigned char)base_g, (unsigned char)base_b, 240 }; + for (int dx = 0; dx < INF_PILLAR_SIZE; dx++) { + for (int dy = 0; dy < INF_PILLAR_SIZE; dy++) { + float tx = (float)(is->pillars[p].x + dx); + float tz2 = -(float)(is->pillars[p].y + dy + 1); + for (int h = 0; h < 3; h++) { + DrawCube((Vector3){ tx + 0.5f, plat_y + 0.5f + (float)h, tz2 + 0.5f }, + 0.95f, 0.95f, 0.95f, pillar_col); + } + } + } + } + } + } + + /* debug: highlight the last raycast-selected tile */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + float dtx = (float)rc->debug_hit_wx; + float dtz = -(float)(rc->debug_hit_wy + 1); + float dgy = rc->terrain + ? terrain_height_avg(rc->terrain, rc->debug_hit_wx, rc->debug_hit_wy) + : 2.0f; + DrawCube((Vector3){ dtx + 0.5f, dgy + 0.02f, dtz + 0.5f }, + 1.0f, 0.02f, 1.0f, (Color){ 255, 0, 255, 180 }); + DrawSphere((Vector3){ rc->debug_ray_hit_x, rc->debug_ray_hit_y, rc->debug_ray_hit_z }, + 0.1f, RED); + } + /* draw ray as line from origin forward */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + Vector3 a = rc->debug_ray_origin; + Vector3 b = { a.x + rc->debug_ray_dir.x * 50.0f, + a.y + rc->debug_ray_dir.y * 50.0f, + a.z + rc->debug_ray_dir.z * 50.0f }; + DrawLine3D(a, b, YELLOW); + } + + /* debug: draw game-logic tile positions for all entities. + green = player, cyan = NPCs. shows where the game thinks entities are + vs where the 3D model renders (which uses sub_x/sub_y interpolation). */ + if (rc->show_debug) { + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* ep = &rc->entities[i]; + if (ep->entity_type == ENTITY_NPC && !ep->npc_visible) continue; + float tx = (float)ep->x; + float ty = (float)ep->y; + float tz = -(ty + 1.0f); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, ep->x, ep->y) : 2.0f; + int sz = ep->npc_size > 1 ? ep->npc_size : 1; + Color col = (ep->entity_type == ENTITY_PLAYER) + ? CLITERAL(Color){ 0, 255, 0, 100 } + : CLITERAL(Color){ 0, 200, 255, 80 }; + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + float mx = tx + (float)dx; + float mz = tz - (float)dy; + DrawCube((Vector3){ mx + 0.5f, ground + 0.08f, mz + 0.5f }, + 0.9f, 0.04f, 0.9f, col); + } + } + } + } + + /* entity click hitboxes are now drawn as 2D convex hulls after EndMode3D */ + + /* encounter overlay: drawn on top of terrain or procedural arena */ + { + EncounterOverlay* ov = &rc->encounter_overlay; + int has_terrain = rc->terrain && rc->terrain->loaded; + + /* helper: get ground height at a tile position */ + #define OV_GROUND(tile_x, tile_y) \ + (has_terrain ? terrain_height_avg(rc->terrain, (tile_x), (tile_y)) : 2.0f) + + /* 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->model_id > 0) { + proj_model = render_get_proj_model(rc, fp->model_id); + } + if (!proj_model) { + /* style-based fallback for backward compatibility */ + if (fp->style == 0 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; + else if (fp->style == 1 && rc->magic_proj_model_ready) + proj_model = &rc->magic_proj_model; + else if (fp->style == 3 && rc->cloud_proj_model_ready) + proj_model = &rc->cloud_proj_model; + else if (fp->style == 4 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; /* spawn orb reuses ranged mesh */ + } + + if (proj_model) { + rlDisableBackfaceCulling(); + float pms = 1.0f / 128.0f; + proj_model->transform = MatrixMultiply( + MatrixMultiply( + MatrixScale(-pms, pms, pms), + MatrixMultiply(MatrixRotateY(fp->yaw + 1.5707963f), MatrixRotateX(fp->pitch))), + MatrixTranslate(pos.x, pos.y, pos.z)); + DrawModel(*proj_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* trail line from source to current position */ + if (rc->show_debug) { + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 150 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 150 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 150 }; break; + case 3: pc = CLITERAL(Color){ 50, 180, 50, 150 }; break; + case 4: pc = CLITERAL(Color){ 230, 230, 230, 150 }; break; + default: pc = WHITE; break; + } + Vector3 src_pos = { fp->src_x + 0.5f, src_ground + fp->start_height, + -(fp->src_y + 1.0f) + 0.5f }; + DrawLine3D(src_pos, pos, pc); + DrawSphere(pos, 0.12f, pc); + } + } + + /* safe spot markers: colored quads on ground at each stand location */ + if (rc->show_safe_spots && rc->gui.encounter_state) { + const EncounterDef* edef_ss = (const EncounterDef*)rc->gui.encounter_def; + if (edef_ss && strcmp(edef_ss->name, "zulrah") == 0) { + ZulrahState* zs_ss = (ZulrahState*)rc->gui.encounter_state; + const ZulRotationPhase* phase_ss = zul_current_phase(zs_ss); + int act_stand = phase_ss->stand; + int act_stall = phase_ss->stall; + + for (int si = 0; si < ZUL_NUM_STAND_LOCATIONS; si++) { + int lx = ZUL_STAND_COORDS[si][0]; + int ly = ZUL_STAND_COORDS[si][1]; + float sx = (float)(rc->arena_base_x + lx) + 0.5f; + float sz = -(float)(rc->arena_base_y + ly + 1) + 0.5f; + float gy = OV_GROUND(rc->arena_base_x + lx, rc->arena_base_y + ly); + + Color col; + if (si == act_stand) + col = (Color){0, 255, 0, 160}; + else if (si == act_stall) + col = (Color){255, 255, 0, 160}; + else + col = (Color){0, 180, 180, 80}; + + DrawCube((Vector3){ sx, gy + 0.08f, sz }, + 0.7f, 0.04f, 0.7f, col); + } + } + } + + #undef OV_GROUND + } + + /* placed objects — disable backface culling since OSRS uses flat + billboard-style quads for trees/plants (two crossing planes) */ + { + /* 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; + /* stack arrays for projection — max 200 sampled points */ + int hull_xs[256], hull_ys[256]; + for (int vi = 0; vi < nv && hull_n < 256; vi += stride) { + float vx = comp->mesh.vertices[vi * 3 + 0]; + float vy = comp->mesh.vertices[vi * 3 + 1]; + float vz = comp->mesh.vertices[vi * 3 + 2]; + /* transform model-space vertex by the same matrix used for drawing */ + Vector3 wv = Vector3Transform((Vector3){ vx, vy, vz }, base); + Vector2 sv = GetWorldToScreen(wv, hull_cam); + /* skip off-screen / behind camera */ + if (sv.x < -1000 || sv.x > 5000 || sv.y < -1000 || sv.y > 5000) continue; + hull_xs[hull_n] = (int)sv.x; + hull_ys[hull_n] = (int)sv.y; + hull_n++; + } + hull_compute(hull_xs, hull_ys, hull_n, &rc->entity_hulls[i]); + } + rlEnableBackfaceCulling(); + } + + /* visual effects: spell impacts, projectiles */ + if (rc->model_cache) { + rlDisableBackfaceCulling(); + float eff_scale = 1.0f / 128.0f; + int eff_ct = rc->effect_client_tick_counter; + + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &rc->effects[i]; + if (e->type == EFFECT_NONE) continue; + if (!e->meta) continue; + + /* look up model */ + OsrsModel* om = model_cache_get(rc->model_cache, e->meta->model_id); + if (!om) continue; + + /* position: sub-tile coords -> tile coords -> raylib world */ + float ex = (float)(e->cur_x / 128.0); + float ez = -(float)(e->cur_y / 128.0); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, (int)ex, (int)(e->cur_y / 128.0)) + : 2.0f; + float ey = ground + (float)(e->height / 128.0); + + /* apply scale from spotanim def */ + float sx = eff_scale * (float)e->meta->resize_xy / 128.0f; + float sz = eff_scale * (float)e->meta->resize_z / 128.0f; + + /* animate: apply current frame to per-effect anim state, + then write transformed vertices into the shared mesh. + note: this temporarily modifies the shared OsrsModel mesh, + which is fine since effects render sequentially. */ + if (e->anim_state && e->meta->anim_seq_id >= 0 && rc->anim_cache + && om->face_indices) { + AnimSequence* seq = render_get_anim_sequence(rc, e->meta->anim_seq_id); + if (seq && e->anim_frame < seq->frame_count) { + AnimSequenceFrame* sf = &seq->frames[e->anim_frame]; + AnimFrameBase* fb = render_get_framebase(rc, + sf->frame.framebase_id); + if (fb) { + anim_apply_frame(e->anim_state, om->base_vertices, + &sf->frame, fb); + anim_update_mesh(om->mesh.vertices, e->anim_state, + om->face_indices, om->mesh.triangleCount); + UpdateMeshBuffer(om->mesh, 0, om->mesh.vertices, + om->mesh.triangleCount * 9 * sizeof(float), 0); + } + } + } + + /* build transform */ + Matrix t = MatrixScale(-sx, sx, sz); /* negate X for handedness */ + + /* projectile orientation: yaw + pitch from trajectory direction. + uses atan2 on the velocity vector (same approach as the flight + system) to orient the model from source toward target. */ + if (e->type == EFFECT_PROJECTILE && e->started) { + /* direction in raylib coords: x_increment maps to world X, + y_increment maps to world -Z (OSRS Y → raylib -Z) */ + float dx = (float)e->x_increment; + float dz = -(float)e->y_increment; + float horiz = sqrtf(dx * dx + dz * dz); + float yaw = atan2f(dx, dz); + float pitch = atan2f((float)e->height_increment, horiz > 0.001f ? horiz : 0.001f); + t = MatrixMultiply(t, MatrixMultiply( + MatrixRotateX(pitch), MatrixRotateY(yaw))); + } + + t = MatrixMultiply(t, MatrixTranslate(ex, ey, ez)); + om->model.transform = t; + + /* spotanim fade: 20% fade in, 60% full, 20% fade out */ + Color tint = WHITE; + if (e->type == EFFECT_SPOTANIM && e->stop_tick > e->start_tick) { + int total = e->stop_tick - e->start_tick; + int elapsed = eff_ct - e->start_tick; + float progress = (float)elapsed / (float)total; + float alpha = 1.0f; + if (progress < 0.2f) alpha = progress / 0.2f; + else if (progress > 0.8f) alpha = (1.0f - progress) / 0.2f; + if (alpha < 0.0f) alpha = 0.0f; + if (alpha > 1.0f) alpha = 1.0f; + tint = (Color){ 255, 255, 255, (unsigned char)(alpha * 255) }; + } + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, tint); + } + rlEnableBackfaceCulling(); + } + + /* fight area boundary wireframe (Z negated) */ + float fa_x = (float)rc->arena_base_x; + float fa_z = -(float)rc->arena_base_y; + float fa_w = (float)rc->arena_width; + float fa_h = -(float)rc->arena_height; /* negative because Z is negated */ + float bh = rc->terrain ? terrain_height_at(rc->terrain, rc->arena_base_x, rc->arena_base_y) : 2.0f; + DrawLine3D( + (Vector3){ fa_x, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z }, YELLOW); + + /* click cross is now drawn as 2D overlay in pvp_render, not in 3D world */ + + /* debug: player→NPC LOS lines (green=can attack, red=blocked/out of range) */ + if (rc->show_debug && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ph = plat_y + 1.0f; /* player line height */ + float player_wx = (float)is->player.x + 0.5f; + float player_wz = -(float)is->player.y - 0.5f; + const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; + for (int ni = 0; ni < INF_MAX_NPCS; ni++) { + InfNPC* npc = &is->npcs[ni]; + if (!npc->active || npc->death_ticks > 0) continue; + float half = (float)(npc->size - 1) / 2.0f; + float npc_wx = (float)npc->x + half + 0.5f; + float npc_wz = -(float)npc->y - half - 0.5f; + int can_atk = encounter_player_can_attack( + is->player.x, is->player.y, + npc->x, npc->y, npc->size, + ls->attack_range, is->los_blockers, is->los_blocker_count); + Color lc = can_atk ? GREEN : RED; + DrawLine3D( + (Vector3){ player_wx, ph, player_wz }, + (Vector3){ npc_wx, ph, npc_wz }, + lc); + } + } + + /* 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(); +} + +/* ======================================================================== */ +/* drawing: 2D overlay models (for 2D mode) */ +/* ======================================================================== */ + +static void render_draw_models_2d_overlay(RenderClient* rc) { + if (!rc->model_cache) return; + + float hw = (float)RENDER_WINDOW_W / 2.0f; + float hh = (float)RENDER_WINDOW_H / 2.0f; + + Camera3D cam = { 0 }; + cam.position = (Vector3){ hw, hh, 1000.0f }; + cam.target = (Vector3){ hw, hh, 0.0f }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = (float)RENDER_WINDOW_H; + cam.projection = CAMERA_ORTHOGRAPHIC; + + float zoom_cx = (float)RENDER_GRID_W / 2.0f; + float zoom_cy = (float)(RENDER_HEADER_HEIGHT + RENDER_GRID_H / 2.0f); + + BeginMode3D(cam); + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + uint8_t slot_idx = p->equipped[GEAR_SLOT_WEAPON]; + uint8_t db_idx = get_item_for_slot(GEAR_SLOT_WEAPON, slot_idx); + if (db_idx == ITEM_NONE || db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_inv_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(rc->model_cache, model_id); + if (!om) continue; + + float sx = (float)render_world_to_screen_x_rc(rc, p->x) + (float)RENDER_TILE_SIZE / 2.0f; + float sy = (float)render_world_to_screen_y_rc(rc, p->y) + (float)RENDER_TILE_SIZE / 2.0f; + + float zsx = zoom_cx + (sx - zoom_cx) * rc->zoom; + float zsy = zoom_cy + (sy - zoom_cy) * rc->zoom; + + float wx = zsx; + float wy = (float)RENDER_WINDOW_H - zsy; + + float scale = rc->model_scale * rc->zoom; + + Matrix transform = MatrixScale(scale, scale, scale); + transform = MatrixMultiply(transform, MatrixTranslate(wx, wy, 0.0f)); + + om->model.transform = transform; + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + } + + EndMode3D(); +} + +/* ======================================================================== */ +/* overhead status: prayer icons + HP bar (2D overlay on 3D scene) */ +/* ======================================================================== */ + +/** + * Draw overhead prayer icons and HP bars above players in 3D mode. + * + * Layout matches OSRS client (Client.java:6011-6049): + * - HP bar: 30px wide, 5px tall, green fill + red remainder, drawn at + * entity height + 15 via npcScreenPos. visible for 6s after taking damage. + * - Prayer icon: drawn above the HP bar. + * + * Both are 2D sprites/rects drawn at screen-projected head position. + */ +static void render_draw_overhead_status(RenderClient* rc, 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 = ground + 1.5f + 0.5f * (float)ent_size; + float 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); + } + } + } + } +} + +/* ======================================================================== */ +/* main render entry point */ +/* ======================================================================== */ + +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); + 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/src/osrs/osrs_special_attacks.h b/src/osrs/osrs_special_attacks.h new file mode 100644 index 0000000000..11623feedb --- /dev/null +++ b/src/osrs/osrs_special_attacks.h @@ -0,0 +1,487 @@ +/** + * @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(...) legacy standalone blowpipe spec + * + * 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" + +/* ======================================================================== */ +/* blowpipe spec constants (moved from osrs_combat.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 + +/* legacy standalone blowpipe spec (moved from osrs_combat.h). + prefer osrs_resolve_spec(ITEM_TOXIC_BLOWPIPE, ...) for new code. */ +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; +} + +/* ======================================================================== */ +/* SpecResult: shared result struct for all special attacks */ +/* ======================================================================== */ + +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; + +/* ======================================================================== */ +/* osrs_spec_cost: energy cost by weapon item index */ +/* ======================================================================== */ + +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; + } +} + +/* ======================================================================== */ +/* osrs_resolve_spec: dispatch special attack by weapon item index */ +/* */ +/* att_roll: base attack roll (eff_level * (bonus + 64)), unmodified */ +/* max_hit: base max hit, unmodified by spec */ +/* def_roll: target's base defence roll (eff_def * (def_bonus + 64)) */ +/* target_def_level: target's current defence level (for drain calcs) */ +/* rng_state: pointer to xorshift32 RNG state */ +/* ======================================================================== */ + +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) { + + /* ---- MELEE ---- */ + + /* 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; + } + + /* ---- RANGED ---- */ + + /* 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; + } + + /* ---- MAGIC ---- */ + + /* 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/src/osrs/osrs_terrain.h b/src/osrs/osrs_terrain.h new file mode 100644 index 0000000000..6387bbc4d1 --- /dev/null +++ b/src/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/src/osrs/osrs_types.h b/src/osrs/osrs_types.h new file mode 100644 index 0000000000..00483c33e6 --- /dev/null +++ b/src/osrs/osrs_types.h @@ -0,0 +1,1171 @@ +/** + * @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. + */ + +/* ============================================================================ + * CRITICAL: OSRS TICK-BASED TIMING MODEL + * ============================================================================ + * + * READ THIS BEFORE MODIFYING ANY COMBAT OR MOVEMENT CODE. + * + * The current simulation models a 600ms tick cycle. actions are queued and + * execute on the NEXT tick, not immediately. encounter-specific ordering can + * still layer on top of that shared queue. + * + * --------------------------------------------------------------------------- + * TICK TIMING OVERVIEW + * --------------------------------------------------------------------------- + * + * TICK N (current state): + * - Player SEES: positions, HP, gear, prayers, everything visible + * - Player QUEUES: their reaction to what they see (actions for next tick) + * - Actions execute: NOTHING YET - actions are just queued + * + * TICK N+1 (next tick): + * - Queued actions from tick N EXECUTE (movement first, then attacks) + * - New state becomes visible + * - Player queues new reaction + * + * --------------------------------------------------------------------------- + * MOVEMENT + ATTACK IN SAME TICK + * --------------------------------------------------------------------------- + * + * When you queue an attack, the game automatically handles movement: + * + * Example: dist=3, melee weapon (range=1), queue "attack" + * - Tick N+1: move 2 tiles (running), now dist=1, attack fires + * - Both movement and attack happen in the SAME tick + * + * Example: dist=0 (under target), queue "attack" + * - Tick N+1: auto-step to adjacent tile (dist=1), attack fires + * - The step-out is IMPLICIT - part of the attack action + * + * --------------------------------------------------------------------------- + * CONFLICTING ACTIONS (IMPORTANT!) + * --------------------------------------------------------------------------- + * + * When EXPLICIT movement conflicts with IMPLICIT attack movement: + * + * Example: dist=0, queue BOTH "attack" AND "move under" + * - Attack needs: step out to dist=1 + * - Movement wants: stay at dist=0 + * - RESULT: Explicit movement wins, attack is CANCELLED + * + * This is because the end state cannot be BOTH dist=1 (for attack) AND + * dist=0 (from explicit movement). Explicit actions override implicit ones. + * + * --------------------------------------------------------------------------- + * STEP UNDER STRATEGY (current NH/PvP model) + * --------------------------------------------------------------------------- + * + * Common tactic when opponent is frozen: + * + * Tick 9: You're under frozen opponent (dist=0), queue "attack" ONLY + * Tick 10: Step out to dist=1, attack fires, queue "move under" ONLY + * Tick 11: Move back under (dist=0), opponent couldn't hit you + * + * The frozen opponent can only hit you if they ALSO queue attack on tick 9. + * Both attacks would fire on tick 10 when you're both effectively at dist=1. + * + * --------------------------------------------------------------------------- + * RECORDING FORMAT + * --------------------------------------------------------------------------- + * + * Fight recordings show for each tick: + * - STATE: What the player sees RIGHT NOW (positions, HP, gear, etc.) + * - ACTIONS: What the player QUEUED as reaction (executes NEXT tick) + * + * Example (valid sequence): + * Tick 9 state: dist=3, actions=["RNG"] + * Tick 10 state: dist=1, attack fires (moved 2 tiles + attacked) + * + * Counter-example (conflicting sequence - attack cancelled): + * Tick 9 state: dist=0, actions=["RNG", "under"] + * Tick 10 state: dist=0, NO attack (explicit "under" cancelled the attack) + * The attack needed dist=1, but "under" forced dist=0. Conflict resolved + * by cancelling the attack - explicit movement wins over implicit step-out. + * + * ========================================================================= */ + +#ifndef OSRS_TYPES_H +#define OSRS_TYPES_H + +#include +#include +#include +#include +#include +#include "osrs_interaction.h" + +// ============================================================================ +// ENVIRONMENT CONSTANTS +// ============================================================================ + +#define NUM_AGENTS 2 +#define MAX_PENDING_HITS 8 +#define HISTORY_SIZE 5 + +#define TICK_DURATION_MS 600 +#define MAX_EPISODE_TICKS 300 + +// ============================================================================ +// WILDERNESS AREA BOUNDS +// ============================================================================ + +#define WILD_MIN_X 2940 +#define WILD_MAX_X 3392 +#define WILD_MIN_Y 3525 +#define WILD_MAX_Y 3968 +#define FIGHT_AREA_BASE_X 3041 +#define FIGHT_AREA_BASE_Y 3530 +#define FIGHT_AREA_WIDTH 61 +#define FIGHT_AREA_HEIGHT 28 +#define FIGHT_NEARBY_RADIUS 5 + +// ============================================================================ +// GAMEPLAY FLAGS +// ============================================================================ + +#define ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK 1 +#define ONLY_SWITCH_GEAR_WHEN_ATTACK_SOON 1 +#define ALLOW_SMITE 1 +#define ALLOW_REDEMPTION 1 +#define ALLOW_MOVING_IF_CAN_ATTACK 0 + +// ============================================================================ +// MAGIC SPELL LEVELS AND DAMAGE +// ============================================================================ + +#define ICE_RUSH_LEVEL 58 +#define ICE_BURST_LEVEL 70 +#define ICE_BLITZ_LEVEL 82 +#define ICE_BARRAGE_LEVEL 94 + +#define BLOOD_RUSH_LEVEL 56 +#define BLOOD_BURST_LEVEL 68 +#define BLOOD_BLITZ_LEVEL 80 +#define BLOOD_BARRAGE_LEVEL 92 + +#define ICE_RUSH_MAX_HIT 18 +#define ICE_BURST_MAX_HIT 22 +#define ICE_BLITZ_MAX_HIT 26 +#define ICE_BARRAGE_MAX_HIT 30 + +#define BLOOD_RUSH_MAX_HIT 15 +#define BLOOD_BURST_MAX_HIT 21 +#define BLOOD_BLITZ_MAX_HIT 25 +#define BLOOD_BARRAGE_MAX_HIT 29 + +#define ATTACK_TIMER_INACTIVE -1000000 + +// ============================================================================ +// EQUIPMENT SLOTS +// ============================================================================ + +// Number of equipment slots (HEAD, CAPE, NECK, AMMO, WEAPON, SHIELD, BODY, LEGS, HANDS, FEET, RING) +#define NUM_GEAR_SLOTS 11 + +// ============================================================================ +// CURRENT LOADOUT-BASED ACTION SPACE +// ============================================================================ +// 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 7 + +// 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 + +// Per-head action dimensions +#define LOADOUT_DIM 9 // KEEP, MELEE, RANGE, MAGE, TANK, SPEC_MELEE, SPEC_RANGE, SPEC_MAGIC, GMAUL +#define COMBAT_DIM 13 // NONE, ATK, ICE, BLOOD, ADJACENT, UNDER, DIAGONAL, FARCAST_2..7 +#define OVERHEAD_DIM 6 // NONE, MAGE, RANGED, MELEE, SMITE, REDEMPTION +#define FOOD_DIM 2 // NONE, EAT +#define POTION_DIM 5 // NONE, BREW, RESTORE, COMBAT, RANGED +#define KARAMBWAN_DIM 2 // NONE, EAT +#define VENG_DIM 2 // NONE, CAST + +// Total action mask size: sum of all head dims = 39 +#define ACTION_MASK_SIZE (LOADOUT_DIM + COMBAT_DIM + OVERHEAD_DIM + \ + FOOD_DIM + POTION_DIM + KARAMBWAN_DIM + VENG_DIM) + +// Per-head action dims array +static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { + LOADOUT_DIM, + COMBAT_DIM, + OVERHEAD_DIM, + FOOD_DIM, + POTION_DIM, + KARAMBWAN_DIM, + VENG_DIM, +}; + +// Number of item stats per item (for observations) +#define NUM_ITEM_STATS 18 + +// Maximum items per slot for observation padding +#define MAX_ITEMS_PER_SLOT 10 + +// Dynamic gear slots that change during combat +// 8 slots: weapon, shield, body, legs, head, cape, neck, ring +#define NUM_DYNAMIC_GEAR_SLOTS 8 + +//* Observation size: 182 base + 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 + +// ============================================================================ +// CORE ENUMS +// ============================================================================ + +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; + +typedef enum { + FIGHT_STYLE_ACCURATE = 0, + FIGHT_STYLE_AGGRESSIVE, + FIGHT_STYLE_CONTROLLED, + FIGHT_STYLE_DEFENSIVE +} FightStyle; + +typedef enum { + MELEE_BONUS_STAB = 0, + MELEE_BONUS_SLASH, + MELEE_BONUS_CRUSH +} MeleeBonusType; + +// ============================================================================ +// SPECIAL ATTACK WEAPON ENUMS +// ============================================================================ + +typedef enum { + MELEE_SPEC_NONE = 0, + MELEE_SPEC_AGS, + MELEE_SPEC_DRAGON_CLAWS, + MELEE_SPEC_GRANITE_MAUL, + MELEE_SPEC_DRAGON_DAGGER, + MELEE_SPEC_VOIDWAKER, + MELEE_SPEC_DWH, + MELEE_SPEC_BGS, + MELEE_SPEC_ZGS, + MELEE_SPEC_SGS, + MELEE_SPEC_ANCIENT_GS, + MELEE_SPEC_VESTAS, + MELEE_SPEC_ABYSSAL_DAGGER, + MELEE_SPEC_DRAGON_LONGSWORD, + MELEE_SPEC_DRAGON_MACE, + MELEE_SPEC_ABYSSAL_BLUDGEON +} MeleeSpecWeapon; + +typedef enum { + RANGED_SPEC_NONE = 0, + RANGED_SPEC_DARK_BOW, + RANGED_SPEC_BALLISTA, + RANGED_SPEC_ACB, + RANGED_SPEC_ZCB, + RANGED_SPEC_DRAGON_KNIFE, + RANGED_SPEC_MSB, + RANGED_SPEC_MORRIGANS +} RangedSpecWeapon; + +typedef enum { + MAGIC_SPEC_NONE = 0, + MAGIC_SPEC_VOLATILE_STAFF +} MagicSpecWeapon; + +// ============================================================================ +// LOADOUT-BASED ACTION ENUMS +// ============================================================================ + +/** Equipment slot indices. */ +typedef enum { + GEAR_SLOT_HEAD = 0, + GEAR_SLOT_CAPE, + GEAR_SLOT_NECK, + GEAR_SLOT_AMMO, + GEAR_SLOT_WEAPON, + GEAR_SLOT_SHIELD, + GEAR_SLOT_BODY, + GEAR_SLOT_LEGS, + GEAR_SLOT_HANDS, + GEAR_SLOT_FEET, + GEAR_SLOT_RING, +} GearSlotIndex; + +/** Dynamic gear slots that change during combat. */ +static const int DYNAMIC_GEAR_SLOTS[NUM_DYNAMIC_GEAR_SLOTS] = { + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, GEAR_SLOT_LEGS, + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING +}; + +/** Loadout action head options. */ +typedef enum { + LOADOUT_KEEP = 0, + LOADOUT_MELEE, + LOADOUT_RANGE, + LOADOUT_MAGE, + LOADOUT_TANK, + LOADOUT_SPEC_MELEE, + LOADOUT_SPEC_RANGE, + LOADOUT_SPEC_MAGIC, + LOADOUT_GMAUL, +} LoadoutAction; + +/** + * Combat action head values (merged attack + movement, dim=13). + * Attacks and movement are mutually exclusive per tick. + * OSRS melee requires cardinal adjacency; auto-walk handles positioning. + */ +#define ATTACK_NONE 0 +#define ATTACK_ATK 1 +#define ATTACK_ICE 2 +#define ATTACK_BLOOD 3 +#define MOVE_ADJACENT 4 +#define MOVE_UNDER 5 +#define MOVE_DIAGONAL 6 +#define MOVE_FARCAST_2 7 +#define MOVE_FARCAST_3 8 +#define MOVE_FARCAST_4 9 +#define MOVE_FARCAST_5 10 +#define MOVE_FARCAST_6 11 +#define MOVE_FARCAST_7 12 +#define MOVE_NONE ATTACK_NONE + +static inline int is_attack_action(int v) { return v >= ATTACK_ATK && v <= ATTACK_BLOOD; } +static inline int is_move_action(int v) { return v >= MOVE_ADJACENT && v <= MOVE_FARCAST_7; } + +/** Overhead prayer action head options. */ +typedef enum { + OVERHEAD_NONE = 0, + OVERHEAD_MAGE, + OVERHEAD_RANGED, + OVERHEAD_MELEE, + OVERHEAD_SMITE, + OVERHEAD_REDEMPTION, +} OverheadAction; + +/** Food action head options. */ +typedef enum { + FOOD_NONE = 0, + FOOD_EAT, +} FoodAction; + +/** Potion action head options. */ +typedef enum { + POTION_NONE = 0, + POTION_BREW, + POTION_RESTORE, + POTION_COMBAT, + POTION_RANGED, + POTION_ANTIVENOM, /* zulrah only — outside PvP action space (POTION_DIM=5) */ +} PotionAction; + +/** Karambwan action head options. */ +typedef enum { + KARAM_NONE = 0, + KARAM_EAT, +} KaramAction; + +/** Vengeance action head options. */ +typedef enum { + VENG_NONE = 0, + VENG_CAST, +} VengAction; + +// ============================================================================ +// GEAR BONUS STRUCTS +// ============================================================================ + +/* 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; + +// ============================================================================ +// COMBAT STRUCTS +// ============================================================================ + +typedef struct { + int damage; + int ticks_until_hit; + AttackStyle attack_type; + int is_special; + int hit_success; + int freeze_ticks; + int heal_percent; + int drain_type; + int drain_percent; + int flat_heal; // fixed HP heal for attacker (e.g. ancient GS blood sacrifice) + int is_morr_bleed; // when this hit lands, set morr_dot_remaining to damage dealt + OverheadPrayer defender_prayer_at_attack; +} PendingHit; + +// ============================================================================ +// ENTITY TYPE (player vs NPC — used by renderer and encounter system) +// ============================================================================ + +typedef enum { + ENTITY_PLAYER = 0, + ENTITY_NPC = 1, +} EntityType; + +// ============================================================================ +// PLAYER / ENTITY STRUCT +// ============================================================================ + +typedef struct { + EntityType entity_type; /* ENTITY_PLAYER or ENTITY_NPC */ + int npc_def_id; /* NPC definition ID (unused for players) */ + int npc_visible; /* render visibility flag (NPCs only, e.g. Zulrah dive) */ + int npc_size; /* NPC hitbox size in tiles (1 for players, 5 for Zulrah, etc.) */ + int npc_anim_id; /* current animation seq ID (-1 = use idle from NpcModelMapping) */ + + // Game mode flags (set per-player during c_reset) + int is_lms; + + // Base stats + int base_attack; + int base_strength; + int base_defence; + int base_ranged; + int base_magic; + int base_prayer; + int base_hitpoints; + + // Current stats + int current_attack; + int current_strength; + int current_defence; + int current_ranged; + int current_magic; + int current_prayer; + int current_hitpoints; + + // Special attack state + int special_energy; + int special_regen_ticks; + int spec_regen_active; + int was_lightbearer_equipped; + int spec_armed; /* 1 = next attack uses special (shared across encounters) */ + OsrsInteraction interaction; /* shared entity interaction 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; + + // Ring of recoil: reflects floor(damage * 0.1) + 1 back to attacker. + // charges track remaining recoil damage the ring can deal (starts at 40). + // at 0 the ring shatters (ring of suffering (i) never shatters). + int recoil_charges; + + // Prayer and style + OverheadPrayer prayer; + OffensivePrayer offensive_prayer; + FightStyle fight_style; + int prayer_drain_counter; // Accumulates drain, triggers at drain_resistance + + // Position + int x, y; + int dest_x, dest_y; + int is_moving; + int is_running; + int run_energy; + int run_recovery_ticks; + int last_obs_target_x; + int last_obs_target_y; + + // Attack tracking + int just_attacked; + AttackStyle last_attack_style; + int last_queued_hit_damage; // Damage of most recent attack (XP drop equivalent) + int attack_was_on_prayer; // 1 if defender had correct prayer when attack processed + int attack_click_canceled; + int attack_click_ready; + int last_attack_dx; + int last_attack_dy; + int last_attack_dist; + + // Pending hits queue + PendingHit pending_hits[MAX_PENDING_HITS]; + int num_pending_hits; + int damage_applied_this_tick; + int did_attack_auto_move; // set in attack movement phase, read in attack combat phase + + // Hit event tracking for event log + int hit_landed_this_tick; + int hit_was_successful; + int hit_damage; + AttackStyle hit_style; + OverheadPrayer hit_defender_prayer; + int hit_was_on_prayer; + int hit_attacker_idx; + int freeze_applied_this_tick; + + // Morrigan's javelin DoT (Phantom Strike): 5 HP every 3 ticks from calc tick + int morr_dot_remaining; // remaining bleed damage to deliver + int morr_dot_tick_counter; // ticks until next bleed (counts down from 3) + + // Damage tracking + float last_target_health_percent; + float tick_damage_scale; + float damage_dealt_scale; + float damage_received_scale; + + // Hit statistics + int total_target_hit_count; + int target_hit_melee_count; + int target_hit_ranged_count; + int target_hit_magic_count; + int target_hit_off_prayer_count; + int target_hit_correct_count; + + int total_target_pray_count; + int target_pray_melee_count; + int target_pray_ranged_count; + int target_pray_magic_count; + int target_pray_correct_count; + + int player_hit_melee_count; + int player_hit_ranged_count; + int player_hit_magic_count; + + int player_pray_melee_count; + int player_pray_ranged_count; + int player_pray_magic_count; + + // History buffers + AttackStyle recent_target_attack_styles[HISTORY_SIZE]; + AttackStyle recent_player_attack_styles[HISTORY_SIZE]; + AttackStyle recent_target_prayer_styles[HISTORY_SIZE]; + AttackStyle recent_player_prayer_styles[HISTORY_SIZE]; + int recent_target_prayer_correct[HISTORY_SIZE]; + int recent_target_hit_correct[HISTORY_SIZE]; + int recent_target_attack_index; + int recent_player_attack_index; + int recent_target_prayer_index; + int recent_player_prayer_index; + int recent_target_prayer_correct_index; + int recent_target_hit_correct_index; + + // Observed target stats + int target_magic_accuracy; + int target_magic_strength; + int target_ranged_accuracy; + int target_ranged_strength; + int target_melee_accuracy; + int target_melee_strength; + int target_magic_gear_magic_defence; + int target_magic_gear_ranged_defence; + int target_magic_gear_melee_defence; + int target_ranged_gear_magic_defence; + int target_ranged_gear_ranged_defence; + int target_ranged_gear_melee_defence; + int target_melee_gear_magic_defence; + int target_melee_gear_ranged_defence; + int target_melee_gear_melee_defence; + + // Prayer correctness flags + int player_prayed_correct; + int target_prayed_correct; + + // Total damage + float total_damage_dealt; + float total_damage_received; + + // Equipment flags + int is_lunar_spellbook; + int observed_target_lunar_spellbook; + int has_blood_fury; + int has_dharok; + + // Spec weapons + MeleeSpecWeapon melee_spec_weapon; + RangedSpecWeapon ranged_spec_weapon; + MagicSpecWeapon magic_spec_weapon; + + // Bolt procs + float bolt_proc_damage; + int bolt_ignores_defense; + + // Slot-based mode equipment (per-slot item indices, 255 = empty) + // equipped[GEAR_SLOT_*] = item index from ITEMS_BY_SLOT table, or 255 if empty + uint8_t equipped[NUM_GEAR_SLOTS]; + + // Available items per slot (for action masking and observations) + // inventory[slot][item_idx] = item database index, 255 = no item + uint8_t inventory[NUM_GEAR_SLOTS][MAX_ITEMS_PER_SLOT]; + + // Number of items available per slot + uint8_t num_items_in_slot[NUM_GEAR_SLOTS]; + + // Cached bonuses for slot-based mode + GearBonuses slot_cached_bonuses; + int slot_gear_dirty; + + // Per-tick action tracking for reward shaping + // These are set when actions actually execute (not when queued) + AttackStyle attack_style_this_tick; // Actual attack style used (NONE if no attack) + int magic_type_this_tick; // 0=none, 1=ice, 2=blood (for visual effects) + int used_special_this_tick; // 1 if special attack was used + int ate_food_this_tick; // 1 if regular food was consumed + int ate_karambwan_this_tick; // 1 if karambwan was consumed + int ate_brew_this_tick; // 1 if saradomin brew was consumed + int cast_veng_this_tick; // 1 if vengeance was cast (for animation) + int clicks_this_tick; // accumulated click count for progressive penalty + + // Previous tick HP percent for reward shaping (premature/wasted eat checks) + float prev_hp_percent; + + // GUI stats panel fields (synced from encounter state each render tick) + int gui_max_hit; + int gui_attack_speed; + int gui_attack_range; + int gui_strength_bonus; +} Player; + +// ============================================================================ +// LOGGING STRUCT +// ============================================================================ + +typedef struct { + float episode_return; + float episode_length; + float wins; + float damage_dealt; + float damage_received; + float wave; + float prayer_correct; + float prayer_total; + float idle_ticks; + float brews_used; + float blood_healed; + /* behavioral metrics */ + float npc_kills; + float gear_switches; + float current_ranged; + float current_magic; + 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) */ + /* action noop rates per head (0=move,1=prayer,2=target,3=gear,4=eat,5=pot,6=spell,7=spec) */ + 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; + +// ============================================================================ +// REWARD SHAPING CONFIG +// ============================================================================ + +typedef struct { + // Per-tick shaping coefficients + float damage_dealt_coef; // per-HP dealt + float damage_received_coef; // per-HP received (negative) + float correct_prayer_bonus; // blocked attack with correct prayer + float wrong_prayer_penalty; // got hit off-prayer + float prayer_switch_no_attack_penalty; // switched protection prayer but opponent didn't attack + float off_prayer_hit_bonus; // hit opponent off-prayer + float melee_frozen_penalty; // melee while frozen and out of range + float wasted_eat_penalty; // per wasted HP of healing overflow + float premature_eat_penalty; // eating above premature threshold + float magic_no_staff_penalty; // casting magic without staff (deprecated, use gear_mismatch) + float gear_mismatch_penalty; // attacking with negative bonus for the attack style + float spec_off_prayer_bonus; // spec when target not praying melee + float spec_low_defence_bonus; // spec when target in mage gear + float spec_low_hp_bonus; // spec when target below 50% HP + float smart_triple_eat_bonus; // triple eat at low HP + float wasted_triple_eat_penalty; // per wasted karam HP at high HP + float damage_burst_bonus; // per HP above burst threshold + int damage_burst_threshold; // minimum damage for burst bonus + float premature_eat_threshold; // HP percent above which eating is premature (70/99) + // Terminal shaping + float ko_bonus; // bonus for KO (opponent had food left) + float wasted_resources_penalty; // dying with food/brews left + // Scale (annealed from Python during training) + float shaping_scale; // 1.0 → floor over training + int enabled; // 0 = sparse only, 1 = full shaping + // Always-on behavioral penalties (independent of `enabled`) + int prayer_penalty_enabled; // wasteful prayer switch penalty + int click_penalty_enabled; // progressive excess-click penalty + int click_penalty_threshold; // free clicks before penalty kicks in + float click_penalty_coef; // penalty per excess click (negative) +} RewardShapingConfig; + +// ============================================================================ +// OPPONENT TYPES (used by osrs_pvp_opponents.h functions) +// ============================================================================ + +typedef enum { + OPP_NONE = 0, + OPP_TRUE_RANDOM, + OPP_PANICKING, + OPP_WEAK_RANDOM, + OPP_SEMI_RANDOM, + OPP_STICKY_PRAYER, + OPP_RANDOM_EATER, + OPP_PRAYER_ROOKIE, + OPP_IMPROVED, + OPP_MIXED_EASY, + OPP_MIXED_MEDIUM, + OPP_ONETICK, + OPP_UNPREDICTABLE_IMPROVED, + OPP_UNPREDICTABLE_ONETICK, + OPP_MIXED_HARD, + OPP_MIXED_HARD_BALANCED, + OPP_PFSP, + OPP_NOVICE_NH, + OPP_APPRENTICE_NH, + OPP_COMPETENT_NH, + OPP_INTERMEDIATE_NH, + OPP_ADVANCED_NH, + OPP_PROFICIENT_NH, + OPP_EXPERT_NH, + OPP_MASTER_NH, + OPP_SAVANT_NH, + OPP_NIGHTMARE_NH, + OPP_VENG_FIGHTER, + OPP_BLOOD_HEALER, + OPP_GMAUL_COMBO, + OPP_RANGE_KITER, + OPP_SELFPLAY, +} OpponentType; + +#define MAX_OPPONENT_POOL 32 + +typedef struct { + OpponentType pool[MAX_OPPONENT_POOL]; + int cum_weights[MAX_OPPONENT_POOL]; /* cumulative weights * 1000 */ + int pool_size; + int active_pool_idx; /* which pool entry is active this episode */ + float wins[MAX_OPPONENT_POOL]; /* per-opponent wins (read+reset by Python) */ + float episodes[MAX_OPPONENT_POOL]; /* per-opponent episode count */ +} PFSPState; + +typedef struct { + OpponentType type; + OpponentType active_sub_policy; + int chosen_prayer; + int chosen_style; + int current_prayer; + int current_prayer_set; + int food_cooldown; + int potion_cooldown; + int karambwan_cooldown; + + /* Phase 2: onetick + realistic policy state */ + int fake_switch_pending; /* 0/1 */ + int fake_switch_style; /* OPP_STYLE_* or -1 */ + int opponent_prayer_at_fake; /* OPP_STYLE_* or -1 (style they were praying) */ + int fake_switch_failed; /* 0/1 (unpredictable_onetick only) */ + int pending_prayer_value; /* OVERHEAD_* value, 0 = none */ + int pending_prayer_delay; /* ticks remaining before applying */ + int last_target_gear_style; /* OPP_STYLE_* or -1, tracks previous tick */ + + /* Per-episode eating thresholds (randomized with noise) */ + float eat_triple_threshold; /* base 0.30, range [0.25, 0.35] */ + float eat_double_threshold; /* base 0.50, range [0.45, 0.55] */ + float eat_brew_threshold; /* base 0.70, range [0.65, 0.75] */ + + /* Per-episode randomized decision parameters */ + float prayer_accuracy; /* chance of correct defensive prayer [0,1] */ + float off_prayer_rate; /* chance of attacking off-prayer [0,1] */ + float offensive_prayer_rate; /* chance of using offensive prayer [0,1] */ + float action_delay_chance; /* per-tick chance to skip prayer+attack [0,0.3] */ + float mistake_rate; /* per-tick chance to pick random prayer [0,0.15] */ + + /* Boss opponent reading ability (master_nh, savant_nh) */ + float read_chance; /* 0.0-1.0, chance to "read" agent action each tick */ + int has_read_this_tick; /* 1 if read succeeded this tick */ + AttackStyle read_agent_style; /* agent's pending attack style (if read) */ + OverheadPrayer read_agent_prayer;/* agent's pending overhead prayer (if read) */ + int read_agent_moving; /* boss read: 1 if agent is moving (not attacking) */ + + /* Anti-kite flee tracking */ + int prev_dist_to_target; /* previous tick distance for flee tracking */ + int target_fleeing_ticks; /* consecutive ticks distance has been increasing */ + + /* gmaul_combo state */ + int combo_state; /* 0=idle, 1=spec_fired (follow with gmaul next tick) */ + float ko_threshold; /* target HP fraction to trigger KO sequence */ + + /* Offensive prayer miss: chance to attack without switching loadout (skipping auto-prayer) */ + float offensive_prayer_miss; + + /* Per-episode style bias: weighted preference for melee/ranged/mage */ + float style_bias[3]; +} OpponentState; + +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) + +// ============================================================================ +// MAIN ENVIRONMENT STRUCT +// ============================================================================ + +typedef struct { + Log log; + + float* observations; + int* actions; + float* rewards; + unsigned char* terminals; + unsigned char* action_masks; + unsigned char action_masks_agents; + int num_agents; + + Player players[NUM_AGENTS]; + + int tick; + int episode_over; + int winner; + int auto_reset; + int pid_holder; + int pid_shuffle_countdown; // ticks until next PID swap (100-150) + + int is_lms; + + 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 = legacy step/reset path for backwards compat). + // When set, c_step/c_reset dispatch through these instead of the default 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; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +static inline int abs_int(int val) { + return val < 0 ? -val : val; +} + +static inline int min_int(int a, int b) { + return a < b ? a : b; +} + +static inline int max_int(int a, int b) { + return a > b ? a : b; +} + +static inline int clamp(int val, int min, int max) { + if (val < min) return min; + if (val > max) return max; + return val; +} + +static inline float clampf(float val, float min, float max) { + if (val < min) return min; + if (val > max) return max; + return val; +} + +/** Chebyshev distance - OSRS uses this for range checks. */ +static inline int chebyshev_distance(int x1, int y1, int x2, int y2) { + int dx = x1 - x2; + int dy = y1 - y2; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + return (dx > dy) ? dx : dy; +} + +/** + * OSRS melee range check: CARDINAL ADJACENCY ONLY (N/S/E/W). + * + * Standard melee weapons (range=1) can ONLY hit from cardinal-adjacent tiles. + * Diagonal tiles (Chebyshev dist=1) are NOT valid melee positions. + * Auto-walk for melee attacks also paths to cardinal adjacency, not diagonal. + * + * This applies to all current weapons. Halberds (range=2, e.g. noxious halberd) + * will need a separate range model when added — they can hit from 2 tiles away + * including some diagonal positions. + */ +static inline int is_in_melee_range(Player* p, Player* t) { + int dx = abs_int(p->x - t->x); + int dy = abs_int(p->y - t->y); + return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); +} + +// ============================================================================ +// RNG FUNCTIONS +// ============================================================================ + +static inline uint32_t xorshift32(uint32_t* state) { + uint32_t x = *state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *state = x; + return x; +} + +static inline int rand_int(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; +} + +// ============================================================================ +// TIMER HELPERS +// ============================================================================ + +static inline int remaining_ticks(int ticks) { + return ticks > 0 ? ticks : 0; +} + +static inline int get_attack_timer_uncapped(Player* p) { + return p->has_attack_timer ? p->attack_timer_uncapped : -100; +} + +static inline int can_attack_now(Player* p) { + if (!p->has_attack_timer) return 1; + return p->attack_timer < 0; +} + +static inline int can_move(Player* p) { + return p->frozen_ticks <= 0; +} + +/** Safe ratio calculation (returns 0 if denominator is 0). */ +static inline float ratio_or_zero(int numerator, int denominator) { + if (denominator == 0) { + return 0.0f; + } + return (float)numerator / (float)denominator; +} + +/** Scale confidence based on sample count (saturates at 10). */ +static inline float confidence_scale(int count) { + if (count >= 10) { + return 1.0f; + } + return (float)count / 10.0f; +} + +/** Check if lightbearer ring is equipped (ITEM_LIGHTBEARER = 49). */ +static inline int is_lightbearer_equipped(Player* p) { + return p->equipped[GEAR_SLOT_RING] == 49; +} + +#define RECOIL_MAX_CHARGES 40 + +#endif // OSRS_TYPES_H From 4aee0baee5f6ceb1484fde203a52dfa6f06f95e9 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 19:58:33 +0300 Subject: [PATCH 02/60] wire OSRS visual asset download into build.sh visual builds (--local/--fast/--web) auto-download pre-exported assets from GitHub releases. training builds need no assets. OSRS visual binary uses shared ocean/osrs/osrs_visual.c source. --- build.sh | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 0f741d5263..0bd84c1607 100755 --- a/build.sh +++ b/build.sh @@ -119,6 +119,16 @@ elif [ "$ENV" = "impulse_wars" ]; then elif [[ "$ENV" == osrs_* ]]; then SRC_DIR="ocean/$ENV" INCLUDES+=(-I./src/osrs) + # download visual assets for standalone builds (training doesn't need them) + if [ "$MODE" = "local" ] || [ "$MODE" = "fast" ] || [ "$MODE" = "web" ]; then + OSRS_DATA="ocean/osrs/data" + if [ ! -f "$OSRS_DATA/equipment.models" ]; then + echo "Downloading OSRS visual assets..." + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v1/osrs-assets.tar.gz" + mkdir -p "$OSRS_DATA" + curl -sL "$OSRS_ASSETS_URL" | tar xz -C "$OSRS_DATA" + fi + fi elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" else @@ -138,13 +148,21 @@ else 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[@]}" From 4dac224885a8a0bd685566a1024404a1e00663f1 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 20:22:56 +0300 Subject: [PATCH 03/60] fix config env_names, test includes, remove dead code env_name must match build dir name (osrs_inferno not puffer_osrs_inferno) or pufferl.py _resolve_backend assertion fails. test build instructions updated for src/osrs/ header location. removed legacy export_inferno_npcs.py (superseded by tools/export_encounter_npcs.py) and standalone Makefile (build.sh --local handles visual builds). --- config/osrs_inferno.ini | 5 +- config/osrs_inferno_zuk.ini | 5 +- config/osrs_pvp.ini | 5 +- config/osrs_zulrah.ini | 5 +- ocean/osrs/Makefile | 43 -- ocean/osrs/scripts/export_inferno_npcs.py | 866 ---------------------- ocean/osrs/tests/test_bolt_procs.c | 6 +- ocean/osrs/tests/test_collision.c | 2 +- ocean/osrs/tests/test_combat_math.c | 8 +- ocean/osrs/tests/test_consumables.c | 4 +- ocean/osrs/tests/test_damage.c | 6 +- ocean/osrs/tests/test_interaction.c | 6 +- ocean/osrs/tests/test_inventory.c | 6 +- ocean/osrs/tests/test_item_effects.c | 6 +- ocean/osrs/tests/test_player_combat.c | 4 +- ocean/osrs/tests/test_special_attacks.c | 10 +- ocean/osrs_pvp/binding.c | 1 - 17 files changed, 37 insertions(+), 951 deletions(-) delete mode 100644 ocean/osrs/Makefile delete mode 100644 ocean/osrs/scripts/export_inferno_npcs.py diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index fa61afb28e..cb611614d6 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -1,9 +1,8 @@ -# Metal config for OSRS Inferno encounter. +# OSRS Inferno encounter. # 8 action heads (79 logits), 1058 obs, long episodes (300-8000+ ticks). [base] -package = ocean -env_name = puffer_osrs_inferno +env_name = osrs_inferno policy_name = MinGRU rnn_name = Recurrent score_metric = episode_return diff --git a/config/osrs_inferno_zuk.ini b/config/osrs_inferno_zuk.ini index fb57150d78..2eabbebfca 100644 --- a/config/osrs_inferno_zuk.ini +++ b/config/osrs_inferno_zuk.ini @@ -1,10 +1,9 @@ -# Metal config for Zuk-only training (wave 69). +# Zuk-only training (wave 69). # short episodes (~30-300 ticks), focused on learning shield-dancing + DPS. # default config from best sweep trial (trial 12, score 32.2). [base] -package = ocean -env_name = puffer_osrs_inferno +env_name = osrs_inferno policy_name = MinGRU rnn_name = Recurrent score_metric = episode_return diff --git a/config/osrs_pvp.ini b/config/osrs_pvp.ini index 8610497c5a..59c92cdae5 100644 --- a/config/osrs_pvp.ini +++ b/config/osrs_pvp.ini @@ -1,9 +1,8 @@ -# Metal config for OSRS NH PvP encounter. +# OSRS NH PvP encounter. # 7 action heads (39 logits), 334 obs + 39 mask = 373 total, short episodes (~300 ticks). [base] -package = ocean -env_name = puffer_osrs_pvp +env_name = osrs_pvp policy_name = MinGRU rnn_name = Recurrent score_metric = episode_return diff --git a/config/osrs_zulrah.ini b/config/osrs_zulrah.ini index 1a6c7d43e5..4d34053cf6 100644 --- a/config/osrs_zulrah.ini +++ b/config/osrs_zulrah.ini @@ -1,9 +1,8 @@ -# Metal config for OSRS Zulrah encounter. +# OSRS Zulrah encounter. # 6 action heads (41 logits), 81 obs + 41 mask = 122 total, medium episodes (~600 ticks max). [base] -package = ocean -env_name = puffer_osrs_zulrah +env_name = osrs_zulrah policy_name = MinGRU rnn_name = Recurrent score_metric = episode_return diff --git a/ocean/osrs/Makefile b/ocean/osrs/Makefile deleted file mode 100644 index 380b9fb2e5..0000000000 --- a/ocean/osrs/Makefile +++ /dev/null @@ -1,43 +0,0 @@ -# OSRS environment Makefile -# -# standalone targets (no PufferLib dependency): -# make — headless benchmark binary -# make visual — headed raylib viewer with human input -# make debug — debug build with sanitizers -# -# PufferLib training uses setup.py build_osrs instead. - -CC = clang -CFLAGS = -Wall -Wextra -O3 -ffast-math -flto -fPIC -std=c11 -DEBUG_FLAGS = -Wall -Wextra -g -O0 -fPIC -std=c11 -DDEBUG -LDFLAGS = -lm - -TARGET = osrs_visual -DEMO_SRC = osrs_visual.c -HEADERS = osrs_env.h - -# Raylib (for visual target). download from https://github.com/raysan5/raylib/releases -RAYLIB_DIR = raylib-5.5_macos -UNAME_S := $(shell uname -s) -ifeq ($(UNAME_S),Darwin) -RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include $(RAYLIB_DIR)/lib/libraylib.a \ - -framework Cocoa -framework OpenGL -framework IOKit -framework CoreVideo -else -RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include -L$(RAYLIB_DIR)/lib -lraylib -lGL -lpthread -ldl -lrt -endif - -.PHONY: all clean debug visual - -all: $(TARGET) - -$(TARGET): $(DEMO_SRC) $(HEADERS) - $(CC) $(CFLAGS) -o $@ $(DEMO_SRC) $(LDFLAGS) - -visual: $(DEMO_SRC) $(HEADERS) osrs_render.h osrs_gui.h - $(CC) $(CFLAGS) -DOSRS_VISUAL $(RAYLIB_FLAGS) -o $(TARGET) $(DEMO_SRC) $(LDFLAGS) - -debug: $(DEMO_SRC) $(HEADERS) - $(CC) $(DEBUG_FLAGS) -o $(TARGET)_debug $(DEMO_SRC) $(LDFLAGS) - -clean: - rm -f $(TARGET) $(TARGET)_debug *.o diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py deleted file mode 100644 index 78e8766339..0000000000 --- a/ocean/osrs/scripts/export_inferno_npcs.py +++ /dev/null @@ -1,866 +0,0 @@ -"""Export inferno NPC models, animations, and spotanim GFX from modern OSRS cache. - -Reads NPC definitions for all inferno monsters (nibblers through Zuk), extracts -their model IDs and animation sequence IDs, exports 3D meshes to .models binary, -exports animations to .anims binary, and updates npc_models.h with mappings. - -Also reads SpotAnim (GFX) configs for inferno projectiles. - -Usage: - uv run python scripts/export_inferno_npcs.py \ - --modern-cache /path/to/osrs-cache-modern \ - --output-dir data -""" - -import argparse -import copy -import io -import struct -import sys -from dataclasses import dataclass, field -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from modern_cache_reader import ( - ModernCacheReader, - read_big_smart, - read_i32, - read_string, - read_u8, - read_u16, - read_u24, - read_u32, -) -from export_models import ( - MDL2_MAGIC, - ModelData, - _merge_models, - decode_model, - expand_model, - load_model_modern, - write_models_binary, -) -from export_animations import ( - ANIM_MAGIC, - FrameBaseDef, - FrameDef, - SequenceDef, - _parse_normal_frame, - load_modern_framebases, - parse_modern_framebase, - write_animations_binary, -) -from modern_cache_reader import parse_sequence as parse_modern_sequence - -# modern cache layout -MODERN_NPC_CONFIG_GROUP = 9 # config index 2, group 9 = NPC definitions -MODERN_SPOTANIM_CONFIG_GROUP = 13 # config index 2, group 13 = SpotAnim/GFX -MODERN_SEQ_CONFIG_GROUP = 12 # config index 2, group 12 = sequences -MODERN_FRAME_INDEX = 0 # frame archives -MODERN_FRAMEBASE_INDEX = 1 # frame bases - -# inferno NPC IDs from the OSRS wiki -INFERNO_NPC_IDS = { - 7691: "Jal-Nib (nibbler)", - 7692: "Jal-MejRah (bat)", - 7693: "Jal-Ak (blob)", - 7694: "Jal-Ak-Rek-Ket (blob melee split)", - 7695: "Jal-Ak-Rek-Xil (blob range split)", - 7696: "Jal-Ak-Rek-Mej (blob mage split)", - 7697: "Jal-ImKot (meleer)", - 7698: "Jal-Xil (ranger)", - 7699: "Jal-Zek (mager)", - 7700: "JalTok-Jad", - 7701: "Yt-HurKot (jad healer)", - 7706: "TzKal-Zuk", - 7707: "Zuk shield", - 7708: "Jal-MejJak (zuk healer)", -} - -# attack anims are NOT in cache NPC config — they come from CombatAnimationData -# which is a separate client table. hardcoded from wiki/runelite/deob client. -INFERNO_ATTACK_ANIMS: dict[int, int] = { - 7691: 7574, # nibbler - 7692: 7578, # bat - 7693: 7581, # blob - 7694: 65535, # blob melee split (no attack anim) - 7695: 65535, # blob range split (no attack anim) - 7696: 65535, # blob mage split (no attack anim) - 7697: 7597, # meleer - 7698: 7605, # ranger - 7699: 7610, # mager - 7700: 7593, # jad - 7701: 65535, # healer jad (no attack anim) - 7706: 7566, # zuk - 7707: 65535, # zuk shield (no attack anim) - 7708: 65535, # zuk healer (no attack anim) -} - -# known inferno projectile/effect GFX IDs to check -# from OSRS wiki inferno page and runelite inferno plugin -INFERNO_SPOTANIM_IDS = { - # jad attacks - 447: "Jad ranged projectile (fireball)", - 448: "Jad magic projectile", - 451: "Jad ranged hit", - 157: "Jad magic hit", - # mager - 1379: "Mager magic projectile", - 1380: "Mager magic hit", - # ranger - 1377: "Ranger ranged projectile", - 1378: "Ranger ranged hit", - # zuk - 1375: "Zuk magic projectile", - 1376: "Zuk ranged projectile", - 1381: "Zuk typeless hit (falling rocks?)", - # bat - 1374: "Bat ranged projectile", - # blob - 1382: "Blob melee", - 1383: "Blob ranged", - 1384: "Blob magic", - # healer - 1385: "Healer magic attack", - # player projectiles (needed for tbow in inferno) - 942: "Dragon arrow projectile (twisted bow)", -} - - -@dataclass -class NpcDef: - """NPC definition from modern OSRS cache.""" - - npc_id: int = 0 - name: str = "" - model_ids: list[int] = field(default_factory=list) - chathead_model_ids: list[int] = field(default_factory=list) - size: int = 1 - idle_anim: int = -1 - walk_anim: int = -1 - run_anim: int = -1 - turn_180_anim: int = -1 - turn_cw_anim: int = -1 - turn_ccw_anim: int = -1 - attack_anim: int = -1 # from wiki/runelite, not in def directly - death_anim: int = -1 # from wiki/runelite, not in def directly - combat_level: int = 0 - width_scale: int = 128 - height_scale: int = 128 - recolor_src: list[int] = field(default_factory=list) - recolor_dst: list[int] = field(default_factory=list) - retexture_src: list[int] = field(default_factory=list) - retexture_dst: list[int] = field(default_factory=list) - - -@dataclass -class SpotAnimDef: - """SpotAnim (GFX) definition from modern OSRS cache.""" - - id: int = 0 - model_id: int = -1 - seq_id: int = -1 - recolor_src: list[int] = field(default_factory=list) - recolor_dst: list[int] = field(default_factory=list) - width_scale: int = 128 - height_scale: int = 128 - rotation: int = 0 - ambient: int = 0 - contrast: int = 0 - - -def parse_modern_npc_def(npc_id: int, data: bytes) -> NpcDef: - """Parse modern OSRS NPC definition from opcode stream. - - Opcode reference from RuneLite NpcLoader (modern revisions): - 1: model IDs (u8 count, u16[count]) - 2: name (string) - 12: size (u8) - 13: idle animation (u16) - 14: walk animation (u16) - 15: turn 180 animation (u16) - 16: turn CW animation (u16, modern split from old 17) - 17: turn CCW animation (u16) - 18: unused / walk backward (u16) - 19: unused (u8 from modern, or actions in old) - 30-34: actions (string each) - 40: recolor pairs (u8 count, u16+u16 per pair) - 41: retexture pairs (u8 count, u16+u16 per pair) - 60: chathead model IDs (u8 count, u16[count]) - 93: drawMapDot = false (flag) - 95: combat level (u16) - 97: width scale (u16) - 98: height scale (u16) - 99: hasRenderPriority (flag) - 100: ambient (u8) - 101: contrast (u8) - 102: head icon (bitfield + smart pairs) - 103: rotation (u16) - 106: morph (varbit+varp+count+children) - 107: isInteractable = false (flag) - 108: isPet = false (modern) - 109: isClickable = false (flag) - 111: isFollower (flag) - 114-118: various transform/morph opcodes - 249: params map - """ - d = NpcDef(npc_id=npc_id) - buf = io.BytesIO(data) - - while True: - opcode_byte = buf.read(1) - if not opcode_byte: - break - opcode = opcode_byte[0] - - if opcode == 0: - break - elif opcode == 1: - count = read_u8(buf) - d.model_ids = [read_u16(buf) for _ in range(count)] - elif opcode == 2: - d.name = read_string(buf) - elif opcode == 3: - read_string(buf) # description (removed in modern, but handle gracefully) - elif opcode == 5: - # pre-modern: another model list? skip - count = read_u8(buf) - for _ in range(count): - read_u16(buf) - elif opcode == 12: - d.size = read_u8(buf) - elif opcode == 13: - d.idle_anim = read_u16(buf) - elif opcode == 14: - d.walk_anim = read_u16(buf) - elif opcode == 15: - d.turn_180_anim = read_u16(buf) # idleRotateLeftAnimation - elif opcode == 16: - d.turn_cw_anim = read_u16(buf) # idleRotateRightAnimation - elif opcode == 17: - # walk + rotate180 + rotateLeft + rotateRight (4 x u16) - d.walk_anim = read_u16(buf) - d.turn_180_anim = read_u16(buf) - d.turn_cw_anim = read_u16(buf) - d.turn_ccw_anim = read_u16(buf) - elif opcode == 18: - read_u16(buf) # category - elif 30 <= opcode <= 34: - read_string(buf) # actions[0..4] - elif opcode == 40: - count = read_u8(buf) - for _ in range(count): - d.recolor_src.append(read_u16(buf)) - d.recolor_dst.append(read_u16(buf)) - elif opcode == 41: - count = read_u8(buf) - for _ in range(count): - d.retexture_src.append(read_u16(buf)) - d.retexture_dst.append(read_u16(buf)) - elif opcode == 60: - count = read_u8(buf) - d.chathead_model_ids = [read_u16(buf) for _ in range(count)] - elif 74 <= opcode <= 79: - read_u16(buf) # stats[opcode - 74] (attack/def/str/range/magic/hp) - elif opcode == 93: - pass # drawMapDot = false - elif opcode == 95: - d.combat_level = read_u16(buf) - elif opcode == 97: - d.width_scale = read_u16(buf) - elif opcode == 98: - d.height_scale = read_u16(buf) - elif opcode == 99: - pass # hasRenderPriority - elif opcode == 100: - read_u8(buf) # ambient - elif opcode == 101: - read_u8(buf) # contrast - elif opcode == 102: - # head icon sprite — u8 bitfield, per set bit: BigSmart2 + UnsignedShortSmartMinusOne - bitfield = read_u8(buf) - bit_count = 0 - tmp = bitfield - while tmp != 0: - bit_count += 1 - tmp >>= 1 - for i in range(bit_count): - if bitfield & (1 << i): - # BigSmart2: if first byte < 128, read u16; else read i32 & 0x7FFFFFFF - pos = buf.tell() - peek = buf.read(1) - if peek and peek[0] < 128: - buf.seek(pos) - read_u16(buf) - else: - buf.seek(pos) - read_i32(buf) - # UnsignedShortSmartMinusOne: same as big_smart but -1 - pos2 = buf.tell() - peek2 = buf.read(1) - if peek2 and peek2[0] < 128: - buf.seek(pos2) - read_u16(buf) - else: - buf.seek(pos2) - read_i32(buf) - elif opcode == 103: - read_u16(buf) # rotation - elif opcode == 106: - # morph: u16 varbit, u16 varp, u8 length, (length+1) u16 configs - read_u16(buf) # varbitId - read_u16(buf) # varpIndex - length = read_u8(buf) - for _ in range(length + 1): - read_u16(buf) # configs - elif opcode == 107: - pass # isInteractable = false - elif opcode == 108: - pass # isPet (modern) - elif opcode == 109: - pass # isClickable = false - elif opcode == 111: - pass # isFollower - elif opcode == 114: - read_u16(buf) # runSequence - elif opcode == 115: - read_u16(buf) # runSequence - read_u16(buf) # runBackSequence - read_u16(buf) # runRightSequence - read_u16(buf) # runLeftSequence - elif opcode == 116: - read_u16(buf) # crawlSequence - elif opcode == 117: - read_u16(buf) # crawlBackSequence - read_u16(buf) # crawlRightSequence - read_u16(buf) # crawlLeftSequence - elif opcode == 118: - # morph2: u16 varbit, u16 varp, u16 default, u8 length, (length+1) u16 configs - read_u16(buf) # varbitId - read_u16(buf) # varpIndex - read_u16(buf) # default child (var) - length = read_u8(buf) - for _ in range(length + 1): - read_u16(buf) # configs - elif opcode == 122: - pass # isFollower - elif opcode == 123: - pass # lowPriorityFollowerOps - elif opcode == 124: - read_u16(buf) # height - elif opcode == 125: - read_u8(buf) # unknown - elif opcode == 126: - read_u16(buf) # footprintSize - elif opcode == 128: - read_u8(buf) # unknown - elif opcode == 129: - pass # unknown flag - elif opcode == 130: - pass # idleAnimRestart - elif opcode == 145: - pass # canHideForOverlap - elif opcode == 146: - read_u16(buf) # overlapTintHSL - elif opcode == 147: - pass # zbuf = false - elif opcode == 249: - count_val = read_u8(buf) - for _ in range(count_val): - is_string = read_u8(buf) - read_u24(buf) # key (medium) - if is_string: - read_string(buf) - else: - read_u32(buf) - else: - print(f" warning: unknown npc opcode {opcode} at npc {npc_id}, pos {buf.tell()}", file=sys.stderr) - break - - return d - - -def parse_modern_spotanim(spotanim_id: int, data: bytes) -> SpotAnimDef: - """Parse modern SpotAnim/GFX definition from opcode stream. - - Opcode reference from RuneLite SpotAnimLoader: - 1: model ID (u16) - 2: sequence ID (u16) - 4: width scale (u16) - 5: height scale (u16) - 6: rotation (u16) - 7: ambient (u8) - 8: contrast (u8) - 40: recolor pairs (u8 count, u16+u16) - 41: retexture pairs (u8 count, u16+u16) - """ - d = SpotAnimDef(id=spotanim_id) - buf = io.BytesIO(data) - - while True: - opcode_byte = buf.read(1) - if not opcode_byte: - break - opcode = opcode_byte[0] - - if opcode == 0: - break - elif opcode == 1: - d.model_id = read_u16(buf) - elif opcode == 2: - d.seq_id = read_u16(buf) - elif opcode == 4: - d.width_scale = read_u16(buf) - elif opcode == 5: - d.height_scale = read_u16(buf) - elif opcode == 6: - d.rotation = read_u16(buf) - elif opcode == 7: - d.ambient = read_u8(buf) - elif opcode == 8: - d.contrast = read_u8(buf) - elif opcode == 40: - count = read_u8(buf) - for _ in range(count): - d.recolor_src.append(read_u16(buf)) - d.recolor_dst.append(read_u16(buf)) - elif opcode == 41: - count = read_u8(buf) - for _ in range(count): - read_u16(buf) - read_u16(buf) - else: - print(f" warning: unknown spotanim opcode {opcode} at gfx {spotanim_id}", file=sys.stderr) - break - - return d - - -def apply_recolors(md: ModelData, src: list[int], dst: list[int]) -> None: - """Apply recolor pairs to model face colors in-place.""" - for i, color in enumerate(md.face_colors): - for s, d in zip(src, dst): - if color == s: - md.face_colors[i] = d - break - - -def apply_scale(md: ModelData, width_scale: int, height_scale: int) -> None: - """Apply NPC width/height scale to vertex positions in-place.""" - if width_scale == 128 and height_scale == 128: - return - ws = width_scale / 128.0 - hs = height_scale / 128.0 - for i in range(md.vertex_count): - md.vertices_x[i] = int(md.vertices_x[i] * ws) - md.vertices_y[i] = int(md.vertices_y[i] * hs) - md.vertices_z[i] = int(md.vertices_z[i] * ws) - - -def main() -> None: - """Export inferno NPC data from modern OSRS cache.""" - parser = argparse.ArgumentParser(description="export inferno NPC models + animations from modern cache") - parser.add_argument( - "--modern-cache", type=Path, required=True, - help="path to modern OpenRS2 flat-file cache", - ) - parser.add_argument( - "--output-dir", type=Path, default=Path("data"), - help="output directory for generated files", - ) - args = parser.parse_args() - - reader = ModernCacheReader(args.modern_cache) - output_dir = args.output_dir - output_dir.mkdir(parents=True, exist_ok=True) - - # ================================================================ - # step 1: read NPC definitions from config index 2, group 9 - # ================================================================ - print("reading NPC definitions from modern cache (index 2, group 9)...") - npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) - print(f" {len(npc_files)} total NPC entries in group 9") - - npc_defs: dict[int, NpcDef] = {} - all_model_ids: set[int] = set() - all_anim_ids: set[int] = set() - - for npc_id, label in sorted(INFERNO_NPC_IDS.items()): - if npc_id not in npc_files: - print(f" NPC {npc_id} ({label}): NOT FOUND in cache") - continue - - npc = parse_modern_npc_def(npc_id, npc_files[npc_id]) - npc_defs[npc_id] = npc - - print(f"\n NPC {npc_id} ({label}):") - print(f" name: {npc.name}") - print(f" models: {npc.model_ids}") - print(f" size: {npc.size}") - print(f" idle_anim: {npc.idle_anim}") - print(f" walk_anim: {npc.walk_anim}") - print(f" scale: {npc.width_scale}x{npc.height_scale}") - if npc.recolor_src: - print(f" recolors: {list(zip(npc.recolor_src, npc.recolor_dst))}") - if npc.retexture_src: - print(f" retextures: {list(zip(npc.retexture_src, npc.retexture_dst))}") - - all_model_ids.update(npc.model_ids) - for anim_id in [npc.idle_anim, npc.walk_anim, npc.turn_180_anim, npc.turn_cw_anim, npc.turn_ccw_anim]: - if anim_id >= 0: - all_anim_ids.add(anim_id) - # 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) - - # ================================================================ - # step 2: read SpotAnim/GFX definitions - # ================================================================ - print("\n\nreading SpotAnim/GFX definitions (index 2, group 13)...") - spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) - print(f" {len(spotanim_files)} total spotanim entries") - - spotanim_defs: dict[int, SpotAnimDef] = {} - for gfx_id, label in sorted(INFERNO_SPOTANIM_IDS.items()): - if gfx_id not in spotanim_files: - print(f" GFX {gfx_id} ({label}): NOT FOUND in cache") - continue - - sa = parse_modern_spotanim(gfx_id, spotanim_files[gfx_id]) - spotanim_defs[gfx_id] = sa - - print(f" GFX {gfx_id} ({label}): model={sa.model_id}, seq={sa.seq_id}, " - f"scale={sa.width_scale}x{sa.height_scale}") - - if sa.model_id >= 0: - all_model_ids.add(sa.model_id) - if sa.seq_id >= 0: - all_anim_ids.add(sa.seq_id) - - print(f"\ntotal unique model IDs to export: {len(all_model_ids)}") - print(f" {sorted(all_model_ids)}") - print(f"total unique animation IDs to export: {len(all_anim_ids)}") - print(f" {sorted(all_anim_ids)}") - - # ================================================================ - # step 3: export NPC models - # ================================================================ - print("\n\nexporting NPC + GFX models...") - all_models: list[ModelData] = [] - - # for each NPC, merge sub-models, apply recolors/scale - for npc_id, npc in sorted(npc_defs.items()): - sub_models: list[ModelData] = [] - for mid in npc.model_ids: - raw = load_model_modern(reader, mid) - if raw is None: - print(f" warning: model {mid} not found for NPC {npc_id}") - continue - md = decode_model(mid, raw) - if md is None: - print(f" warning: failed to decode model {mid} for NPC {npc_id}") - continue - sub_models.append(md) - - if not sub_models: - print(f" NPC {npc_id}: no models decoded") - continue - - if len(sub_models) == 1: - merged = sub_models[0] - else: - merged = _merge_models(sub_models) - - # apply recolors - if npc.recolor_src: - apply_recolors(merged, npc.recolor_src, npc.recolor_dst) - - # apply scale - apply_scale(merged, npc.width_scale, npc.height_scale) - - # use NPC ID as model ID for lookup (synthetic: 0xC0000 + npc_id) - merged.model_id = 0xC0000 + npc_id - all_models.append(merged) - print(f" NPC {npc_id} ({npc.name}): {merged.vertex_count} verts, {merged.face_count} faces") - - # export GFX projectile models, applying spotanim recolors where needed. - # recolored models get synthetic IDs (0xD0000 | gfx_id) so the cache binary - # can hold both the raw and recolored variants of the same base model. - exported_gfx_models: set[int] = set() - for gfx_id, sa in sorted(spotanim_defs.items()): - if sa.model_id < 0: - continue - raw = load_model_modern(reader, sa.model_id) - if raw is None: - print(f" warning: GFX {gfx_id} model {sa.model_id} not found") - continue - md = decode_model(sa.model_id, raw) - if md is None: - print(f" warning: failed to decode GFX {gfx_id} model {sa.model_id}") - continue - if sa.recolor_src: - apply_recolors(md, sa.recolor_src, sa.recolor_dst) - md.model_id = 0xD0000 | gfx_id - print(f" GFX {gfx_id} model {sa.model_id} -> 0x{md.model_id:X} (recolored): {md.vertex_count} verts") - else: - if sa.model_id in exported_gfx_models: - continue # already exported this raw model - print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") - exported_gfx_models.add(md.model_id) - all_models.append(md) - - # write models binary - models_path = output_dir / "inferno_npcs.models" - write_models_binary(models_path, all_models) - file_size = models_path.stat().st_size - print(f"\nwrote {len(all_models)} models ({file_size:,} bytes) to {models_path}") - - # ================================================================ - # step 4: export animations - # ================================================================ - print("\n\nexporting animations...") - seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) - - sequences: dict[int, SequenceDef] = {} - for seq_id in sorted(all_anim_ids): - if seq_id not in seq_files: - print(f" warning: sequence {seq_id} not found in cache") - continue - modern_seq = parse_modern_sequence(seq_id, seq_files[seq_id]) - seq = SequenceDef( - seq_id=modern_seq.seq_id, - frame_count=modern_seq.frame_count, - frame_delays=modern_seq.frame_delays, - primary_frame_ids=modern_seq.primary_frame_ids, - frame_step=modern_seq.frame_step, - interleave_order=modern_seq.interleave_order, - priority=modern_seq.forced_priority, - loop_count=modern_seq.max_loops, - walk_flag=modern_seq.priority, - run_flag=modern_seq.precedence_animating, - ) - sequences[seq_id] = seq - print(f" seq {seq_id}: {seq.frame_count} frames, delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}") - - # collect needed frame groups - needed_groups: set[int] = set() - for seq_id in all_anim_ids & set(sequences.keys()): - seq = sequences[seq_id] - for fid in seq.primary_frame_ids: - if fid != -1: - needed_groups.add(fid >> 16) - - print(f" loading {len(needed_groups)} frame archives...") - - # first pass: discover framebase IDs from frame data headers - needed_base_ids: set[int] = set() - raw_frame_data: dict[int, dict[int, bytes]] = {} - for group_id in sorted(needed_groups): - try: - files = reader.read_group(MODERN_FRAME_INDEX, group_id) - except (KeyError, FileNotFoundError): - print(f" warning: frame archive {group_id} not found") - continue - raw_frame_data[group_id] = files - for file_data in files.values(): - if len(file_data) >= 2: - fb_id = (file_data[0] << 8) | file_data[1] - needed_base_ids.add(fb_id) - - print(f" loading {len(needed_base_ids)} framebases...") - framebases = load_modern_framebases(reader, needed_base_ids) - print(f" loaded {len(framebases)} framebases") - - # second pass: parse frames - all_frames: dict[int, dict[int, FrameDef]] = {} - for group_id, files in raw_frame_data.items(): - frames: dict[int, FrameDef] = {} - for file_id, file_data in files.items(): - if len(file_data) < 3: - continue - frame = _parse_normal_frame(group_id, file_id, file_data, framebases) - if frame is not None: - frames[file_id] = frame - if frames: - all_frames[group_id] = frames - - total_frames = sum(len(v) for v in all_frames.values()) - print(f" {len(all_frames)} frame archives, {total_frames} total frames") - - # write animations binary - anims_path = output_dir / "inferno_npcs.anims" - available_seqs = all_anim_ids & set(sequences.keys()) - write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) - - # ================================================================ - # step 5: update npc_models.h - # ================================================================ - print("\n\nupdating npc_models.h...") - header_path = output_dir / "npc_models.h" - - # build NPC model mapping entries - npc_entries = [] - for npc_id, npc in sorted(npc_defs.items()): - synth_model = 0xC0000 + npc_id - idle = npc.idle_anim if npc.idle_anim >= 0 else 0xFFFF - attack = INFERNO_ATTACK_ANIMS.get(npc_id, 0xFFFF) - walk = npc.walk_anim if npc.walk_anim >= 0 else 0xFFFF - label = INFERNO_NPC_IDS.get(npc_id, npc.name) - npc_entries.append((npc_id, synth_model, idle, attack, walk, label)) - - # build spotanim entries for C header. - # recolored spotanims get synthetic model IDs (0xD0000 | gfx_id) so the - # recolored variant is distinct from the raw model in the binary cache. - spotanim_entries = [] - for gfx_id, sa in sorted(spotanim_defs.items()): - if sa.model_id >= 0: - label = INFERNO_SPOTANIM_IDS.get(gfx_id, "unknown") - if sa.recolor_src: - emit_model_id = 0xD0000 | gfx_id - else: - emit_model_id = sa.model_id - spotanim_entries.append((gfx_id, emit_model_id, sa.seq_id, label)) - - # write C header - with open(header_path, "w") as f: - f.write("/**\n") - f.write(" * @fileoverview NPC model/animation mappings for encounter rendering.\n") - f.write(" *\n") - f.write(" * Maps NPC definition IDs to cache model IDs and animation sequence IDs.\n") - f.write(" * Generated by scripts/export_inferno_npcs.py — do not edit.\n") - f.write(" */\n\n") - f.write("#ifndef NPC_MODELS_H\n") - f.write("#define NPC_MODELS_H\n\n") - f.write("#include \n\n") - - f.write("typedef struct {\n") - f.write(" uint16_t npc_id;\n") - f.write(" uint32_t model_id;\n") - f.write(" uint32_t idle_anim;\n") - f.write(" uint32_t attack_anim;\n") - f.write(" uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */\n") - f.write("} NpcModelMapping;\n\n") - - # zulrah entries - f.write("/* zulrah forms + snakeling */\n") - f.write("static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = {\n") - f.write(" {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */\n") - f.write(" {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */\n") - f.write(" {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */\n") - f.write("};\n\n") - - # snakeling defines (keep existing) - f.write("/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */\n") - f.write("#define SNAKELING_MODEL_ID 10415\n") - f.write("#define SNAKELING_ANIM_IDLE 1721\n") - f.write("#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */\n") - f.write("#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */\n") - f.write("#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */\n") - f.write("#define SNAKELING_ANIM_WALK 2405 /* walk cycle */\n\n") - - # zulrah spotanim defines (keep existing) - f.write("/* zulrah spotanim (projectile/cloud) model IDs */\n") - f.write("#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */\n") - f.write("#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */\n") - f.write("#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */\n") - f.write("#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */\n") - f.write("#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */\n\n") - - # zulrah animation defines (keep existing) - f.write("/* zulrah animation sequence IDs */\n") - f.write("#define ZULRAH_ANIM_ATTACK 5068\n") - f.write("#define ZULRAH_ANIM_IDLE 5069\n") - f.write("#define ZULRAH_ANIM_DIVE 5072\n") - f.write("#define ZULRAH_ANIM_SURFACE 5071\n") - f.write("#define ZULRAH_ANIM_RISE 5073\n") - f.write("#define ZULRAH_ANIM_5070 5070\n") - f.write("#define ZULRAH_ANIM_5806 5806\n") - f.write("#define ZULRAH_ANIM_5807 5807\n") - f.write("#define GFX_SNAKELING_SPAWN_ANIM 5358\n\n") - - # inferno NPC model mappings - f.write("/* ================================================================ */\n") - f.write("/* inferno NPC model/animation mappings */\n") - f.write("/* ================================================================ */\n\n") - - f.write("static const NpcModelMapping NPC_MODEL_MAP_INFERNO[] = {\n") - for npc_id, synth_model, idle, attack, walk, label in npc_entries: - f.write(f" {{{npc_id}, 0x{synth_model:X}, {idle}, {attack}, {walk}}}, /* {label} */\n") - f.write("};\n\n") - - # inferno NPC defines for walk anims and other useful data - f.write("/* inferno NPC walk animation IDs */\n") - for npc_id, npc in sorted(npc_defs.items()): - safe_name = INFERNO_NPC_IDS[npc_id].split("(")[1].rstrip(")") if "(" in INFERNO_NPC_IDS[npc_id] else INFERNO_NPC_IDS[npc_id] - safe_name = safe_name.replace(" ", "_").replace("-", "_").upper() - if npc.walk_anim >= 0: - f.write(f"#define INF_WALK_ANIM_{safe_name} {npc.walk_anim}\n") - f.write("\n") - - # inferno spotanim/GFX defines - f.write("/* inferno spotanim (projectile/effect) model + animation IDs */\n") - for gfx_id, model_id, seq_id, label in spotanim_entries: - safe_label = label.replace(" ", "_").replace("(", "").replace(")", "").replace("?", "").upper() - f.write(f"#define INF_GFX_{gfx_id}_MODEL {model_id} /* {label} */\n") - if seq_id >= 0: - f.write(f"#define INF_GFX_{gfx_id}_ANIM {seq_id}\n") - f.write("\n") - - # inferno pillar models — "Rocky support" objects 30284-30287, 4 HP levels - f.write("/* inferno pillar models — Rocky support objects 30284-30287 */\n") - f.write("#define INF_PILLAR_MODEL_100 33044 /* object 30284 — full health */\n") - f.write("#define INF_PILLAR_MODEL_75 33043 /* object 30285 — 75% HP */\n") - f.write("#define INF_PILLAR_MODEL_50 33042 /* object 30286 — 50% HP */\n") - f.write("#define INF_PILLAR_MODEL_25 33045 /* object 30287 — 25% HP */\n\n") - - # combined lookup function that searches both zulrah and inferno tables - f.write("static const NpcModelMapping* npc_model_lookup(uint16_t npc_id) {\n") - f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_ZULRAH) / sizeof(NPC_MODEL_MAP_ZULRAH[0])); i++) {\n") - f.write(" if (NPC_MODEL_MAP_ZULRAH[i].npc_id == npc_id) return &NPC_MODEL_MAP_ZULRAH[i];\n") - f.write(" }\n") - f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_INFERNO) / sizeof(NPC_MODEL_MAP_INFERNO[0])); i++) {\n") - f.write(" if (NPC_MODEL_MAP_INFERNO[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO[i];\n") - f.write(" }\n") - f.write(" return NULL;\n") - f.write("}\n\n") - - f.write("#endif /* NPC_MODELS_H */\n") - - print(f"wrote {header_path}") - - # ================================================================ - # step 6: print encounter_inferno.h mapping table - # ================================================================ - print("\n\n========================================") - print("INF_NPC_DEF_IDS mapping table for encounter_inferno.h:") - print("========================================") - print("static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = {") - - inf_type_to_npc = { - "INF_NPC_NIBBLER": 7691, - "INF_NPC_BAT": 7692, - "INF_NPC_BLOB": 7693, - "INF_NPC_BLOB_MELEE": 7694, - "INF_NPC_BLOB_RANGE": 7695, - "INF_NPC_BLOB_MAGE": 7696, - "INF_NPC_MELEER": 7697, - "INF_NPC_RANGER": 7698, - "INF_NPC_MAGER": 7699, - "INF_NPC_JAD": 7700, - "INF_NPC_ZUK": 7706, - "INF_NPC_HEALER_JAD": 7701, - "INF_NPC_HEALER_ZUK": 7708, - "INF_NPC_ZUK_SHIELD": 7707, - } - for enum_name, npc_id in inf_type_to_npc.items(): - npc = npc_defs.get(npc_id) - name = npc.name if npc else "UNKNOWN" - print(f" [{enum_name}] = {npc_id}, /* {name} */") - print("};") - - print("\ndone.") - - -if __name__ == "__main__": - main() diff --git a/ocean/osrs/tests/test_bolt_procs.c b/ocean/osrs/tests/test_bolt_procs.c index a51d703d01..bb97de2091 100644 --- a/ocean/osrs/tests/test_bolt_procs.c +++ b/ocean/osrs/tests/test_bolt_procs.c @@ -6,8 +6,8 @@ * 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 \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_bolt_procs \ * ocean/osrs/tests/test_bolt_procs.c -lm * ./test_bolt_procs */ @@ -16,7 +16,7 @@ #include #include -#include "ocean/osrs/osrs_bolt_procs.h" +#include "osrs_bolt_procs.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_collision.c b/ocean/osrs/tests/test_collision.c index f6868ae6ea..ae745286d8 100644 --- a/ocean/osrs/tests/test_collision.c +++ b/ocean/osrs/tests/test_collision.c @@ -5,7 +5,7 @@ * 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 + * Compile: cd PufferLib && cc -O2 -Isrc/osrs -o test_collision ocean/osrs/tests/test_collision.c -lm * Run: ./test_collision */ diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index cf1bacbca6..811be5592f 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -7,8 +7,8 @@ * derived from the TypeScript reference (.refs/osrs-dps-calc/). * * BUILD: - * cd pufferlib-metal - * cc -std=c11 -O0 -g -I. -o test_combat_math \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_combat_math \ * ocean/osrs/tests/test_combat_math.c -lm * ./test_combat_math * @@ -25,8 +25,8 @@ #include #include -#include "ocean/osrs/osrs_encounter.h" -#include "ocean/osrs/osrs_special_attacks.h" +#include "osrs_encounter.h" +#include "osrs_special_attacks.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_consumables.c b/ocean/osrs/tests/test_consumables.c index 76a4c3f32b..a6d549ed08 100644 --- a/ocean/osrs/tests/test_consumables.c +++ b/ocean/osrs/tests/test_consumables.c @@ -2,12 +2,12 @@ * @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 + * Build: cc -std=c11 -O0 -g -Isrc/osrs -o test_consumables ocean/osrs/tests/test_consumables.c -lm */ #include #include -#include "ocean/osrs/osrs_consumables.h" +#include "osrs_consumables.h" static int total_tests = 0; static int passed_tests = 0; diff --git a/ocean/osrs/tests/test_damage.c b/ocean/osrs/tests/test_damage.c index 82761f9742..0b8e371aa6 100644 --- a/ocean/osrs/tests/test_damage.c +++ b/ocean/osrs/tests/test_damage.c @@ -4,8 +4,8 @@ * (prayer reduction, vengeance, recoil, smite). * * BUILD: - * cd pufferlib-metal - * cc -std=c11 -O0 -g -I. -o test_damage \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_damage \ * ocean/osrs/tests/test_damage.c -lm * ./test_damage * @@ -21,7 +21,7 @@ #include #include -#include "ocean/osrs/osrs_damage.h" +#include "osrs_damage.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_interaction.c b/ocean/osrs/tests/test_interaction.c index e4060ccc2a..d393d80a90 100644 --- a/ocean/osrs/tests/test_interaction.c +++ b/ocean/osrs/tests/test_interaction.c @@ -3,8 +3,8 @@ * @brief tests for osrs_interaction.h: entity interaction system + spec toggle * * BUILD: - * cd pufferlib-metal - * cc -std=c11 -O0 -g -I. -o test_interaction \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_interaction \ * ocean/osrs/tests/test_interaction.c -lm * ./test_interaction */ @@ -12,7 +12,7 @@ #include #include -#include "ocean/osrs/osrs_interaction.h" +#include "osrs_interaction.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_inventory.c b/ocean/osrs/tests/test_inventory.c index 281202ba95..c772e8a8db 100644 --- a/ocean/osrs/tests/test_inventory.c +++ b/ocean/osrs/tests/test_inventory.c @@ -3,8 +3,8 @@ * @brief tests for osrs_inventory.h: 28-slot inventory + equipment management * * BUILD: - * cd pufferlib-metal - * cc -std=c11 -O0 -g -I. -o test_inventory \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_inventory \ * ocean/osrs/tests/test_inventory.c -lm * ./test_inventory */ @@ -13,7 +13,7 @@ #include #include -#include "ocean/osrs/osrs_inventory.h" +#include "osrs_inventory.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index c26b248500..34966f88d9 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -6,8 +6,8 @@ * 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 \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_item_effects \ * ocean/osrs/tests/test_item_effects.c -lm * ./test_item_effects * @@ -24,7 +24,7 @@ #include #include -#include "ocean/osrs/osrs_encounter.h" +#include "osrs_encounter.h" /* ======================================================================== */ /* test harness (same macros as test_combat_math.c) */ diff --git a/ocean/osrs/tests/test_player_combat.c b/ocean/osrs/tests/test_player_combat.c index 8c0d0db13a..5ff84c3496 100644 --- a/ocean/osrs/tests/test_player_combat.c +++ b/ocean/osrs/tests/test_player_combat.c @@ -6,13 +6,13 @@ * 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 + * Build: cc -std=c11 -O0 -g -Isrc/osrs -o test_player_combat ocean/osrs/tests/test_player_combat.c -lm */ #include #include #include -#include "ocean/osrs/osrs_combat.h" +#include "osrs_combat.h" static int total_tests = 0; static int passed_tests = 0; diff --git a/ocean/osrs/tests/test_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c index 5a9a6da078..adecba7b1e 100644 --- a/ocean/osrs/tests/test_special_attacks.c +++ b/ocean/osrs/tests/test_special_attacks.c @@ -8,8 +8,8 @@ * defence roll, volatile staff, godsword variants, double-hit specs). * * BUILD: - * cd pufferlib-metal - * cc -std=c11 -O0 -g -I. -o test_special_attacks \ + * cd PufferLib + * cc -std=c11 -O0 -g -Isrc/osrs -o test_special_attacks \ * ocean/osrs/tests/test_special_attacks.c -lm * ./test_special_attacks * @@ -25,9 +25,9 @@ #include #include -#include "ocean/osrs/osrs_pvp_combat.h" -#include "ocean/osrs/osrs_combat.h" -#include "ocean/osrs/osrs_special_attacks.h" +#include "osrs_pvp_combat.h" +#include "osrs_combat.h" +#include "osrs_special_attacks.h" /* ======================================================================== */ /* test harness (same pattern as test_combat_math.c) */ diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c index 1e1eb6a2bb..4addbfd31c 100644 --- a/ocean/osrs_pvp/binding.c +++ b/ocean/osrs_pvp/binding.c @@ -199,7 +199,6 @@ void my_log(Log* log, Dict* out) { /* ======================================================================== * PFSP: set/get opponent pool weights across all envs - * Called from Python via pybind11 wrappers in metal_bindings.mm * ======================================================================== */ void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { From f0030551108600885ad28989e28f3dbf69d66d7a Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 20:39:50 +0300 Subject: [PATCH 04/60] wire up c_render and fix PvP binding c_render now calls pvp_render in all three envs so puffer eval --render-mode raylib actually renders. forward-declare pvp_render in osrs_pvp_api.h so bindings can opt into render by including osrs_render.h. fix PvP binding to use current pvp_runtime/ocean_io struct layout (was using flat fields). --- ocean/osrs_inferno/binding.c | 14 ++++++-- ocean/osrs_pvp/binding.c | 65 +++++++++++++++++++----------------- ocean/osrs_zulrah/binding.c | 14 ++++++-- src/osrs/osrs_pvp_api.h | 12 ++----- 4 files changed, 60 insertions(+), 45 deletions(-) diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index af857f5cbc..8531fce1dd 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -10,9 +10,10 @@ #include #include -#include "osrs_encounter.h" -#include "osrs_types.h" +#include "osrs_env.h" /* pulls in osrs_types, encounter, pvp stack */ #include "encounters/encounter_inferno.h" +#include "encounters/encounter_zulrah.h" /* render.h references ZulrahState */ +#include "osrs_render.h" #define INF_TOTAL_OBS (INF_NUM_OBS + INF_ACTION_MASK_SIZE) @@ -38,6 +39,8 @@ typedef struct { 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 */ + + OsrsEnv render_env; /* minimal env wrapper for pvp_render() */ } InfernoEnv; #define OBS_SIZE INF_TOTAL_OBS @@ -232,7 +235,12 @@ void c_close(Env* env) { } } -void c_render(Env* env) { (void)env; } +void c_render(Env* env) { + OsrsEnv* re = &env->render_env; + re->encounter_def = (void*)&ENCOUNTER_INFERNO; + re->encounter_state = env->enc_state; + pvp_render(re); +} #define MY_VEC_INIT #include "vecenv.h" diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c index 4addbfd31c..15c9c8a7d7 100644 --- a/ocean/osrs_pvp/binding.c +++ b/ocean/osrs_pvp/binding.c @@ -8,6 +8,9 @@ */ #include "osrs_env.h" +#include "encounters/encounter_inferno.h" /* render.h references InfernoState */ +#include "encounters/encounter_zulrah.h" /* render.h references ZulrahState */ +#include "osrs_render.h" /* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. * vecenv.h's create_static_vec assigns to env->observations, env->actions, @@ -78,20 +81,22 @@ void c_step(Env* env) { void c_reset(Env* env) { /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ - env->pvp.ocean_obs = (float*)env->observations; - env->pvp.ocean_rew = env->rewards; - env->pvp.ocean_term = &env->ocean_term_staging; - env->pvp.ocean_acts = env->ocean_acts_staging; + 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_rew[0] = 0.0f; - env->pvp.ocean_term[0] = 0; + 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; } +void c_render(Env* env) { + pvp_render(&env->pvp); +} #include "vecenv.h" @@ -107,20 +112,20 @@ void my_init(Env* env, Dict* kwargs) { * * For now, point ocean pointers at internal staging so pvp_reset doesn't * crash on writes to ocean_term/ocean_rew. */ - env->pvp.ocean_obs = NULL; - env->pvp.ocean_rew = env->pvp._rews_buf; - env->pvp.ocean_term = &env->ocean_term_staging; - env->pvp.ocean_acts = env->ocean_acts_staging; - env->pvp.ocean_obs_p1 = NULL; - env->pvp.ocean_selfplay_mask = NULL; + 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; /* config from Dict (all values are double) */ - env->pvp.use_c_opponent = 1; + 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.opponent.type = opp ? (OpponentType)(int)opp->value : OPP_IMPROVED; + 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; @@ -156,10 +161,10 @@ void my_init(Env* env, Dict* kwargs) { env->pvp.shaping.click_penalty_coef = -0.003f; /* gear: default tier 0 (basic LMS) */ - env->pvp.gear_tier_weights[0] = 1.0f; - env->pvp.gear_tier_weights[1] = 0.0f; - env->pvp.gear_tier_weights[2] = 0.0f; - env->pvp.gear_tier_weights[3] = 0.0f; + 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 sets up game state (players, positions, gear, etc.) * but does NOT write to ocean buffers — that happens in c_reset. */ @@ -205,11 +210,11 @@ void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int p Env* envs = (Env*)vec->envs; if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; for (int e = 0; e < vec->size; e++) { - int was_unconfigured = (envs[e].pvp.pfsp.pool_size == 0); - envs[e].pvp.pfsp.pool_size = pool_size; + 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.pfsp.pool[i] = (OpponentType)pool[i]; - envs[e].pvp.pfsp.cum_weights[i] = cum_weights[i]; + envs[e].pvp.pvp_runtime.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pvp_runtime.pfsp.cum_weights[i] = cum_weights[i]; } /* Only reset on first configuration — restarts the episode that was started * during env creation before the pool was set (would have used fallback opponent). @@ -225,8 +230,8 @@ void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes int pool_size = 0; for (int e = 0; e < vec->size; e++) { - if (envs[e].pvp.pfsp.pool_size > pool_size) - pool_size = envs[e].pvp.pfsp.pool_size; + 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++) { @@ -236,11 +241,11 @@ void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes /* Aggregate and reset (read-and-reset pattern) */ for (int e = 0; e < vec->size; e++) { - for (int i = 0; i < envs[e].pvp.pfsp.pool_size; i++) { - out_wins[i] += envs[e].pvp.pfsp.wins[i]; - out_episodes[i] += envs[e].pvp.pfsp.episodes[i]; + 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.pfsp.wins, 0, sizeof(envs[e].pvp.pfsp.wins)); - memset(envs[e].pvp.pfsp.episodes, 0, sizeof(envs[e].pvp.pfsp.episodes)); + 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_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 6f0a051d03..88608862c8 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -11,9 +11,10 @@ #include #include -#include "osrs_encounter.h" -#include "osrs_types.h" +#include "osrs_env.h" /* pulls in osrs_types, encounter, pvp stack */ #include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" /* render.h references InfernoState */ +#include "osrs_render.h" /* total obs = raw obs + action mask */ #define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) @@ -35,6 +36,8 @@ typedef struct { /* staging buffer for action type conversion */ int acts_staging[ZUL_NUM_ACTION_HEADS]; unsigned char term_staging; + + OsrsEnv render_env; } ZulrahEnv; #define OBS_SIZE ZUL_TOTAL_OBS @@ -110,7 +113,12 @@ void c_close(Env* env) { } } -void c_render(Env* env) { (void)env; } +void c_render(Env* env) { + OsrsEnv* re = &env->render_env; + re->encounter_def = (void*)&ENCOUNTER_ZULRAH; + re->encounter_state = env->enc_state; + pvp_render(re); +} #include "vecenv.h" diff --git a/src/osrs/osrs_pvp_api.h b/src/osrs/osrs_pvp_api.h index 4225c67145..3d3ecf8801 100644 --- a/src/osrs/osrs_pvp_api.h +++ b/src/osrs/osrs_pvp_api.h @@ -320,15 +320,9 @@ void pvp_init(OsrsEnv* env) { memset(&env->log, 0, sizeof(env->log)); } -/** - * Render stub (required by PufferLib ocean template). - * When OSRS_VISUAL is defined, osrs_pvp_render.h provides the real implementation. - */ -#ifndef OSRS_VISUAL -void pvp_render(OsrsEnv* env) { - (void)env; -} -#endif +/* 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. From 497e6bc284fbfebe5d394211c211dbb60aa9292c Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 20:56:10 +0300 Subject: [PATCH 05/60] suppress unused-function warnings in binding TU render.h and encounter headers define static helpers only called by the standalone viewer. wrap render/encounter includes in GCC diagnostic push/pop to silence unused-function noise in the binding compile. --- ocean/osrs_inferno/binding.c | 6 ++++++ ocean/osrs_pvp/binding.c | 4 ++++ ocean/osrs_zulrah/binding.c | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 8531fce1dd..ec053c7361 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -11,9 +11,15 @@ #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) diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c index 15c9c8a7d7..464eccc963 100644 --- a/ocean/osrs_pvp/binding.c +++ b/ocean/osrs_pvp/binding.c @@ -8,9 +8,13 @@ */ #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 /* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. * vecenv.h's create_static_vec assigns to env->observations, env->actions, diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 88608862c8..0783441783 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -12,9 +12,13 @@ #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 /* total obs = raw obs + action mask */ #define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) From 4945f3c3a0ddfc4436b6ccc660c9c9adb9a70751 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 21:47:59 +0300 Subject: [PATCH 06/60] extract OSRS assets to data/ at repo root, not ocean/osrs/data osrs_visual.c loads from relative 'data/' path. binary runs from repo root where build.sh outputs it, so data/ needs to be there too. --- build.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.sh b/build.sh index 0bd84c1607..04ca72c8b9 100755 --- a/build.sh +++ b/build.sh @@ -119,14 +119,14 @@ elif [ "$ENV" = "impulse_wars" ]; then elif [[ "$ENV" == osrs_* ]]; then SRC_DIR="ocean/$ENV" INCLUDES+=(-I./src/osrs) - # download visual assets for standalone builds (training doesn't need them) + # download visual assets for standalone builds (training doesn't need them). + # standalone binary loads data/ relative to CWD, so extract at repo root. if [ "$MODE" = "local" ] || [ "$MODE" = "fast" ] || [ "$MODE" = "web" ]; then - OSRS_DATA="ocean/osrs/data" - if [ ! -f "$OSRS_DATA/equipment.models" ]; then + if [ ! -f "data/equipment.models" ]; then echo "Downloading OSRS visual assets..." OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v1/osrs-assets.tar.gz" - mkdir -p "$OSRS_DATA" - curl -sL "$OSRS_ASSETS_URL" | tar xz -C "$OSRS_DATA" + mkdir -p data + curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi fi elif [ -d "ocean/$ENV" ]; then From 5d96c9ac36ca44099de4bd9877de1a97d4032bfa Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 22:09:22 +0300 Subject: [PATCH 07/60] move shared OSRS headers from src/osrs to ocean/osrs keep env-specific code under ocean/ alongside the visual viewer and scripts. build.sh points -Iocean/osrs at them. also always download visual assets, not just for --local builds, since puffer eval renders through the same _C.so and needs them present. --- build.sh | 19 +++++++++---------- {src => ocean}/osrs/data/item_models.h | 0 {src => ocean}/osrs/data/npc_models.h | 0 {src => ocean}/osrs/data/npc_models_inferno.h | 0 {src => ocean}/osrs/data/npc_models_zulrah.h | 0 {src => ocean}/osrs/data/player_models.h | 0 .../osrs/encounters/encounter_inferno.h | 0 .../osrs/encounters/encounter_nh_pvp.h | 0 .../osrs/encounters/encounter_zulrah.h | 0 {src => ocean}/osrs/osrs_anim.h | 0 {src => ocean}/osrs/osrs_bolt_procs.h | 0 {src => ocean}/osrs/osrs_collision.h | 0 {src => ocean}/osrs/osrs_combat.h | 0 {src => ocean}/osrs/osrs_consumables.h | 0 {src => ocean}/osrs/osrs_damage.h | 0 {src => ocean}/osrs/osrs_encounter.h | 0 {src => ocean}/osrs/osrs_env.h | 0 {src => ocean}/osrs/osrs_gui.h | 0 {src => ocean}/osrs/osrs_human_input.h | 0 {src => ocean}/osrs/osrs_human_input_types.h | 0 {src => ocean}/osrs/osrs_interaction.h | 0 {src => ocean}/osrs/osrs_inventory.h | 0 {src => ocean}/osrs/osrs_items.h | 0 {src => ocean}/osrs/osrs_items_generated.h | 0 {src => ocean}/osrs/osrs_models.h | 0 {src => ocean}/osrs/osrs_monsters_generated.h | 0 {src => ocean}/osrs/osrs_objects.h | 0 {src => ocean}/osrs/osrs_pathfinding.h | 0 {src => ocean}/osrs/osrs_pvp_actions.h | 0 {src => ocean}/osrs/osrs_pvp_api.h | 0 {src => ocean}/osrs/osrs_pvp_combat.h | 0 {src => ocean}/osrs/osrs_pvp_effects.h | 0 {src => ocean}/osrs/osrs_pvp_gear.h | 0 {src => ocean}/osrs/osrs_pvp_movement.h | 0 {src => ocean}/osrs/osrs_pvp_observations.h | 0 {src => ocean}/osrs/osrs_pvp_opponents.h | 0 {src => ocean}/osrs/osrs_render.h | 0 {src => ocean}/osrs/osrs_special_attacks.h | 0 {src => ocean}/osrs/osrs_terrain.h | 0 {src => ocean}/osrs/osrs_types.h | 0 40 files changed, 9 insertions(+), 10 deletions(-) rename {src => ocean}/osrs/data/item_models.h (100%) rename {src => ocean}/osrs/data/npc_models.h (100%) rename {src => ocean}/osrs/data/npc_models_inferno.h (100%) rename {src => ocean}/osrs/data/npc_models_zulrah.h (100%) rename {src => ocean}/osrs/data/player_models.h (100%) rename {src => ocean}/osrs/encounters/encounter_inferno.h (100%) rename {src => ocean}/osrs/encounters/encounter_nh_pvp.h (100%) rename {src => ocean}/osrs/encounters/encounter_zulrah.h (100%) rename {src => ocean}/osrs/osrs_anim.h (100%) rename {src => ocean}/osrs/osrs_bolt_procs.h (100%) rename {src => ocean}/osrs/osrs_collision.h (100%) rename {src => ocean}/osrs/osrs_combat.h (100%) rename {src => ocean}/osrs/osrs_consumables.h (100%) rename {src => ocean}/osrs/osrs_damage.h (100%) rename {src => ocean}/osrs/osrs_encounter.h (100%) rename {src => ocean}/osrs/osrs_env.h (100%) rename {src => ocean}/osrs/osrs_gui.h (100%) rename {src => ocean}/osrs/osrs_human_input.h (100%) rename {src => ocean}/osrs/osrs_human_input_types.h (100%) rename {src => ocean}/osrs/osrs_interaction.h (100%) rename {src => ocean}/osrs/osrs_inventory.h (100%) rename {src => ocean}/osrs/osrs_items.h (100%) rename {src => ocean}/osrs/osrs_items_generated.h (100%) rename {src => ocean}/osrs/osrs_models.h (100%) rename {src => ocean}/osrs/osrs_monsters_generated.h (100%) rename {src => ocean}/osrs/osrs_objects.h (100%) rename {src => ocean}/osrs/osrs_pathfinding.h (100%) rename {src => ocean}/osrs/osrs_pvp_actions.h (100%) rename {src => ocean}/osrs/osrs_pvp_api.h (100%) rename {src => ocean}/osrs/osrs_pvp_combat.h (100%) rename {src => ocean}/osrs/osrs_pvp_effects.h (100%) rename {src => ocean}/osrs/osrs_pvp_gear.h (100%) rename {src => ocean}/osrs/osrs_pvp_movement.h (100%) rename {src => ocean}/osrs/osrs_pvp_observations.h (100%) rename {src => ocean}/osrs/osrs_pvp_opponents.h (100%) rename {src => ocean}/osrs/osrs_render.h (100%) rename {src => ocean}/osrs/osrs_special_attacks.h (100%) rename {src => ocean}/osrs/osrs_terrain.h (100%) rename {src => ocean}/osrs/osrs_types.h (100%) diff --git a/build.sh b/build.sh index 04ca72c8b9..128b8a5a15 100755 --- a/build.sh +++ b/build.sh @@ -118,16 +118,15 @@ elif [ "$ENV" = "impulse_wars" ]; then LINK_ARCHIVES+=("./$BOX2D_NAME/libbox2d.a") elif [[ "$ENV" == osrs_* ]]; then SRC_DIR="ocean/$ENV" - INCLUDES+=(-I./src/osrs) - # download visual assets for standalone builds (training doesn't need them). - # standalone binary loads data/ relative to CWD, so extract at repo root. - if [ "$MODE" = "local" ] || [ "$MODE" = "fast" ] || [ "$MODE" = "web" ]; then - if [ ! -f "data/equipment.models" ]; then - echo "Downloading OSRS visual assets..." - OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v1/osrs-assets.tar.gz" - mkdir -p data - curl -sL "$OSRS_ASSETS_URL" | tar xz -C data - fi + 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-v1/osrs-assets.tar.gz" + mkdir -p data + curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" diff --git a/src/osrs/data/item_models.h b/ocean/osrs/data/item_models.h similarity index 100% rename from src/osrs/data/item_models.h rename to ocean/osrs/data/item_models.h diff --git a/src/osrs/data/npc_models.h b/ocean/osrs/data/npc_models.h similarity index 100% rename from src/osrs/data/npc_models.h rename to ocean/osrs/data/npc_models.h diff --git a/src/osrs/data/npc_models_inferno.h b/ocean/osrs/data/npc_models_inferno.h similarity index 100% rename from src/osrs/data/npc_models_inferno.h rename to ocean/osrs/data/npc_models_inferno.h diff --git a/src/osrs/data/npc_models_zulrah.h b/ocean/osrs/data/npc_models_zulrah.h similarity index 100% rename from src/osrs/data/npc_models_zulrah.h rename to ocean/osrs/data/npc_models_zulrah.h diff --git a/src/osrs/data/player_models.h b/ocean/osrs/data/player_models.h similarity index 100% rename from src/osrs/data/player_models.h rename to ocean/osrs/data/player_models.h diff --git a/src/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h similarity index 100% rename from src/osrs/encounters/encounter_inferno.h rename to ocean/osrs/encounters/encounter_inferno.h diff --git a/src/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h similarity index 100% rename from src/osrs/encounters/encounter_nh_pvp.h rename to ocean/osrs/encounters/encounter_nh_pvp.h diff --git a/src/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h similarity index 100% rename from src/osrs/encounters/encounter_zulrah.h rename to ocean/osrs/encounters/encounter_zulrah.h diff --git a/src/osrs/osrs_anim.h b/ocean/osrs/osrs_anim.h similarity index 100% rename from src/osrs/osrs_anim.h rename to ocean/osrs/osrs_anim.h diff --git a/src/osrs/osrs_bolt_procs.h b/ocean/osrs/osrs_bolt_procs.h similarity index 100% rename from src/osrs/osrs_bolt_procs.h rename to ocean/osrs/osrs_bolt_procs.h diff --git a/src/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h similarity index 100% rename from src/osrs/osrs_collision.h rename to ocean/osrs/osrs_collision.h diff --git a/src/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h similarity index 100% rename from src/osrs/osrs_combat.h rename to ocean/osrs/osrs_combat.h diff --git a/src/osrs/osrs_consumables.h b/ocean/osrs/osrs_consumables.h similarity index 100% rename from src/osrs/osrs_consumables.h rename to ocean/osrs/osrs_consumables.h diff --git a/src/osrs/osrs_damage.h b/ocean/osrs/osrs_damage.h similarity index 100% rename from src/osrs/osrs_damage.h rename to ocean/osrs/osrs_damage.h diff --git a/src/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h similarity index 100% rename from src/osrs/osrs_encounter.h rename to ocean/osrs/osrs_encounter.h diff --git a/src/osrs/osrs_env.h b/ocean/osrs/osrs_env.h similarity index 100% rename from src/osrs/osrs_env.h rename to ocean/osrs/osrs_env.h diff --git a/src/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h similarity index 100% rename from src/osrs/osrs_gui.h rename to ocean/osrs/osrs_gui.h diff --git a/src/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h similarity index 100% rename from src/osrs/osrs_human_input.h rename to ocean/osrs/osrs_human_input.h diff --git a/src/osrs/osrs_human_input_types.h b/ocean/osrs/osrs_human_input_types.h similarity index 100% rename from src/osrs/osrs_human_input_types.h rename to ocean/osrs/osrs_human_input_types.h diff --git a/src/osrs/osrs_interaction.h b/ocean/osrs/osrs_interaction.h similarity index 100% rename from src/osrs/osrs_interaction.h rename to ocean/osrs/osrs_interaction.h diff --git a/src/osrs/osrs_inventory.h b/ocean/osrs/osrs_inventory.h similarity index 100% rename from src/osrs/osrs_inventory.h rename to ocean/osrs/osrs_inventory.h diff --git a/src/osrs/osrs_items.h b/ocean/osrs/osrs_items.h similarity index 100% rename from src/osrs/osrs_items.h rename to ocean/osrs/osrs_items.h diff --git a/src/osrs/osrs_items_generated.h b/ocean/osrs/osrs_items_generated.h similarity index 100% rename from src/osrs/osrs_items_generated.h rename to ocean/osrs/osrs_items_generated.h diff --git a/src/osrs/osrs_models.h b/ocean/osrs/osrs_models.h similarity index 100% rename from src/osrs/osrs_models.h rename to ocean/osrs/osrs_models.h diff --git a/src/osrs/osrs_monsters_generated.h b/ocean/osrs/osrs_monsters_generated.h similarity index 100% rename from src/osrs/osrs_monsters_generated.h rename to ocean/osrs/osrs_monsters_generated.h diff --git a/src/osrs/osrs_objects.h b/ocean/osrs/osrs_objects.h similarity index 100% rename from src/osrs/osrs_objects.h rename to ocean/osrs/osrs_objects.h diff --git a/src/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h similarity index 100% rename from src/osrs/osrs_pathfinding.h rename to ocean/osrs/osrs_pathfinding.h diff --git a/src/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h similarity index 100% rename from src/osrs/osrs_pvp_actions.h rename to ocean/osrs/osrs_pvp_actions.h diff --git a/src/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h similarity index 100% rename from src/osrs/osrs_pvp_api.h rename to ocean/osrs/osrs_pvp_api.h diff --git a/src/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h similarity index 100% rename from src/osrs/osrs_pvp_combat.h rename to ocean/osrs/osrs_pvp_combat.h diff --git a/src/osrs/osrs_pvp_effects.h b/ocean/osrs/osrs_pvp_effects.h similarity index 100% rename from src/osrs/osrs_pvp_effects.h rename to ocean/osrs/osrs_pvp_effects.h diff --git a/src/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h similarity index 100% rename from src/osrs/osrs_pvp_gear.h rename to ocean/osrs/osrs_pvp_gear.h diff --git a/src/osrs/osrs_pvp_movement.h b/ocean/osrs/osrs_pvp_movement.h similarity index 100% rename from src/osrs/osrs_pvp_movement.h rename to ocean/osrs/osrs_pvp_movement.h diff --git a/src/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h similarity index 100% rename from src/osrs/osrs_pvp_observations.h rename to ocean/osrs/osrs_pvp_observations.h diff --git a/src/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h similarity index 100% rename from src/osrs/osrs_pvp_opponents.h rename to ocean/osrs/osrs_pvp_opponents.h diff --git a/src/osrs/osrs_render.h b/ocean/osrs/osrs_render.h similarity index 100% rename from src/osrs/osrs_render.h rename to ocean/osrs/osrs_render.h diff --git a/src/osrs/osrs_special_attacks.h b/ocean/osrs/osrs_special_attacks.h similarity index 100% rename from src/osrs/osrs_special_attacks.h rename to ocean/osrs/osrs_special_attacks.h diff --git a/src/osrs/osrs_terrain.h b/ocean/osrs/osrs_terrain.h similarity index 100% rename from src/osrs/osrs_terrain.h rename to ocean/osrs/osrs_terrain.h diff --git a/src/osrs/osrs_types.h b/ocean/osrs/osrs_types.h similarity index 100% rename from src/osrs/osrs_types.h rename to ocean/osrs/osrs_types.h From 272715d231bc17eb10c9743668bc5c6b1b66c37f Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 22:22:53 +0300 Subject: [PATCH 08/60] fix eval rendering, camera pan, sprite filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - c_render now loads encounter terrain/objects/models/anims on first call (same as standalone viewer did in run_visual), so puffer eval renders the 3D world instead of a black screen - middle-mouse pan was inverted (mouse right → world left); fixed sign - prayer/spell sprite filenames now numeric (matches what osrs_gui.h loads). also point build.sh at osrs-assets-v3 which includes missing hitmarks and headicons_prayer sprites --- build.sh | 2 +- ocean/osrs/osrs_render.h | 6 +++--- ocean/osrs/scripts/export_sprites_modern.py | 12 ++---------- ocean/osrs_inferno/binding.c | 18 ++++++++++++++++++ ocean/osrs_zulrah/binding.c | 14 ++++++++++++++ 5 files changed, 38 insertions(+), 14 deletions(-) diff --git a/build.sh b/build.sh index 128b8a5a15..ecd2479484 100755 --- a/build.sh +++ b/build.sh @@ -124,7 +124,7 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v1/osrs-assets.tar.gz" + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v2/osrs-assets-v2.tar.gz" mkdir -p data curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 4097c65a43..3d9cb5ebb1 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -1205,10 +1205,10 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { if (rc->cam_pitch < 0.1f) rc->cam_pitch = 0.1f; if (rc->cam_pitch > 1.4f) rc->cam_pitch = 1.4f; } else { - /* pan */ + /* 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; + 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) { diff --git a/ocean/osrs/scripts/export_sprites_modern.py b/ocean/osrs/scripts/export_sprites_modern.py index 350bca9d6d..3307a3a369 100644 --- a/ocean/osrs/scripts/export_sprites_modern.py +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -240,16 +240,8 @@ def save_sprite_png(sprite: SpriteFrame, path: Path) -> None: 168: "tab_combat", 776: "tab_quests", 779: "tab_prayer", 780: "tab_magic", 898: "tab_stats", 899: "tab_quests2", 900: "tab_inventory", 901: "tab_equipment", - 127: "pray_mage", 128: "pray_range", 129: "pray_melee", - 130: "pray_redemption", 131: "pray_retribution", 132: "pray_smite", - 504: "pray_eagle_eye", 505: "pray_mystic_might", - 945: "pray_chivalry", 946: "pray_piety", 947: "pray_preserve", - 1420: "pray_rigour", 1421: "pray_augury", - 325: "spell_ice_rush", 326: "spell_ice_burst", - 327: "spell_ice_blitz", 328: "spell_ice_barrage", - 333: "spell_blood_rush", 334: "spell_blood_burst", - 335: "spell_blood_blitz", 336: "spell_blood_barrage", - 564: "spell_vengeance", + # 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", diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index ec053c7361..013c55791f 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -245,7 +245,25 @@ 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->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"); + } } #define MY_VEC_INIT diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 0783441783..f8d3260bbb 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -121,7 +121,21 @@ 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"); + } } #include "vecenv.h" From 1c83c136a371d10102f75e941eb88befa9857934 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 22:36:20 +0300 Subject: [PATCH 09/60] eval tick pacing + spell-target ground-click walks c_render now sleeps between frames to match ticks_per_second so eval runs at OSRS speed instead of blazing through. 9/0 keys adjust the speed during viewing (uses the same existing mechanism standalone viewer already had). also fix: clicking ground while a spell is selected now walks to that tile AND cancels targeting, matching OSRS behaviour. previously it just silently cancelled without walking. --- ocean/osrs/osrs_render.h | 10 +++++----- ocean/osrs_inferno/binding.c | 10 ++++++++++ ocean/osrs_zulrah/binding.c | 9 +++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 3d9cb5ebb1..248a38fca4 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -1407,14 +1407,14 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { rc->debug_plane_wx = -1; rc->debug_plane_wy = -1; if (best_wx >= 0) { - /* ground click: only movement, skip entity check (hull handles that) */ + /* 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; - } else { - rc->human_input.pending_move_x = best_wx; - rc->human_input.pending_move_y = best_wy; - human_set_click_cross(&rc->human_input, mx, my, 0); } + rc->human_input.pending_move_x = best_wx; + rc->human_input.pending_move_y = best_wy; + human_set_click_cross(&rc->human_input, mx, my, 0); } } /* end else (ground click) */ } else { diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 013c55791f..7882e6a961 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -264,6 +264,16 @@ void c_render(Env* env) { rc->npc_model_cache = model_cache_load("data/inferno.models"); rc->npc_anim_cache = anim_cache_load("data/inferno.anims"); } + + /* eval pacing: sleep to match tick rate so rollouts don't blaze through. + use 9/0 keys to slow/speed while viewing. defaults to OSRS speed. */ + 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(); + } } #define MY_VEC_INIT diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index f8d3260bbb..7c8f45d71d 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -136,6 +136,15 @@ void c_render(Env* env) { rc->npc_model_cache = model_cache_load("data/zulrah.models"); rc->npc_anim_cache = anim_cache_load("data/zulrah.anims"); } + + /* eval pacing: sleep to match tick rate (9/0 keys slow/speed up) */ + 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" From cca152931f4a55dde3b016454758fe0e1bc75b18 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 22:40:01 +0300 Subject: [PATCH 10/60] OSRS viewer: aspect ratio + inventory fixes - window now 765x503 matching OSRS fixed-client layout, side panel 190px. tile grid decoupled from pixel size (was tied together, forcing wide viewport). inventory cells scaled from 76x65 down to OSRS-native 42x36. - inferno ammo slot items (dragon darts, dragon arrows) no longer appear as swappable inventory stacks. in real OSRS darts live in the blowpipe and arrows in dizana's quiver, not as separate inventory items. - add bastion potion and stamina potion inventory slot types with proper OSRS item IDs (22461/22464/22467/22470 and 12625/12627/12629/12631). inferno was previously rendering these as super combat + ranging pot because the generic slot types were reused. --- ocean/osrs/encounters/encounter_inferno.h | 9 ++++++ ocean/osrs/osrs_gui.h | 39 ++++++++++++++++++++--- ocean/osrs/osrs_render.h | 19 ++++++----- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 99a8c358b0..997c48c085 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -800,6 +800,15 @@ static void inf_reset(EncounterState* state, uint32_t seed) { tank_extra[GEAR_SLOT_BODY] = INF_TANK_BODY; tank_extra[GEAR_SLOT_LEGS] = INF_TANK_LEGS; encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, tank_extra); + + /* 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; } s->player.brew_doses = 32; /* 8 pots x 4 doses */ s->player.restore_doses = 40; /* 10 pots x 4 doses */ diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 9a2d6614ba..838578c644 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -152,6 +152,8 @@ typedef enum { 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) */ @@ -181,6 +183,14 @@ typedef enum { #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 */ @@ -281,6 +291,8 @@ typedef struct { 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 @@ -808,10 +820,11 @@ static int gui_content_y(GuiState* gs) { the 4-column grid (304px) fills the panel with 8px padding each side. */ #define INV_COLS 4 #define INV_ROWS 7 -#define INV_CELL_W 76 -#define INV_CELL_H 65 -#define INV_SPRITE_W 65 /* 36 * 76/42 */ -#define INV_SPRITE_H 57 /* 32 * 76/42 */ +/* OSRS native inventory cell pitch is ~42x36, sprite 36x32. */ +#define INV_CELL_W 42 +#define INV_CELL_H 36 +#define INV_SPRITE_W 36 +#define INV_SPRITE_H 32 /** Get the OSRS item ID for a consumable based on remaining doses/count. */ static int gui_consumable_osrs_id(InvSlotType type, int doses) { @@ -848,6 +861,16 @@ static int gui_consumable_osrs_id(InvSlotType type, int doses) { 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; } } @@ -962,6 +985,8 @@ static void gui_populate_inventory(GuiState* gs, Player* p) { 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 @@ -1171,6 +1196,12 @@ static void gui_update_inventory(GuiState* gs, Player* p) { 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); } diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 248a38fca4..c3a8cb314a 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -31,16 +31,19 @@ /* ======================================================================== */ #define RENDER_TILE_SIZE 20 -#define RENDER_PANEL_WIDTH 320 -#define RENDER_HEADER_HEIGHT 40 -#define RENDER_SPLATS_PER_PLAYER 4 /* OSRS max: 4 simultaneous splats per entity */ +/* window sized to match the OSRS fixed-client layout (765x503). + 3D viewport projects into the area left of the side panel; the tile + grid is a game-logic unit only, decoupled from window pixels. */ +#define RENDER_WINDOW_W 765 +#define RENDER_WINDOW_H 503 +#define RENDER_PANEL_WIDTH 190 /* OSRS side panel width */ +#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 MAX_RENDER_ENTITIES 64 /* max entities rendered (players + NPCs/bosses/adds) */ -#define RENDER_GRID_W (FIGHT_AREA_WIDTH * RENDER_TILE_SIZE) -#define RENDER_GRID_H (FIGHT_AREA_HEIGHT * RENDER_TILE_SIZE) -#define RENDER_WINDOW_W (RENDER_GRID_W + RENDER_PANEL_WIDTH) -#define RENDER_WINDOW_H (RENDER_GRID_H + RENDER_HEADER_HEIGHT) +#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 } From 0a8334d338211c8d9df81c92a72bab96afa2b076 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 22:48:02 +0300 Subject: [PATCH 11/60] export_items.sh: self-contained Java item sprite exporter wraps ExportItemSprites.java with auto-download of runelite-cache and transitive deps from Maven Central / RuneLite repo. first run downloads ~13MB of jars into ocean/osrs/build/item_exporter/deps/, subsequent runs reuse them. requires java 11+ and curl. export_all.sh now calls it for the default loadout items (inferno pots, darts, arrows, weapons). skips gracefully if javac not installed. also bump osrs-assets release to v4 which includes the dragon dart, bastion potion, and stamina potion item sprites. --- build.sh | 2 +- ocean/osrs/scripts/export_all.sh | 25 +++++++-- ocean/osrs/scripts/export_items.sh | 83 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 5 deletions(-) create mode 100755 ocean/osrs/scripts/export_items.sh diff --git a/build.sh b/build.sh index ecd2479484..3c2cdf3e8b 100755 --- a/build.sh +++ b/build.sh @@ -124,7 +124,7 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v2/osrs-assets-v2.tar.gz" + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v4/osrs-assets-v4.tar.gz" mkdir -p data curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi diff --git a/ocean/osrs/scripts/export_all.sh b/ocean/osrs/scripts/export_all.sh index 089cff932f..b71fbb404c 100755 --- a/ocean/osrs/scripts/export_all.sh +++ b/ocean/osrs/scripts/export_all.sh @@ -185,13 +185,30 @@ fi echo "=== wilderness objects (skipped, 685MB+) ===" echo " run manually: python scripts/export_objects.py --modern-cache \$CACHE --keys \$KEYS --output data/wilderness.objects --wilderness" +# ============================================================================ +# item sprites (inventory icons) — uses Java + runelite-cache +# ============================================================================ + +# default item IDs: the loadout items used by inferno + zulrah + pvp. +# add more here as needed. comma-separated. +ITEM_IDS="11230,22461,22464,22467,22470,12625,12627,12629,12631" +ITEM_IDS+=",4151,22325,26374,12926,27277,28254" # weapons (whip/scythe/bp/tbow/scb) +ITEM_IDS+=",10828,21018,13239,27235,27238,27229" # gear (helm/body/legs/torva etc.) +ITEM_IDS+=",6685,6687,6689,6691,3024,3026,3028,3030" # brew + restore +ITEM_IDS+=",385,3144,2434,139,141,143" # food + prayer pot + +echo "=== item inventory sprites (needs Java + runelite-cache, auto-fetched) ===" +if ! command -v javac >/dev/null 2>&1; then + echo " skip: javac not found. install openjdk-11+ to export item sprites." +else + if ! skip_if_exists "$DATA_DIR/sprites/items/11230.png"; then + "$SCRIPT_DIR/export_items.sh" "$CACHE" "$ITEM_IDS" + fi +fi + # ============================================================================ # done # ============================================================================ echo "" echo "done. assets exported to $DATA_DIR/" -echo "" -echo "note: 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_items.sh b/ocean/osrs/scripts/export_items.sh new file mode 100755 index 0000000000..ee399a7342 --- /dev/null +++ b/ocean/osrs/scripts/export_items.sh @@ -0,0 +1,83 @@ +#!/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 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)" +BUILD_DIR="$OSRS_DIR/build/item_exporter" +DEPS_DIR="$BUILD_DIR/deps" +OUTPUT_DIR="$OSRS_DIR/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/" From 0d5c980935dfe6654edf0afa6c545639749fcbc6 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 23:04:08 +0300 Subject: [PATCH 12/60] spell highlight + ancient spell order add yellow border on the selected spell cell while in spell-target cursor mode (clicked a spell, waiting for enemy click). record the exact GuiSpellIdx clicked so highlight shows the right cell (was only storing ATTACK_ICE/ATTACK_BLOOD family). also fix ancient spellbook row ordering: real OSRS goes Rush -> Blitz -> Burst -> Barrage left to right (ascending level), and Blood above Ice. was previously Rush/Burst/Blitz/Barrage which is wrong. --- ocean/osrs/osrs_gui.h | 27 +++++++++++++++++++++------ ocean/osrs/osrs_human_input.h | 2 ++ ocean/osrs/osrs_human_input_types.h | 1 + ocean/osrs/osrs_render.h | 6 ++++++ 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 838578c644..f70513fbe1 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -311,6 +311,10 @@ typedef struct { 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; /* ======================================================================== */ @@ -1717,15 +1721,18 @@ typedef struct { GuiSpellIdx idx; } GuiSpellEntry; +/* real OSRS ancient book ordering within each row: Rush, Blitz, Burst, Barrage + (ascending level, widget IDs 79-82 for ice). we list blood first then ice + because that's the actual top-down order in the Ancient tab. */ static const GuiSpellEntry GUI_SPELL_GRID[] = { - { "Ice Rush", GUI_SPELL_ICE_RUSH }, - { "Ice Burst", GUI_SPELL_ICE_BURST }, - { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, - { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, { "Blood Rush", GUI_SPELL_BLOOD_RUSH }, - { "Blood Burst", GUI_SPELL_BLOOD_BURST }, { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, + { "Blood Burst", GUI_SPELL_BLOOD_BURST }, { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, + { "Ice Rush", GUI_SPELL_ICE_RUSH }, + { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "Ice Burst", GUI_SPELL_ICE_BURST }, + { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, { "Vengeance", GUI_SPELL_VENGEANCE }, }; #define GUI_SPELL_GRID_COUNT 9 @@ -1748,8 +1755,12 @@ static void gui_draw_spellbook(GuiState* gs, Player* p) { 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 = (i == 8 && p->veng_active); + 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) { @@ -1761,6 +1772,10 @@ static void gui_draw_spellbook(GuiState* gs, Player* p) { 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 (scaled to cell) */ GuiSpellIdx sidx = GUI_SPELL_GRID[i].idx; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index 5a36fd46df..ae5f85b46c 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -265,10 +265,12 @@ static void human_handle_spell_click(HumanInput* hi, GuiState* gs, /* ice spell — enter targeting mode */ 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_BARRAGE) { /* blood spell — enter targeting mode */ hi->cursor_mode = CURSOR_SPELL_TARGET; hi->selected_spell = ATTACK_BLOOD; + hi->selected_spell_gui_idx = (int)sidx; } } diff --git a/ocean/osrs/osrs_human_input_types.h b/ocean/osrs/osrs_human_input_types.h index 7cb50678bf..9d58aff879 100644 --- a/ocean/osrs/osrs_human_input_types.h +++ b/ocean/osrs/osrs_human_input_types.h @@ -32,6 +32,7 @@ typedef struct HumanInput { 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 */ diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index c3a8cb314a..7e037c388d 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -4359,6 +4359,12 @@ void pvp_render(OsrsEnv* env) { /* 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) */ From 380998c02ce265526e81a7b1b6c9930140e88d0a Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 23:06:03 +0300 Subject: [PATCH 13/60] add PLAY_REPLAY env var to play back a recorded episode in eval set PLAY_REPLAY=path.replay and the first env (env 0) loads the replay, seeds the encounter RNG to match the recording, and overrides the policy's actions with the recorded ones every tick. other envs run the policy as normal. combine with RECORD_REPLAY in training to first save a best episode, then PLAY_REPLAY in eval to watch it back. --- ocean/osrs_inferno/binding.c | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 7882e6a961..8115ea56bb 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -46,6 +46,13 @@ typedef struct { 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; + OsrsEnv render_env; /* minimal env wrapper for pvp_render() */ } InfernoEnv; @@ -61,8 +68,16 @@ static int g_best_ticks = 999999; static int g_best_zuk_hp = 999999; /* lowest Zuk HP seen (for Zuk-only training) */ void c_step(Env* env) { - for (int i = 0; i < NUM_ATNS; i++) - env->acts_staging[i] = (int)env->actions[i]; + /* 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++; + } else { + 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) { @@ -221,7 +236,10 @@ void c_step(Env* env) { } void c_reset(Env* env) { - ENCOUNTER_INFERNO.reset(env->enc_state, 0); + /* 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); @@ -235,6 +253,8 @@ void c_reset(Env* env) { 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; @@ -303,6 +323,43 @@ void my_init(Env* env, Dict* kwargs) { 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; + static int g_play_replay_loaded = 0; + const char* play_path = getenv("PLAY_REPLAY"); + 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); + } else { + 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) { + int* buf = (int*)malloc(num_ticks * NUM_ATNS * sizeof(int)); + if (fread(buf, sizeof(int), num_ticks * NUM_ATNS, fp) == (size_t)(num_ticks * NUM_ATNS)) { + 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); + /* seed the encounter to match the recording */ + ENCOUNTER_INFERNO.reset(env->enc_state, rng_seed); + } else { + free(buf); + fprintf(stderr, "PLAY_REPLAY: short read from %s\n", play_path); + } + } + fclose(fp); + } + } } /* curriculum wave mixing: start some agents at later waves for late-game gradient signal. From 106ca9ab5e1ee2b6b58e74e5c4d37173f9c343a6 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 23:13:03 +0300 Subject: [PATCH 14/60] OSRS UI authenticity: prayer 5x6, ancient spellbook, weapon styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prayer book: now 29 entries in 5x6 grid matching real OSRS display order (by level, Prayerbook widget layout). sprite IDs corrected — each prayer now shows its actual sprite (SharpEye was showing as RockSkin etc). added Mystic Lore, Steel Skin, Ultimate Strength, Incredible Reflexes that were missing from the 25-entry grid. ancient spellbook: now the full 4x4 combat grid + vengeance, sorted by level (Rush/Burst/Blitz/Barrage rows, Smoke/Shadow/Blood/Ice cols). non-castable spells (Smoke/Shadow) render with the greyed 'off' sprite for authenticity; clicks on them are ignored. only Ice/Blood/Vengeance enter the targeting flow. attack styles: weapon-specific names — bow/blowpipe/zcb shows 3 buttons (Accurate/Rapid/Longrange), scythe shows 3 (Chop/Jab/Block), godsword shows Chop/Slash/Smash/Block, claws shows Chop/Slash/Lunge/Block, staff shows Bash/Pound/Focus/Block. unknown weapons fall back to the classic Accurate/Aggressive/Controlled/Defensive. --- ocean/osrs/osrs_gui.h | 232 ++++++++++++++++++++++------------ ocean/osrs/osrs_human_input.h | 13 +- 2 files changed, 156 insertions(+), 89 deletions(-) diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index f70513fbe1..d8e61f3960 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -85,52 +85,72 @@ typedef enum { /* prayer icon indices */ /* ======================================================================== */ -/* prayer icons relevant to PvP/PvE. indices into gui prayer sprite arrays. - ordered to match the real OSRS prayer book (5 cols, top-to-bottom). */ +/* 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, /* sprite 115 / 135 */ - GUI_PRAY_BURST_STR, /* 116 / 136 */ - GUI_PRAY_CLARITY, /* 117 / 137 */ - GUI_PRAY_SHARP_EYE, /* 118 / 138 */ - GUI_PRAY_MYSTIC_WILL, /* 119 / 139 */ - GUI_PRAY_ROCK_SKIN, /* 120 / 140 */ - GUI_PRAY_SUPERHUMAN, /* 121 / 141 */ - GUI_PRAY_IMPROVED_REFLEX, /* 122 / 142 */ - GUI_PRAY_RAPID_RESTORE, /* 123 / 143 */ - GUI_PRAY_RAPID_HEAL, /* 124 / 144 */ - GUI_PRAY_PROTECT_ITEM, /* 125 / 145 */ - GUI_PRAY_HAWK_EYE, /* 126 / 146 — actually sprite 502/506 */ - GUI_PRAY_PROTECT_MAGIC, /* 127 / 147 */ - GUI_PRAY_PROTECT_MISSILES, /* 128 / 148 */ - GUI_PRAY_PROTECT_MELEE, /* 129 / 149 */ - GUI_PRAY_REDEMPTION, /* 130 / 150 */ - GUI_PRAY_RETRIBUTION, /* 131 / 151 */ - GUI_PRAY_SMITE, /* 132 / 152 */ - GUI_PRAY_CHIVALRY, /* 133 / 153 — actually sprite 945/949 */ - GUI_PRAY_PIETY, /* 134 / 154 — actually sprite 946/950 */ - /* additional prayers with non-contiguous sprite IDs */ - GUI_PRAY_EAGLE_EYE, /* sprite 504 / 508 */ - GUI_PRAY_MYSTIC_MIGHT, /* sprite 505 / 509 */ - GUI_PRAY_PRESERVE, /* sprite 947 / 951 */ - GUI_PRAY_RIGOUR, /* sprite 1420 / 1424 */ - GUI_PRAY_AUGURY, /* sprite 1421 / 1425 */ - GUI_NUM_PRAYERS + 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; /* ======================================================================== */ /* spell icon indices */ /* ======================================================================== */ +/* 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 { - GUI_SPELL_ICE_RUSH = 0, /* sprite 325 / 375 */ - GUI_SPELL_ICE_BURST, /* 326 / 376 */ - GUI_SPELL_ICE_BLITZ, /* 327 / 377 */ - GUI_SPELL_ICE_BARRAGE, /* 328 / 378 */ - GUI_SPELL_BLOOD_RUSH, /* 333 / 383 */ - GUI_SPELL_BLOOD_BURST, /* 334 / 384 */ - GUI_SPELL_BLOOD_BLITZ, /* 335 / 385 */ - GUI_SPELL_BLOOD_BARRAGE, /* 336 / 386 */ - GUI_SPELL_VENGEANCE, /* 564 */ + /* 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; @@ -386,18 +406,22 @@ static void gui_load_sprites(GuiState* gs) { gs->skill_icons_loaded &= gui_try_load(&gs->skill_icons[i], skill_icon_files[i]); } - /* prayer icons: enabled (base sprite) and disabled (+20 for base range). - base prayers 115-134 (enabled), 135-154 (disabled). - then non-contiguous: 502-509, 945-951, 1420-1425. */ - static const int pray_on_ids[] = { - 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, - 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, - 504, 505, 947, 1420, 1421, + /* 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[] = { - 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, - 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, - 508, 509, 951, 1424, 1425, + 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]); @@ -406,12 +430,20 @@ static void gui_load_sprites(GuiState* gs) { gui_try_load(&gs->prayer_off[i], off_path); } - /* spell icons */ - static const int spell_on_ids[] = { - 325, 326, 327, 328, 333, 334, 335, 336, 564, + /* 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[] = { - 375, 376, 377, 378, 383, 384, 385, 386, 614, + 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]); @@ -1534,21 +1566,9 @@ static void gui_draw_equipment(GuiState* gs, Player* p) { /* prayer panel (interface 541) — single 5-column grid, all 25 prayers */ /* ======================================================================== */ -/* OSRS prayer book order: 5 columns, 5 rows = 25 prayers. - each entry maps a grid position to a GuiPrayerIdx. */ -static const GuiPrayerIdx GUI_PRAYER_GRID[25] = { - GUI_PRAY_THICK_SKIN, GUI_PRAY_BURST_STR, GUI_PRAY_CLARITY, - GUI_PRAY_SHARP_EYE, GUI_PRAY_MYSTIC_WILL, - GUI_PRAY_ROCK_SKIN, GUI_PRAY_SUPERHUMAN, GUI_PRAY_IMPROVED_REFLEX, - GUI_PRAY_RAPID_RESTORE, GUI_PRAY_RAPID_HEAL, - GUI_PRAY_PROTECT_ITEM, GUI_PRAY_HAWK_EYE, GUI_PRAY_PROTECT_MAGIC, - GUI_PRAY_PROTECT_MISSILES, GUI_PRAY_PROTECT_MELEE, - GUI_PRAY_REDEMPTION, GUI_PRAY_RETRIBUTION, GUI_PRAY_SMITE, - GUI_PRAY_CHIVALRY, GUI_PRAY_PIETY, - GUI_PRAY_EAGLE_EYE, GUI_PRAY_MYSTIC_MIGHT, GUI_PRAY_PRESERVE, - GUI_PRAY_RIGOUR, GUI_PRAY_AUGURY, -}; -#define GUI_PRAYER_GRID_COUNT 25 +/* 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) { @@ -1595,7 +1615,7 @@ static void gui_draw_prayer(GuiState* gs, Player* p) { int ix = gx + col * (icon_sz + gap); int iy = oy + row * (icon_sz + gap); - GuiPrayerIdx pidx = GUI_PRAYER_GRID[i]; + GuiPrayerIdx pidx = (GuiPrayerIdx)i; int active = gui_prayer_is_active(pidx, p); /* draw slot_tile background */ @@ -1649,13 +1669,36 @@ static void gui_draw_combat(GuiState* gs, Player* p) { oy += 22; } - /* 4 attack style buttons (2x2 grid) scaled to fill panel width */ - static const char* style_names[] = { "Accurate", "Aggressive", "Controlled", "Defensive" }; + /* 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; /* ~151px */ + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; int btn_h = 60; - for (int i = 0; i < 4; i++) { + for (int i = 0; i < num_styles; i++) { int col = i % 2; int row = i / 2; int bx = ox + col * (btn_w + btn_gap); @@ -1721,21 +1764,40 @@ typedef struct { GuiSpellIdx idx; } GuiSpellEntry; -/* real OSRS ancient book ordering within each row: Rush, Blitz, Burst, Barrage - (ascending level, widget IDs 79-82 for ice). we list blood first then ice - because that's the actual top-down order in the Ancient tab. */ +/* 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 }, - { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, - { "Blood Burst", GUI_SPELL_BLOOD_BURST }, - { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, { "Ice Rush", GUI_SPELL_ICE_RUSH }, - { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "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 9 +#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; @@ -1777,10 +1839,12 @@ static void gui_draw_spellbook(GuiState* gs, Player* p) { DrawRectangleLinesEx((Rectangle){(float)ix, (float)iy, (float)icon_sz, (float)icon_sz}, 2.0f, YELLOW); } - /* draw spell sprite (scaled to cell) */ + /* 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) { - Texture2D tex = gs->spell_on[sidx]; + 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 }; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index ae5f85b46c..c7bce5650d 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -192,7 +192,7 @@ static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, int cell_y = oy + row * (icon_sz + gap); if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; - GuiPrayerIdx pidx = GUI_PRAYER_GRID[idx]; + GuiPrayerIdx pidx = (GuiPrayerIdx)idx; /* map prayer to action — only actionable prayers */ switch (pidx) { @@ -258,16 +258,19 @@ static void human_handle_spell_click(HumanInput* hi, GuiState* gs, 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_BARRAGE) { - /* ice spell — enter targeting mode */ + } 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_BARRAGE) { - /* blood spell — enter targeting mode */ + } 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; From 1e9b1efc66a80cf8c2fb8540e3080202e8152e43 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 12 Apr 2026 23:14:29 +0300 Subject: [PATCH 15/60] bump osrs-assets to v5 (adds smoke/shadow spell sprites) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export_sprites_modern.py now writes all 32 ancient spell sprites (smoke, shadow, blood, ice × rush/burst/blitz/barrage × on/off) so the full ancient spellbook renders correctly. --- build.sh | 2 +- ocean/osrs/scripts/export_sprites_modern.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 3c2cdf3e8b..bc1dfcec9d 100755 --- a/build.sh +++ b/build.sh @@ -124,7 +124,7 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v4/osrs-assets-v4.tar.gz" + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v5/osrs-assets-v5.tar.gz" mkdir -p data curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi diff --git a/ocean/osrs/scripts/export_sprites_modern.py b/ocean/osrs/scripts/export_sprites_modern.py index 3307a3a369..4d8c015899 100644 --- a/ocean/osrs/scripts/export_sprites_modern.py +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -199,12 +199,16 @@ def save_sprite_png(sprite: SpriteFrame, path: Path) -> None: ], # tab icons (combat, stats, quests, inventory, equipment, prayer, magic) "tab": [168, 776, 779, 780, 898, 899, 900, 901], - # ancient spell icons (enabled + disabled) + # 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], From 8e44cc85d59b5fe74d76e4882d4645dc05194581 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 14 Apr 2026 00:59:55 +0300 Subject: [PATCH 16/60] fix eval render: load assets in c_render, pace ticks at 600ms, 1.5x window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three fixes for puffer eval that the standalone viewer didn't have: 1. c_render bootstrap: lazy-init in c_render now mirrors the standalone's run_visual sequence after first pvp_render — load equipment models + anims, init overlay models, load encounter terrain/objects/cmap/anims, call render_populate_entities to set arena bounds, then re-center the camera on the arena and seed sub-tile positions for entities. without this the camera stayed at default wilderness coords and the 3D world rendered as a black void even though assets loaded. 2. tick pacing: c_step now sleeps to match ticks_per_second (default 1.667 = OSRS native 600ms/tick) so eval doesn't blaze through episodes at Python rollout speed. 9/0 keys still adjust the rate live via render_handle_input. only paces when rendering is active (rc != NULL). 3. window 1.5x: 765x503 → 1148x755 with proportional 285px panel and 63x54 inventory cells. same OSRS aspect ratio, comfortable on modern monitors. --- ocean/osrs/osrs_gui.h | 8 +++--- ocean/osrs/osrs_render.h | 6 ++--- ocean/osrs_inferno/binding.c | 50 +++++++++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index d8e61f3960..d6ab18530f 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -857,10 +857,10 @@ static int gui_content_y(GuiState* gs) { #define INV_COLS 4 #define INV_ROWS 7 /* OSRS native inventory cell pitch is ~42x36, sprite 36x32. */ -#define INV_CELL_W 42 -#define INV_CELL_H 36 -#define INV_SPRITE_W 36 -#define INV_SPRITE_H 32 +#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) { diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 7e037c388d..bf2c169478 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -34,9 +34,9 @@ /* window sized to match the OSRS fixed-client layout (765x503). 3D viewport projects into the area left of the side panel; the tile grid is a game-logic unit only, decoupled from window pixels. */ -#define RENDER_WINDOW_W 765 -#define RENDER_WINDOW_H 503 -#define RENDER_PANEL_WIDTH 190 /* OSRS side panel width */ +#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 */ diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 8115ea56bb..29e236e4a4 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -53,6 +53,9 @@ typedef struct { int replay_cursor; /* ticks consumed so far */ uint32_t replay_rng_seed; + float ticks_per_second; + double last_step_time; + OsrsEnv render_env; /* minimal env wrapper for pvp_render() */ } InfernoEnv; @@ -68,6 +71,23 @@ static int g_best_ticks = 999999; static int g_best_zuk_hp = 999999; /* lowest Zuk HP seen (for Zuk-only training) */ void c_step(Env* env) { + RenderClient* rc = (RenderClient*)env->render_env.client; + if (rc != NULL) { + env->ticks_per_second = rc->ticks_per_second; + } + if (rc != NULL && env->ticks_per_second > 0.0f) { + double interval = 1.0 / env->ticks_per_second; + double now = GetTime(); + if (env->last_step_time > 0.0) { + double elapsed = now - env->last_step_time; + if (elapsed < interval) { + WaitTime(interval - elapsed); + now = GetTime(); + } + } + env->last_step_time = now; + } + /* 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; @@ -274,6 +294,11 @@ void c_render(Env* env) { 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"); @@ -283,16 +308,21 @@ void c_render(Env* env) { 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"); - } - /* eval pacing: sleep to match tick rate so rollouts don't blaze through. - use 9/0 keys to slow/speed while viewing. defaults to OSRS speed. */ - 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(); + /* 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]; + } } } @@ -330,6 +360,8 @@ void my_init(Env* env, Dict* kwargs) { 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; const char* play_path = getenv("PLAY_REPLAY"); if (play_path && play_path[0] && !g_play_replay_loaded) { From d09a435980f1c28792efabffc9049f07717317bd Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 14 Apr 2026 10:11:43 +0300 Subject: [PATCH 17/60] drop osrs_inferno_zuk config, window comment fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove separate osrs_inferno_zuk.ini alias — use --env.start-wave 69 instead. update window size comments to reflect the 1.5x scaling. --- config/osrs_inferno_zuk.ini | 189 ------------------------------------ ocean/osrs/osrs_gui.h | 2 +- ocean/osrs/osrs_render.h | 6 +- 3 files changed, 4 insertions(+), 193 deletions(-) delete mode 100644 config/osrs_inferno_zuk.ini diff --git a/config/osrs_inferno_zuk.ini b/config/osrs_inferno_zuk.ini deleted file mode 100644 index 2eabbebfca..0000000000 --- a/config/osrs_inferno_zuk.ini +++ /dev/null @@ -1,189 +0,0 @@ -# Zuk-only training (wave 69). -# short episodes (~30-300 ticks), focused on learning shield-dancing + DPS. -# default config from best sweep trial (trial 12, score 32.2). - -[base] -env_name = osrs_inferno -policy_name = MinGRU -rnn_name = Recurrent -score_metric = episode_return - -[env] -start_wave = 69.0 -mask_in_obs = 1.0 - -[vec] -total_agents = 512 -num_buffers = 4 - -[policy] -hidden_size = 128 -num_layers = 3 - -[train] -total_timesteps = 100000000 -horizon = 32 -learning_rate = 0.00139 -beta1 = 0.8 -beta2 = 0.982 -ent_coef = 0.05 -gamma = 0.99988 -gae_lambda = 0.591 -vtrace_rho_clip = 1.0 -vtrace_c_clip = 2.24 -prio_alpha = 0.999 -prio_beta0 = 0.346 -clip_coef = 0.829 -vf_coef = 0.214 -vf_clip_coef = 1.856 -max_grad_norm = 2.29 -replay_ratio = 2.47 -minibatch_size = 2048 -ns_iters = 5 -weight_decay = 0.016 -min_lr_ratio = 0.077 -checkpoint_interval = 0 - -[sweep] -min_sps = 50000 -max_suggestion_cost = 300 -metric = episode_return -metric_distribution = linear - -[sweep.train.total_timesteps] -distribution = log_normal -min = 100000000 -max = 200000000 -scale = time - -[sweep.train.horizon] -distribution = uniform_pow2 -min = 8 -max = 128 -scale = auto - -[sweep.train.learning_rate] -distribution = log_normal -min = 0.0002 -max = 0.01 -scale = 0.5 - -[sweep.train.ent_coef] -distribution = log_normal -min = 0.0001 -max = 0.05 -scale = auto - -[sweep.train.gamma] -distribution = logit_normal -min = 0.999 -max = 0.999999 -scale = auto - -[sweep.train.beta1] -distribution = uniform -min = 0.8 -max = 0.95 -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.replay_ratio] -distribution = uniform -min = 0.5 -max = 3.0 -scale = auto - -[sweep.train.clip_coef] -distribution = uniform -min = 0.2 -max = 2.0 -scale = auto - -[sweep.train.vf_coef] -distribution = log_normal -min = 0.01 -max = 1.0 -scale = auto - -[sweep.train.vf_clip_coef] -distribution = uniform -min = 0.2 -max = 2.0 -scale = auto - -[sweep.train.max_grad_norm] -distribution = uniform -min = 0.5 -max = 3.0 -scale = auto - -[sweep.train.weight_decay] -distribution = log_normal -min = 0.005 -max = 0.1 -scale = auto - -[sweep.train.min_lr_ratio] -distribution = uniform -min = 0.0 -max = 0.3 -scale = auto - -[sweep.train.minibatch_size] -distribution = uniform_pow2 -min = 256 -max = 4096 -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.5 -scale = auto - -[sweep.vec.total_agents] -distribution = uniform_pow2 -min = 256 -max = 2048 -scale = auto - -[sweep.vec.num_buffers] -distribution = uniform_pow2 -min = 2 -max = 4 -scale = auto - -[sweep.policy.hidden_size] -distribution = uniform_pow2 -min = 128 -max = 512 -scale = auto - -[sweep.policy.num_layers] -distribution = uniform -min = 2 -max = 3 -scale = auto diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index d6ab18530f..825d95a0bd 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -856,7 +856,7 @@ static int gui_content_y(GuiState* gs) { 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, sprite 36x32. */ +/* 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 diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index bf2c169478..71e4d964a4 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -31,9 +31,9 @@ /* ======================================================================== */ #define RENDER_TILE_SIZE 20 -/* window sized to match the OSRS fixed-client layout (765x503). - 3D viewport projects into the area left of the side panel; the tile - grid is a game-logic unit only, decoupled from window pixels. */ +/* 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 */ From 3cbc2000dc3579b9552c25115fa23c95b718f368 Mon Sep 17 00:00:00 2001 From: Joseph Suarez Date: Tue, 14 Apr 2026 15:50:36 +0000 Subject: [PATCH 18/60] bump dict log sizes to 64 --- build.sh | 3 ++- src/bindings.cu | 4 ++-- src/bindings_cpu.cpp | 2 +- src/pufferlib.cu | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/build.sh b/build.sh index bc1dfcec9d..19d824a6ab 100755 --- a/build.sh +++ b/build.sh @@ -55,7 +55,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 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; } From 1f8abba82273237a8820d3edd26c066e1ca4ab04 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 14 Apr 2026 20:57:19 +0300 Subject: [PATCH 19/60] fix BFS fallback stale-cost bug + NPC rendering in eval pathfinding generation-counter fallback now checks BFS_VISITED before reading BFS_COST. eval c_render calls render_post_tick every frame so spawned NPCs get visual positions updated (fixes invisible enemies). --- ocean/osrs/osrs_pathfinding.h | 73 +++++++++++++++++++++-------------- ocean/osrs_inferno/binding.c | 11 ++++++ 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/ocean/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h index d430dc302e..d6d19f0091 100644 --- a/ocean/osrs/osrs_pathfinding.h +++ b/ocean/osrs/osrs_pathfinding.h @@ -361,18 +361,33 @@ static inline PathResult pathfind_step_arena( return result; } - /* BFS working arrays — arena-sized (48x48 max = ~9KB each vs 104x104 = ~43KB) */ - int via[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - int cost[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; - memset(via, 0, sizeof(via)); - memset(cost, 0, sizeof(cost)); + /* 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; - via[local_src_x][local_src_y] = VIA_START; - cost[local_src_x][local_src_y] = 1; + BFS_VISIT(local_src_x, local_src_y, VIA_START, 1); queue_x[tail] = local_src_x; queue_y[tail] = local_src_y; tail++; @@ -392,73 +407,73 @@ static inline PathResult pathfind_step_arena( int abs_x = arena_origin_x + cur_x; int abs_y = arena_origin_y + cur_y; - int next_cost = cost[cur_x][cur_y] + 1; + 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 && via[cur_x][cur_y - 1] == 0 + 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++; - via[cur_x][cur_y - 1] = VIA_S; cost[cur_x][cur_y - 1] = next_cost; + BFS_VISIT(cur_x, cur_y - 1, VIA_S, next_cost); } /* west */ - if (cur_x > 0 && via[cur_x - 1][cur_y] == 0 + 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++; - via[cur_x - 1][cur_y] = VIA_W; cost[cur_x - 1][cur_y] = next_cost; + BFS_VISIT(cur_x - 1, cur_y, VIA_W, next_cost); } /* north */ - if (cur_y < arena_h - 1 && via[cur_x][cur_y + 1] == 0 + 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++; - via[cur_x][cur_y + 1] = VIA_N; cost[cur_x][cur_y + 1] = next_cost; + BFS_VISIT(cur_x, cur_y + 1, VIA_N, next_cost); } /* east */ - if (cur_x < arena_w - 1 && via[cur_x + 1][cur_y] == 0 + 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++; - via[cur_x + 1][cur_y] = VIA_E; cost[cur_x + 1][cur_y] = next_cost; + BFS_VISIT(cur_x + 1, cur_y, VIA_E, next_cost); } /* south-west */ - if (cur_x > 0 && cur_y > 0 && via[cur_x - 1][cur_y - 1] == 0 + 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++; - via[cur_x - 1][cur_y - 1] = VIA_SW; cost[cur_x - 1][cur_y - 1] = next_cost; + BFS_VISIT(cur_x - 1, cur_y - 1, VIA_SW, next_cost); } /* north-west */ - if (cur_x > 0 && cur_y < arena_h - 1 && via[cur_x - 1][cur_y + 1] == 0 + 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++; - via[cur_x - 1][cur_y + 1] = VIA_NW; cost[cur_x - 1][cur_y + 1] = next_cost; + BFS_VISIT(cur_x - 1, cur_y + 1, VIA_NW, next_cost); } /* south-east */ - if (cur_x < arena_w - 1 && cur_y > 0 && via[cur_x + 1][cur_y - 1] == 0 + 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++; - via[cur_x + 1][cur_y - 1] = VIA_SE; cost[cur_x + 1][cur_y - 1] = next_cost; + 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 && via[cur_x + 1][cur_y + 1] == 0 + 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++; - via[cur_x + 1][cur_y + 1] = VIA_NE; cost[cur_x + 1][cur_y + 1] = next_cost; + BFS_VISIT(cur_x + 1, cur_y + 1, VIA_NE, next_cost); } #undef EB @@ -474,13 +489,13 @@ static inline PathResult pathfind_step_arena( 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 (cost[fx][fy] == 0) 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 && cost[fx][fy] < best_cost)) { + (dist_sq == best_dist_sq && BFS_COST(fx, fy) < best_cost)) { best_dist_sq = dist_sq; - best_cost = cost[fx][fy]; + best_cost = BFS_COST(fx, fy); best_x = fx; best_y = fy; } } @@ -495,7 +510,7 @@ static inline PathResult pathfind_step_arena( /* backtrack to find first step */ while (1) { - int v = via[cur_x][cur_y]; + 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--; @@ -506,7 +521,7 @@ static inline PathResult pathfind_step_arena( return result; } cur_x = prev_x; cur_y = prev_y; - if (via[cur_x][cur_y] == VIA_NONE || via[cur_x][cur_y] == VIA_START) break; + if (BFS_VIA(cur_x, cur_y) == VIA_NONE || BFS_VIA(cur_x, cur_y) == VIA_START) break; } return result; diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 29e236e4a4..14cc3d6377 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -324,6 +324,17 @@ void c_render(Env* env) { rc->dest_y[i] = rc->sub_y[i]; } } + + /* update NPC visual positions every frame (not just first call). + render_post_tick snaps sub_x/sub_y/dest_x/dest_y for spawned/moved NPCs + and resets composite state on npc_slot changes. the standalone viewer + calls this in visual_frame; without it, NPCs that spawn after first + render stay at stale/zero positions and are invisible. */ + RenderClient* rc2 = (RenderClient*)re->client; + if (rc2) { + render_populate_entities(rc2, re); + render_post_tick(rc2, re); + } } #define MY_VEC_INIT From f7072fc3ea49561bd72bbb146522fc7ac58cc344 Mon Sep 17 00:00:00 2001 From: Joseph Suarez Date: Tue, 14 Apr 2026 20:49:57 +0000 Subject: [PATCH 20/60] minify obs, tweak rewards, fast config. Wave 33 in 2:17 --- config/osrs_inferno.ini | 37 +-- ocean/osrs/encounters/encounter_inferno.h | 310 ++++++++++++++++------ ocean/osrs/osrs_encounter.h | 15 +- ocean/osrs/osrs_visual.c | 109 ++++++++ ocean/osrs_inferno/binding.c | 24 +- 5 files changed, 374 insertions(+), 121 deletions(-) diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index cb611614d6..0d69e8418e 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -12,45 +12,22 @@ start_wave = 0.0 mask_in_obs = 1.0 # curriculum: fraction of agents starting at later waves (rest at start_wave) curriculum_wave_1 = 20.0 -curriculum_frac_1 = 0.10 +curriculum_frac_1 = 0.00 curriculum_wave_2 = 40.0 -curriculum_frac_2 = 0.05 +curriculum_frac_2 = 0.00 curriculum_wave_3 = 60.0 -curriculum_frac_3 = 0.05 +curriculum_frac_3 = 0.00 [vec] -total_agents = 256 -num_buffers = 2 +total_agents = 8192 +num_buffers = 4 [policy] -hidden_size = 512 +hidden_size = 128 num_layers = 3 [train] -# anchor from CUDA sweep (100 trials, 4080S). best config for wave 28-30 range. -# real progress requires 10B+ steps on multi-GPU — short sweeps only find brute-force configs. -total_timesteps = 1600000000 -horizon = 64 -min_lr_ratio = 0.4465 -learning_rate = 0.004373 -beta1 = 0.9337 -eps = 0.000012 -ent_coef = 0.026798 -gamma = 0.9999145 -gae_lambda = 0.6379 -vtrace_rho_clip = 1.552 -vtrace_c_clip = 1.322 -prio_alpha = 0.0 -prio_beta0 = 0.6249 -clip_coef = 0.2 -vf_coef = 0.2132 -vf_clip_coef = 0.282447 -max_grad_norm = 1.799 -replay_ratio = 1.538 -minibatch_size = 4096 -ns_iters = 5 -weight_decay = 0.00227 -checkpoint_interval = 0 +total_timesteps = 400_000_000 [sweep] min_sps = 50000 diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 997c48c085..f83556e9b4 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -355,6 +355,7 @@ static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { /* 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 @@ -576,6 +577,7 @@ typedef struct { 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 */ @@ -1259,6 +1261,10 @@ static void inf_npc_attack(InfernoState* s, int idx) { has_los_now && !npc->had_los_last_tick) { npc->blob_scanned_prayer = (int)s->player.prayer; + /* Pre-determine style for oracle */ + 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; @@ -1400,7 +1406,8 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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; } + 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->attacked_this_tick = 1; @@ -1429,19 +1436,15 @@ static void inf_npc_attack(InfernoState* s, int idx) { if (npc->blob_scanned_prayer < 0) { /* no pending scan → start scan phase */ npc->blob_scanned_prayer = (int)s->player.prayer; + /* Pre-determine style for oracle */ + 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 */ - OverheadPrayer read_prayer = (OverheadPrayer)npc->blob_scanned_prayer; - if (read_prayer == PRAYER_PROTECT_MAGIC) - npc->attack_style = ATTACK_STYLE_RANGED; - else if (read_prayer == PRAYER_PROTECT_RANGED) - npc->attack_style = ATTACK_STYLE_MAGIC; - else - npc->attack_style = (encounter_rand_int(&s->rng_state, 2) == 0) - ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; npc->blob_scanned_prayer = -1; /* fall through to common attack code */ } @@ -1561,7 +1564,8 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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]++; } + 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); @@ -1571,7 +1575,8 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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]++; } + 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; @@ -1986,10 +1991,51 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { } } +static OverheadPrayer inf_get_oracle_prayer(InfernoState* s) { + int correct_style = -1; + for (int h = 0; h < s->player_pending_hit_count; h++) { + EncounterPendingHit* ph = &s->player_pending_hits[h]; + if (ph->check_prayer && ph->ticks_remaining == 1) { + correct_style = ph->attack_style; + break; + } + } + if (correct_style == -1) { + 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_JAD || 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; + if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; + + int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, npc->size); + if (dist == 0 || dist > st->attack_range) continue; + + int t = npc->attack_timer; + if (t == 0) t = 1; + if (t == 1) { + int style = npc->attack_style; + + if (st->can_melee && dist == 1) { + style = ATTACK_STYLE_MELEE; + } + correct_style = style; + break; + } + } + } + if (correct_style == ATTACK_STYLE_MELEE) return PRAYER_PROTECT_MELEE; + if (correct_style == ATTACK_STYLE_RANGED) return PRAYER_PROTECT_RANGED; + if (correct_style == ATTACK_STYLE_MAGIC) return PRAYER_PROTECT_MAGIC; + return s->player.prayer; +} + static void inf_player_pretick(InfernoState* s, const int* actions) { - /* prayer switches become active in pretick, then drain uses the updated - overhead before NPCs act. ref: osrs-sdk World.ts + PrayerController.ts. */ - encounter_apply_prayer_action(&s->player.prayer, actions[INF_HEAD_PRAYER]); + s->player.prayer = inf_get_oracle_prayer(s); int drain = encounter_prayer_drain_effect(s->player.prayer) + 24; encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, 0, &s->player.prayer_drain_counter, drain); @@ -2347,6 +2393,8 @@ static float inf_compute_reward(InfernoState* s) { if (s->behind_shield_this_tick) r += 0.005f; + + if (s->damage_dealt_this_tick > 0.0f) r += 0.01f * s->damage_dealt_this_tick; @@ -2366,6 +2414,7 @@ static void inf_step(EncounterState* state, const int* actions) { s->damage_dealt_this_tick = 0.0f; s->damage_received_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; @@ -2434,7 +2483,7 @@ static void inf_step(EncounterState* state, const int* actions) { encounter_resolve_player_pending_hits( s->player_pending_hits, &s->player_pending_hit_count, &s->player, s->player.prayer, - &s->damage_received_this_tick, &s->prayer_correct_this_tick); + &s->damage_received_this_tick, &s->prayer_correct_this_tick, &s->off_prayer_hits_this_tick); inf_resolve_pending_sparks(s); /* player actions */ @@ -2461,7 +2510,7 @@ static void inf_step(EncounterState* state, const int* actions) { total_npc_attacks counts attacks directed at the player (not nibbler→pillar). */ s->total_prayer_correct += s->prayer_correct_this_tick; for (int i = 0; i < INF_MAX_NPCS; i++) { - if (s->npcs[i].attacked_this_tick && s->npcs[i].type != INF_NPC_NIBBLER) + 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 @@ -2502,7 +2551,8 @@ static void inf_step(EncounterState* state, const int* actions) { if (actions[h] == 0) s->action_noop_count[h]++; } - s->reward = inf_compute_reward(s); + inf_compute_reward(s); + s->reward = 0.0f; s->episode_return += s->reward; /* check player death */ @@ -2513,7 +2563,7 @@ static void inf_step(EncounterState* state, const int* actions) { 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 */ + s->reward = 0.0f; /* player died. Do not negative reward to avoid stalling.*/ return; } @@ -2534,10 +2584,11 @@ static void inf_step(EncounterState* state, const int* actions) { 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; - s->reward = 1.0f; /* player won */ } else { s->wave_spawn_target = s->wave + 1; s->wave_spawn_delay = 9; @@ -2557,10 +2608,10 @@ static void inf_step(EncounterState* state, const int* actions) { /* ======================================================================== */ /* obs layout: 49 player + 12 pillar + 33*32 NPC + 5*8 pending hits = 1157 */ -#define INF_PLAYER_OBS_SIZE 49 -#define INF_FEATURES_PER_NPC 33 +#define INF_PLAYER_OBS_SIZE 52 +#define INF_TOTAL_NPC_OBS_SIZE 282 #define INF_FEATURES_PER_HIT 5 -#define INF_NUM_OBS (INF_PLAYER_OBS_SIZE + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS + INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) +#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] = { @@ -2600,8 +2651,9 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = (float)s->player.current_prayer / 99.0f; obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; /* tick normalization: Zuk-only (~300 ticks) vs full runs (~18000 ticks) */ - obs[i++] = (s->start_wave >= 68) ? (float)s->tick / 500.0f - : (float)s->tick / (float)INF_MAX_TICKS; + obs[i++] = 0.0f; + // (s->start_wave >= 68) ? (float)s->tick / 500.0f + // : (float)s->tick / (float)INF_MAX_TICKS; obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; @@ -2626,44 +2678,89 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = (float)s->loadout_stats[s->weapon_set].def_ranged / 300.0f; obs[i++] = (float)s->player.special_energy / 100.0f; - /* prayer-critical: distilled from NPC array so the agent doesn't have to - scan 32 slots to figure out what to pray. these directly answer: - "what style should I pray?" and "how urgent is it?" */ + /* prayer-critical: distilled from NPC array and pending hits */ { int min_timer = 999; - int min_style = 0; /* style of the NPC with lowest timer */ + int min_style = 0; int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; + int correct_style_next_tick = -1; + + /* 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 = ph->ticks_remaining; + 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; + } + if (t == 1) { + correct_style_next_tick = ph->attack_style; + } + } + } + + /* 2. NPCs firing (handles non-Jad, which check prayer on launch/firing) */ 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_JAD || 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 (st->attack_range <= 1 && npc->type != INF_NPC_MELEER) continue; /* skip nibblers */ - /* only count NPCs that can actually attack: has LOS, in range, not frozen/stunned */ if (npc->frozen_ticks > 0 || npc->stun_timer > 0) continue; - if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; - int dist = encounter_dist_to_npc(s->player.x, s->player.y, - npc->x, npc->y, npc->size); - if (dist == 0 || dist > st->attack_range) continue; - int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; - if (npc->attack_timer < min_timer) { - min_timer = npc->attack_timer; + + /* Relaxed check: if they can reach us or shoot us soon, track them. */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, npc->size); + if (dist == 0) continue; /* Under us, usually can't attack or moves out */ + + /* If they are completely out of range (more than 1 tile away from being able to attack), + we might ignore them, but to be safe we'll just track all aggroed NPCs that are off cooldown. + We'll just leave dist and LOS out of the strict filter. */ + + 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; + } + if (st->can_melee && dist == 1) { + style = ATTACK_STYLE_MELEE; + } + + int t = npc->attack_timer; + if (t == 0) t = 1; /* Safety fallback if timer hit 0 */ + + if (t < min_timer) { + min_timer = t; min_style = style; } - if (npc->attack_timer <= 2) { + if (t <= 2) { if (style == ATTACK_STYLE_MELEE) has_melee_2 = 1; if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; } + if (t == 1) { + correct_style_next_tick = style; + } } + int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; - /* ticks until next enemy attack (0 = firing this tick, 1 = imminent) */ obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; - /* style of most imminent attacker (one-hot) */ obs[i++] = (min_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; obs[i++] = (min_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; obs[i++] = (min_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; - /* how many distinct styles fire within 2 ticks (0=safe, 1=single pray, 2+=conflict) */ obs[i++] = (float)conflict_count / 3.0f; + + obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; } /* Zuk-phase features (10 features: 1 flag + 9 Zuk-specific) */ @@ -2714,47 +2811,107 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = (float)(s->pillars[p].y - py) / (float)INF_ARENA_HEIGHT; } - /* NPCs: INF_FEATURES_PER_NPC (33) features each, up to INF_MAX_NPCS */ + 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) { - obs[i++] = 1.0f; - /* type one-hot (14 features) */ - for (int t = 0; t < INF_NUM_NPC_TYPES; t++) - obs[i++] = ((int)npc->type == t) ? 1.0f : 0.0f; + int t = npc->type; + if (slot_counts[t] < slot_max[t]) { + obs_slots[slot_offsets[t] + slot_counts[t]] = n; + slot_counts[t]++; + } + } + } + + /* 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; - /* relative position to player */ obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; - obs[i++] = (float)npc->attack_timer / 10.0f; - /* attack style: for jad, use jad_attack_style (the actual per-attack style) - since npc->attack_style stays at the default forever */ - { + 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; } - obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; - obs[i++] = (float)npc->frozen_ticks / 32.0f; - obs[i++] = INF_NPC_MAX_HIT_NORM[npc->type]; - /* blob scan state (3-feature one-hot: magic / ranged / other) */ - if (npc->type == INF_NPC_BLOB && 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; + + 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; + } } - obs[i++] = (float)INF_NPC_STATS[npc->type].attack_range / 100.0f; - obs[i++] = (float)INF_NPC_STATS[npc->type].magic_def_bonus / 350.0f; + /* barrage AoE count: unique NPCs in 3x3 area via occupancy grid */ { int aoe_count = 0; - uint32_t seen = 0; /* bitmask of NPC indices already counted */ + uint32_t seen = 0; int cx = npc->x - INF_ARENA_MIN_X; int cy = npc->y - INF_ARENA_MIN_Y; for (int dx = -1; dx <= 1; dx++) { @@ -2774,26 +2931,21 @@ static void inf_write_obs(EncounterState* state, float* obs) { } obs[i++] = (float)aoe_count / 8.0f; } - /* NPC aggro target: 1.0 if targeting player, 0.0 if targeting another NPC - (pillar/shield/Zuk). tells agent which NPCs need tagging off pillars/shield. */ - obs[i++] = (npc->aggro_target < 0) ? 1.0f : 0.0f; - /* is this NPC the player's current attack target? */ - obs[i++] = (osrs_interaction_active(&s->interaction) && - s->interaction.target_slot == n) ? 1.0f : 0.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 < INF_FEATURES_PER_NPC; j++) obs[i++] = 0.0f; + 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_FEATURES_PER_NPC * INF_MAX_NPCS; + 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 " - "(INF_FEATURES_PER_NPC=%d, actual=%d per slot)\n", - i, expected_npc_end, INF_FEATURES_PER_NPC, - (i - INF_PLAYER_OBS_SIZE - 12) / INF_MAX_NPCS); + fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d\n", + i, expected_npc_end); abort(); } } diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index d8afbf72df..dffc4f5409 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -854,7 +854,8 @@ static inline int encounter_resolve_npc_pending_hit( 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 + float* damage_received_acc, int* prayer_correct_count, + int* off_prayer_hit_count ) { for (int i = 0; i < *hit_count; i++) { hits[i].ticks_remaining--; @@ -864,8 +865,20 @@ static inline void encounter_resolve_player_pending_hits( 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) { + /* Even if check_prayer was done at launch (e.g. non-Jad mobs), + it only zeroed dmg if correctly prayed AT LAUNCH. + Wait, actually, for non-Jad, check_prayer is 0 and dmg is already computed. + If dmg > 0, it means we took a hit. Was it an off-prayer hit? + Yes, because if we prayed correctly at launch, dmg would be 0. + BUT we don't know if we just missed the pray or if it was typeless. + We assume ATTACK_STYLE_NONE is typeless. */ + if (off_prayer_hit_count) (*off_prayer_hit_count)++; } + encounter_damage_player(player, dmg, damage_received_acc); hits[i] = hits[--(*hit_count)]; i--; diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index 9ceb48c9d2..9ab5b20a03 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -99,6 +99,89 @@ static void benchmark(OsrsEnv* env, int num_steps) { 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. */ @@ -544,12 +627,14 @@ static void run_visual(OsrsEnv* env, const char* encounter_name, const char* rep 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) @@ -569,6 +654,30 @@ int main(int argc, char** argv) { 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 */ diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 29e236e4a4..2bf4bb46c0 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -500,26 +500,26 @@ void my_log(Log* log, Dict* out) { float unavoidable_rate = (off_prayer > 0.0f) ? log->unavoidable_off_prayer / off_prayer : 0.0f; dict_set(out, "unavoidable_off_prayer_rate", unavoidable_rate); - dict_set(out, "unavoidable_off_prayer", log->unavoidable_off_prayer); + //dict_set(out, "unavoidable_off_prayer", log->unavoidable_off_prayer); dict_set(out, "brews_remaining", log->brews_remaining); dict_set(out, "restores_remaining", log->restores_remaining); dict_set(out, "prayer_at_death", log->prayer_at_death); - dict_set(out, "npc_kills", log->npc_kills); - dict_set(out, "gear_switches", log->gear_switches); + //dict_set(out, "npc_kills", log->npc_kills); + //dict_set(out, "gear_switches", log->gear_switches); dict_set(out, "current_ranged", log->current_ranged); dict_set(out, "current_magic", log->current_magic); dict_set(out, "behind_shield_pct", log->behind_shield_pct); dict_set(out, "zuk_hp_remaining", log->zuk_hp_remaining); - dict_set(out, "noop_move", log->noop_move); - dict_set(out, "noop_prayer", log->noop_prayer); - dict_set(out, "noop_target", log->noop_target); - dict_set(out, "noop_gear", log->noop_gear); - dict_set(out, "noop_eat", log->noop_eat); - dict_set(out, "noop_potion", log->noop_potion); - dict_set(out, "noop_spell", log->noop_spell); - dict_set(out, "noop_spec", log->noop_spec); + //dict_set(out, "noop_move", log->noop_move); + //dict_set(out, "noop_prayer", log->noop_prayer); + //dict_set(out, "noop_target", log->noop_target); + //dict_set(out, "noop_gear", log->noop_gear); + //dict_set(out, "noop_eat", log->noop_eat); + //dict_set(out, "noop_potion", log->noop_potion); + //dict_set(out, "noop_spell", log->noop_spell); + //dict_set(out, "noop_spec", log->noop_spec); 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); @@ -538,6 +538,7 @@ void my_log(Log* log, Dict* out) { /* 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" @@ -558,4 +559,5 @@ void my_log(Log* log, Dict* out) { if (log->killed_by_type[t] > 0.0f) dict_set(out, kill_keys[t], log->killed_by_type[t]); } + */ } From ef47f13f95d9ed25f61cbbe08c9b069c9ad4338d Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 15 Apr 2026 15:40:13 +0300 Subject: [PATCH 21/60] inferno: net damage reward + jad healer fix + zuk healer parity - exclude HEALER_JAD from generic NPC-damages-NPC block so its heal branch is reachable. before this, jad healers did 0 damage (magic stats are 0) AND never healed, making jad waves artificially easy. - HEALER_ZUK stun_on_spawn 2 -> 1 to match JalMejJak.ts SPAWN_DELAY - INF_MAX_PENDING_SPARKS 16 -> 32 so 4-healer overlapping volleys (up to 24 sparks in flight) don't silently drop - add hp_restored_this_tick tracked from healer landings + mager resurrection. reward is now 0.01 * max(0, damage - restored) so healers canceling the agent's damage yield zero signal, same shape for zuk healers, jad healers, and mager resurrection - log.hp_restored surfaces total restoration per episode on dashboard --- ocean/osrs/encounters/encounter_inferno.h | 38 +++++++++++++++++++---- ocean/osrs/osrs_types.h | 1 + ocean/osrs_inferno/binding.c | 2 ++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 997c48c085..b02955e530 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -190,7 +190,7 @@ static const InfNPCOverlay INF_NPC_OVERLAY[INF_NUM_NPC_TYPES] = { [INF_NPC_JAD] = { 50, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 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, 0, 1 }, - [INF_NPC_HEALER_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 10, 100, 0, 2, 0 }, /* stun_on_spawn=2 per InfernoTrainer TzKalZuk.ts:168 */ + [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 }, }; @@ -365,7 +365,9 @@ typedef struct { int hp, max_hp; } InfDeadMob; -#define INF_MAX_PENDING_SPARKS 16 +/* 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; @@ -564,6 +566,11 @@ typedef struct { float episode_return; /* accumulated reward over entire episode */ float damage_dealt_this_tick; float damage_received_this_tick; + /* HP restored to the enemy side this tick — subtracted from damage_dealt + in inf_compute_reward so the agent gets no credit for damage that's + immediately undone. sources: zuk healer heals (landing tick), jad healer + heals (landing tick), mager resurrection (the resurrected mob's HP). */ + 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 */ @@ -571,6 +578,7 @@ typedef struct { /* cumulative stats for diagnostics */ float total_damage_dealt; 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 */ @@ -1276,9 +1284,11 @@ static void inf_npc_attack(InfernoState* s, int idx) { if (npc->type == INF_NPC_ZUK_SHIELD) return; /* NPC targeting another NPC (set/jad → shield): always hit, random damage. - zuk healers excluded — they have their own handler below that HEALS instead of damages. */ + 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_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); @@ -1657,6 +1667,9 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { 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; + /* resurrection restores HP the agent already paid to remove — treat like a + heal for reward purposes so re-kills don't double-count as progress. */ + s->hp_restored_this_tick += (float)dm->hp; /* remove from dead store (swap with last) */ s->dead_mobs[di] = s->dead_mobs[s->dead_mob_count - 1]; @@ -1811,9 +1824,13 @@ static void inf_healer_apply_landed_heal(InfernoState* s, int idx) { 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( @@ -2332,6 +2349,7 @@ static float inf_compute_reward(InfernoState* s) { blow's damage is counted in total_damage_received */ s->total_damage_dealt += s->damage_dealt_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; @@ -2347,8 +2365,15 @@ static float inf_compute_reward(InfernoState* s) { if (s->behind_shield_this_tick) r += 0.005f; - if (s->damage_dealt_this_tick > 0.0f) - r += 0.01f * s->damage_dealt_this_tick; + /* net effective damage: gross damage minus HP restored to enemies this tick. + zuk/jad healers heal their targets and mager resurrection spawns dead mobs + back at 50% HP — without subtracting these the agent has no signal that + its damage is being undone and can't learn to prioritize healers/mager. + clamped at 0 so a pure-healing tick is zero-reward, never negative (a + negative per-tick reward would make dying feel good). */ + float net_damage = s->damage_dealt_this_tick - s->hp_restored_this_tick; + if (net_damage > 0.0f) + r += 0.01f * net_damage; return r; } @@ -2365,6 +2390,7 @@ static void inf_step(EncounterState* state, const int* actions) { s->reward = 0.0f; s->damage_dealt_this_tick = 0.0f; s->damage_received_this_tick = 0.0f; + s->hp_restored_this_tick = 0.0f; s->prayer_correct_this_tick = 0; s->tick_styles_fired = 0; s->tick_attacks_fired = 0; diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 00483c33e6..85d335ba58 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -791,6 +791,7 @@ typedef struct { /* 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 hp_restored; /* HP restored to enemies (healers + mager) this episode */ /* action noop rates per head (0=move,1=prayer,2=target,3=gear,4=eat,5=pot,6=spell,7=spec) */ float noop_move; float noop_prayer; diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 14cc3d6377..62672ec97f 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -138,6 +138,7 @@ void c_step(Env* env) { env->log.episode_length += (float)s->tick; env->log.damage_dealt += s->total_damage_dealt; env->log.damage_received += s->total_damage_received; + env->log.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; @@ -523,6 +524,7 @@ void my_log(Log* log, Dict* out) { 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, "hp_restored", log->hp_restored); dict_set(out, "noop_move", log->noop_move); dict_set(out, "noop_prayer", log->noop_prayer); dict_set(out, "noop_target", log->noop_target); From e3b413259323ee71085a7ab0f35df332229e4ea3 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 15 Apr 2026 17:26:24 +0300 Subject: [PATCH 22/60] puffer eval: auto-load latest checkpoint by default - eval() without --load-model-path now auto-resolves the latest checkpoints//**/*.bin by ctime and loads it. if none exist, prints a warning and continues with random weights (no silent mystery-untrained-policy). --load-model-path latest and explicit paths unchanged. - flush=True on the Loaded/WARNING prints so they aren't swallowed by stdout buffering when the raylib window opens immediately after. - harden the save guard at line 236 so checkpoint_interval <= 0 means "disabled" instead of ZeroDivisionError crash (future-proofing) - remove the dead checkpoint_interval = 0 from [train] in osrs_inferno, osrs_zulrah, osrs_pvp configs. nothing reads [train] for that key; the real one lives in [base] and inherits 200 from default.ini. the original commit 5543a77a7 put it in the wrong section. --- config/osrs_inferno.ini | 1 - config/osrs_pvp.ini | 1 - config/osrs_zulrah.ini | 1 - pufferlib/pufferl.py | 26 ++++++++++++++++++++------ 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index cb611614d6..de28df8c60 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -50,7 +50,6 @@ replay_ratio = 1.538 minibatch_size = 4096 ns_iters = 5 weight_decay = 0.00227 -checkpoint_interval = 0 [sweep] min_sps = 50000 diff --git a/config/osrs_pvp.ini b/config/osrs_pvp.ini index 59c92cdae5..c1023c150c 100644 --- a/config/osrs_pvp.ini +++ b/config/osrs_pvp.ini @@ -35,4 +35,3 @@ replay_ratio = 0.25 minibatch_size = 4096 ns_iters = 5 weight_decay = 0.001 -checkpoint_interval = 0 diff --git a/config/osrs_zulrah.ini b/config/osrs_zulrah.ini index 4d34053cf6..cd95b5c1b2 100644 --- a/config/osrs_zulrah.ini +++ b/config/osrs_zulrah.ini @@ -36,4 +36,3 @@ replay_ratio = 0.25 minibatch_size = 4096 ns_iters = 5 weight_decay = 0.001 -checkpoint_interval = 0 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) From 5957f0e32f1b112dbf7cbc816194999dabf7a7ce Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 15 Apr 2026 17:26:24 +0300 Subject: [PATCH 23/60] inferno: smooth 60fps animation in puffer eval render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit c_render now spins pvp_render at ~60fps until the next sim tick is due, then returns. matches the standalone ./osrs_visual viewer's visual_frame pattern (osrs_visual.c:158). removes the WaitTime from c_step since c_render drives pacing now. root cause: pvp_render uses GetFrameTime() to accumulate client_tick_ accumulator, stepping render_client_tick every 20ms — this is what interpolates sub_x/sub_y smoothly between sim ticks. the old flow called pvp_render once per 600ms tick, so all 30 client-ticks collapsed into a single frame and entities snapped instantly without visible motion. now pvp_render fires ~36 times per tick, matching the 60fps render / 1.67Hz sim split the standalone viewer uses. replay (PLAY_REPLAY/RECORD_REPLAY) is unaffected — only timing moved. --- ocean/osrs_inferno/binding.c | 50 +++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 62672ec97f..590b79fa40 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -71,22 +71,9 @@ static int g_best_ticks = 999999; static int g_best_zuk_hp = 999999; /* lowest Zuk HP seen (for Zuk-only training) */ void c_step(Env* env) { - RenderClient* rc = (RenderClient*)env->render_env.client; - if (rc != NULL) { - env->ticks_per_second = rc->ticks_per_second; - } - if (rc != NULL && env->ticks_per_second > 0.0f) { - double interval = 1.0 / env->ticks_per_second; - double now = GetTime(); - if (env->last_step_time > 0.0) { - double elapsed = now - env->last_step_time; - if (elapsed < interval) { - WaitTime(interval - elapsed); - now = GetTime(); - } - } - env->last_step_time = now; - } + /* tick pacing lives in c_render — it blocks at the tick deadline calling + pvp_render at ~60fps so sub-tile interpolation can animate between sim + ticks. nothing to do here timing-wise. */ /* replay playback: if this env has a loaded replay, override policy actions */ if (env->replay_actions && env->replay_cursor < env->replay_num_ticks) { @@ -324,18 +311,33 @@ void c_render(Env* env) { rc->dest_x[i] = rc->sub_x[i]; rc->dest_y[i] = rc->sub_y[i]; } + env->last_step_time = GetTime(); } - /* update NPC visual positions every frame (not just first call). + RenderClient* rc = (RenderClient*)re->client; + if (!rc) return; + + /* 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. the standalone viewer - calls this in visual_frame; without it, NPCs that spawn after first - render stay at stale/zero positions and are invisible. */ - RenderClient* rc2 = (RenderClient*)re->client; - if (rc2) { - render_populate_entities(rc2, re); - render_post_tick(rc2, re); + 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 From eb86e94007bc1b3c4c43eeef036d39f082ca2d61 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 15 Apr 2026 17:30:03 +0300 Subject: [PATCH 24/60] inferno: fix boost potion sprites (bastion/stamina) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two bugs stacked: (1) inf_fill_render_entities was aliasing bastion_doses → combat_potion_doses and stamina_doses → ranged_potion_doses, which routed through the super-combat / ranging sprite paths and hid the proper bastion/stamina types added in cca152931. (2) the consumable_ids sprite-load list in gui_inv_load_ sprites never included bastion/stamina OSRS IDs, so even after fixing the alias the sprites showed as "??? ???". fix both: remove the alias, add OSRS_ID_BASTION_{4,3,2,1} and OSRS_ID_STAMINA_{4,3,2,1} to the consumable_ids array. item_sprite_ count now loads 134 (was 126). --- ocean/osrs/encounters/encounter_inferno.h | 4 ---- ocean/osrs/osrs_gui.h | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b02955e530..335908bbd9 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -2958,10 +2958,6 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i InfernoState* s = (InfernoState*)state; int n = 0; - /* GUI reads combat_potion_doses/ranged_potion_doses generically; - inferno uses bastion/stamina, so map them for the GUI display */ - s->player.combat_potion_doses = s->player.bastion_doses; - s->player.ranged_potion_doses = s->player.stamina_doses; { const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; s->player.gui_max_hit = ls->max_hit; diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 825d95a0bd..2b433e8823 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -502,6 +502,8 @@ static void gui_load_sprites(GuiState* gs) { 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; From 14d8313141d29d92afee2c4704be6580cb85ba83 Mon Sep 17 00:00:00 2001 From: Joseph Suarez Date: Thu, 16 Apr 2026 15:02:33 +0000 Subject: [PATCH 25/60] Temp changes - fast zuk --- config/osrs_inferno.ini | 4 +- ocean/osrs/encounters/encounter_inferno.h | 118 +++++++++++++++------- ocean/osrs/osrs_types.h | 1 + ocean/osrs_inferno/binding.c | 3 + 4 files changed, 86 insertions(+), 40 deletions(-) diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index 0d69e8418e..3a9d6a47cd 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -3,12 +3,10 @@ [base] env_name = osrs_inferno -policy_name = MinGRU -rnn_name = Recurrent score_metric = episode_return [env] -start_wave = 0.0 +start_wave = 69.0 mask_in_obs = 1.0 # curriculum: fraction of agents starting at later waves (rest at start_wave) curriculum_wave_1 = 20.0 diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index f83556e9b4..04381b4966 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -541,6 +541,7 @@ typedef struct { Player player; InfNPC npcs[INF_MAX_NPCS]; + int current_obs_slots[INF_OBS_NPCS]; InfPillar pillars[INF_NUM_PILLARS]; InfZukState zuk; @@ -564,6 +565,7 @@ typedef struct { float reward; float episode_return; /* accumulated reward over entire episode */ float damage_dealt_this_tick; + float damage_zuk_healers_this_tick; float damage_received_this_tick; int prayer_correct_this_tick; /* count of NPC attacks blocked by prayer this tick */ int wave_completed_this_tick; @@ -571,6 +573,7 @@ typedef struct { /* cumulative stats for diagnostics */ float total_damage_dealt; + float total_zuk_healer_damage; float total_damage_received; int total_waves_cleared; int ticks_without_action; /* consecutive ticks with no attack or movement */ @@ -812,7 +815,7 @@ static void inf_reset(EncounterState* state, uint32_t seed) { } s->player.num_items_in_slot[GEAR_SLOT_AMMO] = 0; } - s->player.brew_doses = 32; /* 8 pots x 4 doses */ + s->player.brew_doses = 0; /* 8 pots x 4 doses */ s->player.restore_doses = 40; /* 10 pots x 4 doses */ s->player.bastion_doses = 4; /* 1 pot x 4 doses */ s->player.stamina_doses = 4; /* 1 pot x 4 doses */ @@ -1928,7 +1931,7 @@ static void inf_tick_npcs(InfernoState* s) { #define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ #define INF_HEAD_PRAYER 1 /* 5: no_change, off, melee, range, mage (ENCOUNTER_PRAYER_DIM) */ -#define INF_HEAD_TARGET 2 /* INF_MAX_NPCS+1: none or NPC index */ +#define INF_HEAD_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 */ @@ -1936,8 +1939,8 @@ static void inf_tick_npcs(InfernoState* s) { #define INF_HEAD_SPEC 7 /* 2: no_change, toggle (arm/disarm blowpipe spec) */ #define INF_NUM_ACTION_HEADS 8 -static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 }; -#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_MAX_NPCS+1 + 5 + 2 + 4 + 3 + 2) +static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_OBS_NPCS+1, 5, 2, 4, 3, 2 }; +#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_OBS_NPCS+1 + 5 + 2 + 4 + 3 + 2) /* movement uses shared encounter_move_to_target from osrs_encounter.h */ @@ -2156,17 +2159,14 @@ static void inf_tick_player(InfernoState* s, const int* actions) { target=0 means "no new target this tick" (preserves existing target). */ int target = actions[INF_HEAD_TARGET]; int has_new_target = 0; - if (target > 0 && target <= INF_MAX_NPCS) { - int npc_idx = target - 1; - if (s->npcs[npc_idx].active && s->npcs[npc_idx].death_ticks == 0 && + 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); has_new_target = 1; - /* tagging: redirect NPC aggro from shield/zuk to player */ - if (s->npcs[npc_idx].aggro_target != -1) { - s->npcs[npc_idx].aggro_target = -1; - s->npcs[npc_idx].stun_timer = 2; /* 2-tick delay on aggro switch */ - } } } /* explicit movement (ground click or RL move) cancels attack target, @@ -2352,6 +2352,12 @@ static void inf_tick_player(InfernoState* s, const int* actions) { s->player.attack_timer = ls->attack_speed; + /* tagging: redirect NPC aggro from shield/zuk to player */ + if (target_npc->aggro_target != -1) { + target_npc->aggro_target = -1; + target_npc->stun_timer = 2; /* 2-tick delay on aggro switch */ + } + /* player projectile event for renderer */ s->player_attacked_this_tick = 1; s->player_attack_npc_idx = s->interaction.target_slot; @@ -2377,6 +2383,7 @@ static float inf_compute_reward(InfernoState* s) { /* accumulate diagnostic stats BEFORE terminal check so the killing blow's damage is counted in total_damage_received */ 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; if (s->episode_over) @@ -2385,18 +2392,32 @@ static float inf_compute_reward(InfernoState* s) { float r = 0.0f; /* survival: per-tick bonus for staying alive */ - if (s->wave >= 68) - r += 0.001f; + //if (s->wave >= 68) + // r += 0.001f; /* shield positioning: strong signal for the core Zuk mechanic. this is THE thing we need the agent to learn first. */ - if (s->behind_shield_this_tick) - r += 0.005f; - + //if (s->behind_shield_this_tick) + // r += 0.005f; + /* Specialized damage reward for Zuk wave: + If Zuk healers are alive, ONLY reward damage to them. + Otherwise, reward all damage. */ + int zuk_healers_alive = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_HEALER_ZUK && s->npcs[i].death_ticks == 0) { + zuk_healers_alive = 1; + break; + } + } - if (s->damage_dealt_this_tick > 0.0f) - r += 0.01f * s->damage_dealt_this_tick; + if (zuk_healers_alive) { + if (s->damage_zuk_healers_this_tick > 0.0f) + r += 0.001f * s->damage_zuk_healers_this_tick; + } else { + if (s->damage_dealt_this_tick > 0.0f) + r += 0.001f * s->damage_dealt_this_tick; + } return r; } @@ -2412,6 +2433,7 @@ static void inf_step(EncounterState* state, const int* actions) { /* clear per-tick state */ s->reward = 0.0f; s->damage_dealt_this_tick = 0.0f; + s->damage_zuk_healers_this_tick = 0.0f; s->damage_received_this_tick = 0.0f; s->prayer_correct_this_tick = 0; s->off_prayer_hits_this_tick = 0; @@ -2462,11 +2484,15 @@ static void inf_step(EncounterState* state, const int* actions) { 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) { + if (s->npcs[i].type == INF_NPC_HEALER_ZUK) { + s->damage_zuk_healers_this_tick += (s->damage_dealt_this_tick - dmg_before); + } s->npcs[i].hit_spell_type = spell; inf_apply_npc_death(s, i); } @@ -2551,8 +2577,7 @@ static void inf_step(EncounterState* state, const int* actions) { if (actions[h] == 0) s->action_noop_count[h]++; } - inf_compute_reward(s); - s->reward = 0.0f; + s->reward = inf_compute_reward(s); s->episode_return += s->reward; /* check player death */ @@ -2608,7 +2633,7 @@ static void inf_step(EncounterState* state, const int* actions) { /* ======================================================================== */ /* obs layout: 49 player + 12 pillar + 33*32 NPC + 5*8 pending hits = 1157 */ -#define INF_PLAYER_OBS_SIZE 52 +#define INF_PLAYER_OBS_SIZE 49 #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) @@ -2683,8 +2708,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { int min_timer = 999; int min_style = 0; int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; - int correct_style_next_tick = -1; - + /* 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]; @@ -2700,8 +2724,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { if (ph->attack_style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; } if (t == 1) { - correct_style_next_tick = ph->attack_style; - } + } } } @@ -2746,10 +2769,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; } - if (t == 1) { - correct_style_next_tick = style; - } - } + } int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; @@ -2758,9 +2778,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = (min_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; obs[i++] = (float)conflict_count / 3.0f; - obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; - obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; - obs[i++] = 0.0f; // (correct_style_next_tick == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + /* removed oracle prayer obs */ } /* Zuk-phase features (10 features: 1 flag + 9 Zuk-specific) */ @@ -2843,6 +2861,9 @@ static void inf_write_obs(EncounterState* state, float* obs) { } } } + 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]; @@ -2991,10 +3012,15 @@ static void inf_write_mask(EncounterState* state, float* mask) { mask[offset++] = (s->player.prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; - /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive (not dying) */ + /* HEAD_TARGET (INF_OBS_NPCS+1): none always valid, slot valid only if mapped NPC is alive */ mask[offset++] = 1.0f; /* no target */ - for (int n = 0; n < INF_MAX_NPCS; n++) { - mask[offset++] = (s->npcs[n].active && s->npcs[n].death_ticks == 0) ? 1.0f : 0.0f; + for (int n = 0; n < INF_OBS_NPCS; n++) { + int idx = s->current_obs_slots[n]; + if (idx >= 0 && s->npcs[idx].active && s->npcs[idx].death_ticks == 0 && s->npcs[idx].type != INF_NPC_ZUK_SHIELD) { + mask[offset++] = 1.0f; + } else { + mask[offset++] = 0.0f; + } } /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ @@ -3213,6 +3239,7 @@ static void* inf_get_log(EncounterState* state) { 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; @@ -3436,7 +3463,24 @@ static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterSta encounter_translate_movement(hi, actions, INF_HEAD_MOVE, inf_get_player_for_input, state); encounter_translate_prayer(hi, actions, INF_HEAD_PRAYER); - encounter_translate_target(hi, actions, INF_HEAD_TARGET); + 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 = -1; + for (int j = 0; j < INF_OBS_NPCS; j++) { + if (s->current_obs_slots[j] == hi->pending_target_idx) { + found_slot = j; + break; + } + } + if (found_slot >= 0) { + 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; diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 00483c33e6..c4020a0bd4 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -791,6 +791,7 @@ typedef struct { /* 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 zuk_healer_damage; /* total damage dealt to Zuk healers */ /* action noop rates per head (0=move,1=prayer,2=target,3=gear,4=eat,5=pot,6=spell,7=spec) */ float noop_move; float noop_prayer; diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 16025b7c15..c04b14ebaf 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -137,6 +137,7 @@ void c_step(Env* env) { 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.wins += (s->winner == 0) ? 1.0f : 0.0f; env->log.wave += (float)s->wave; @@ -523,6 +524,8 @@ void my_log(Log* log, Dict* out) { 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, "zuk_healer_damage", log->zuk_healer_damage); + dict_set(out, "deaths_to_jad", log->killed_by_type[INF_NPC_JAD] / log->n); //dict_set(out, "noop_move", log->noop_move); //dict_set(out, "noop_prayer", log->noop_prayer); //dict_set(out, "noop_target", log->noop_target); From 91fbe223f1f18d085ca72b49c64b062998cee1a5 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Thu, 16 Apr 2026 20:47:50 +0300 Subject: [PATCH 26/60] inferno: fix shield barrage, mager nibbler resurrect, eval render reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mask out INF_NPC_ZUK_SHIELD in HEAD_TARGET so the policy can't pick it, and skip shield + dying NPCs in the barrage AoE builder so nothing chips the invulnerable shield's HP - exclude INF_NPC_NIBBLER from inf_store_dead_mob so mager can't resurrect nibblers (matches InfernoTrainer's npcDied registrations) - puffer eval: c_step now sets pending_render_reset on terminal and c_render consumes it to clear history, in-flight effects, mark the inv grid dirty, and re-snap sub-tile positions — mirrors osrs_visual.c so potions replenish visually and no dead-body artifact leaks into the next episode --- ocean/osrs/encounters/encounter_inferno.h | 20 +++++++++++++---- ocean/osrs_inferno/binding.c | 27 +++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 335908bbd9..3dd6901b8e 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -720,8 +720,11 @@ static inline void inf_invalidate_los_cache(InfernoState* s) { static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { if (s->dead_mob_count >= INF_MAX_DEAD_MOBS) return; - /* only store resurrectable types (not healers, not shield, not jad/zuk) */ - if (npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || + /* only store resurrectable types. matches InfernoTrainer's npcDied() + registrations: bat, blob parent, meleer, ranger, mager. nibblers, + healers, shield, jad, zuk do NOT register — excluded here. */ + if (npc->type == INF_NPC_NIBBLER || + npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_ZUK || npc->type == INF_NPC_JAD) return; @@ -2230,6 +2233,10 @@ static void inf_tick_player(InfernoState* s, const int* actions) { } 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, @@ -2865,10 +2872,15 @@ static void inf_write_mask(EncounterState* state, float* mask) { mask[offset++] = (s->player.prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; - /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive (not dying) */ + /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive + (not dying) and not the Zuk shield. the shield is invulnerable in OSRS + and must never be a player target — mask it out at the source. */ mask[offset++] = 1.0f; /* no target */ for (int n = 0; n < INF_MAX_NPCS; n++) { - mask[offset++] = (s->npcs[n].active && s->npcs[n].death_ticks == 0) ? 1.0f : 0.0f; + int valid = s->npcs[n].active + && s->npcs[n].death_ticks == 0 + && s->npcs[n].type != INF_NPC_ZUK_SHIELD; + mask[offset++] = valid ? 1.0f : 0.0f; } /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 590b79fa40..4149438017 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -56,6 +56,10 @@ typedef struct { 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; @@ -240,6 +244,10 @@ void c_step(Env* env) { 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; } } @@ -317,6 +325,25 @@ void c_render(Env* env) { 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); + rc->gui.inv_grid_dirty = 1; + 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. */ From 8f30e21706304b2fd67836ce8278ec5fa04e4a6e Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Thu, 16 Apr 2026 22:45:57 +0300 Subject: [PATCH 27/60] docs: use build.sh instead of setup.py in osrs tools README --- ocean/osrs/tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ocean/osrs/tools/README.md b/ocean/osrs/tools/README.md index d783b99988..173d35620a 100644 --- a/ocean/osrs/tools/README.md +++ b/ocean/osrs/tools/README.md @@ -101,7 +101,7 @@ the header provides: ```bash cd ocean/osrs && make visual # visual binary -cd ../../.. && python setup.py build_osrs_my_encounter --force # training env +cd ../../.. && ./build.sh osrs_my_encounter # training env ``` ## manifest format reference From 214847ce1263e47eec954d710a3f0ae6154c35c2 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Fri, 17 Apr 2026 08:34:28 +0300 Subject: [PATCH 28/60] inferno: healers heal once before tag, rapid BP/tbow speed, meteor healer sfx zuk healers now land their first heal on Zuk before the agent can tag them. aggro switches were happening on player FIRE tick, so healers cleared aggro before ever entering the heal branch. moved the tag to projectile LAND tick to match osrs-sdk Unit.ts:645 shouldChangeAggro (called after damage applies). blowpipe and twisted bow attack_speed now reduced by 1 in inferno loadouts to match rapid stance. equipment.json stores the longrange value (BP 3, tbow 6); rapid is 2 and 5 respectively per osrs-sdk Blowpipe.ts:79-84 and TwistedBow.ts:70-75. without this the BP lost its 2t advantage and the agent underused it. healer projectile switched from INFERNO_ZUK_PROJECTILE to INFERNO_ZEK_PROJECTILE so it's visually distinct from Zuk's fireball (closer meteor shape). InfernoTrainer uses a custom tekton_meteor model; proper OSRS healer spotanim needs a manifest update + re-export. --- ocean/osrs/encounters/encounter_inferno.h | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index f06bbd80a6..5e519573d0 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -846,6 +846,12 @@ static void inf_reset(EncounterState* state, uint32_t seed) { ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + /* rapid stance reduces attack_speed by 1 vs accurate/longrange. equipment.json + stores the longrange (base) value — blowpipe 3 → 2, tbow 6 → 5. without + this, blowpipe loses its 2t advantage and agent underuses it. + ref: osrs-sdk Blowpipe.ts:79-84, TwistedBow.ts:70-75. */ + s->loadout_stats[INF_GEAR_TBOW].attack_speed -= 1; + s->loadout_stats[INF_GEAR_BP].attack_speed -= 1; /* spawn position depends on wave */ int is_zuk_wave = (saved_start >= 68); @@ -2376,12 +2382,6 @@ static void inf_tick_player(InfernoState* s, const int* actions) { s->player.attack_timer = ls->attack_speed; - /* tagging: redirect NPC aggro from shield/zuk to player */ - if (target_npc->aggro_target != -1) { - target_npc->aggro_target = -1; - target_npc->stun_timer = 2; /* 2-tick delay on aggro switch */ - } - /* player projectile event for renderer */ s->player_attacked_this_tick = 1; s->player_attack_npc_idx = s->interaction.target_slot; @@ -2523,6 +2523,14 @@ static void inf_step(EncounterState* state, const int* actions) { s->damage_zuk_healers_this_tick += (s->damage_dealt_this_tick - dmg_before); } 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) { + 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); } } @@ -3366,7 +3374,11 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) ? INF_GFX_448_MODEL : INF_GFX_447_MODEL; break; case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; - case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; /* same model as zuk fireball, differentiated by arc */ + /* InfernoTrainer uses a tekton_meteor-style model for Jal-MejJak. + OSRS cache doesn't have a dedicated healer spotanim exported here, + so we reuse INFERNO_ZEK_PROJECTILE (orange ball, closest meteor + shape) until a proper healer spotanim is added to the manifest. */ + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1376_MODEL; break; default: break; } @@ -3427,7 +3439,7 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* 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_1375_MODEL); + 4 * 30, 96, 64, 16, 3.0f, 0, 1, 1, INF_GFX_1376_MODEL); spark->visual_emitted = 1; } From 3eacab0291efc574e34b24841393e0d5b1fb84c5 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Fri, 17 Apr 2026 08:55:18 +0300 Subject: [PATCH 29/60] osrs: FightStyle stance as first-class combat concept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FightStyle was only the 4 melee stances; ranged/magic stances lived as raw int style_bonus passed around per-call, losing the why (is 0 rapid or autocast? one needs -1 attack_speed, the other doesn't). each caller re-derived the mapping and most forgot the speed piece — which is why blowpipe was firing every 3 ticks instead of 2 and losing its DPS edge. - extend FightStyle with RAPID, LONGRANGE, AUTOCAST, DEFENSIVE_AUTOCAST - add osrs_stance_{att,str,def}_bonus + osrs_stance_{speed,range}_mod helpers in osrs_combat.h as single source of truth per osrs wiki "Combat Options" - encounter_compute_loadout_stats takes FightStyle instead of int style_bonus, derives att/str/def bonuses and applies rapid/longrange speed+range mods - EncounterLoadoutStats stores the stance so encounter_update_loadout_level re-derives consistently after brew drain / potion boost - pvp calculate_effective_{attack,strength,defence} collapse their open-coded switches to use the shared helpers - inferno: mage AUTOCAST, tbow/bp RAPID (restores BP 2t, tbow 5t). drop the local -1 patches - zulrah: mage ACCURATE (trident/Eye of Ayak are powered staves, +3 magic per wiki), range RAPID - fix test assertions that encoded the old behavior where style_bonus=3 was double-counted on both att and str (aggressive wrongly gave +3 att, accurate wrongly gave +3 str). real osrs separates them. ref: osrs wiki "Combat Options", .refs/osrs-dps-calc Equipment.ts:245-270, .refs/osrs-sdk Blowpipe.ts:79-84, TwistedBow.ts:70-75 tests: combat_math 155/155, item_effects 164/164, player_combat 41/41, damage/consumables/interaction/inventory/special_attacks/bolt_procs all pass. test_collision build failure pre-existed (unrelated). --- ocean/osrs/encounters/encounter_inferno.h | 16 ++--- ocean/osrs/encounters/encounter_zulrah.h | 6 +- ocean/osrs/osrs_combat.h | 58 +++++++++++++++++ ocean/osrs/osrs_encounter.h | 51 ++++++++------- ocean/osrs/osrs_pvp_combat.h | 19 ++---- ocean/osrs/osrs_types.h | 19 ++++-- ocean/osrs/tests/test_combat_math.c | 76 ++++++++++++----------- ocean/osrs/tests/test_item_effects.c | 67 +++++++++++--------- 8 files changed, 193 insertions(+), 119 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 5e519573d0..b4410ed8ee 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -839,19 +839,15 @@ static void inf_reset(EncounterState* state, uint32_t seed) { 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) */ + /* 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). */ encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, - ENCOUNTER_PRAYER_AUGURY, 99, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + ENCOUNTER_PRAYER_AUGURY, 99, FIGHT_STYLE_AUTOCAST, 30, &s->loadout_stats[INF_GEAR_MAGE]); encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, - ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + ENCOUNTER_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_TBOW]); encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, - ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_BP]); - /* rapid stance reduces attack_speed by 1 vs accurate/longrange. equipment.json - stores the longrange (base) value — blowpipe 3 → 2, tbow 6 → 5. without - this, blowpipe loses its 2t advantage and agent underuses it. - ref: osrs-sdk Blowpipe.ts:79-84, TwistedBow.ts:70-75. */ - s->loadout_stats[INF_GEAR_TBOW].attack_speed -= 1; - s->loadout_stats[INF_GEAR_BP].attack_speed -= 1; + ENCOUNTER_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_BP]); /* spawn position depends on wave */ int is_zuk_wave = (saved_start >= 68); diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index 3e5d7843b2..9e68b2b883 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -1788,10 +1788,12 @@ static void zul_reset(EncounterState* state, uint32_t seed) { /* derive combat stats from ITEM_DATABASE */ EncounterPrayer mage_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_AUGURY : ENCOUNTER_PRAYER_NONE; EncounterPrayer range_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_RIGOUR : ENCOUNTER_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, 0, 30, &s->mage_stats); + 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, 0, 0, &s->range_stats); + range_prayer, 99, FIGHT_STYLE_RAPID, 0, &s->range_stats); int r = s->player.equipped[GEAR_SLOT_RING]; s->player.recoil_charges = (r == ITEM_RING_OF_RECOIL || r == ITEM_RING_OF_SUFFERING_RI) ? RECOIL_MAX_CHARGES : 0; diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h index f23bd1d31e..a98de98348 100644 --- a/ocean/osrs/osrs_combat.h +++ b/ocean/osrs/osrs_combat.h @@ -355,6 +355,64 @@ static inline int osrs_player_eff_level(int base_level, float prayer_mult, int s 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) { diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index dffc4f5409..27dab4f384 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -1002,15 +1002,15 @@ typedef struct { int strength_bonus; /* ranged_strength, magic_damage %, or melee_strength */ int eff_level; /* effective attack level (floor(base*prayer) + style + 8) */ int max_hit; /* base max hit (before tbow/set bonuses) */ - int attack_speed; /* ticks between attacks */ - int attack_range; /* max chebyshev distance */ + 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 style_bonus; int spell_base_damage; } EncounterLoadoutStats; @@ -1022,15 +1022,16 @@ typedef enum { ENCOUNTER_PRAYER_PIETY, /* +20% melee attack, +23% melee strength, +25% defence */ } EncounterPrayer; -/** derive all combat stats from a loadout array + prayer + style. +/** 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. + 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 prayer prayer enum for level multiplier @param base_level base combat level (usually 99) - @param style_bonus +0 for rapid/autocast, +3 for accurate, +1 for controlled + @param 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 */ static inline void encounter_compute_loadout_stats( @@ -1038,12 +1039,13 @@ static inline void encounter_compute_loadout_stats( AttackStyle style, EncounterPrayer prayer, int base_level, - int style_bonus, + 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; @@ -1054,8 +1056,11 @@ static inline void encounter_compute_loadout_stats( out->def_crush = eb.defence_crush; out->def_magic = eb.defence_magic; out->def_ranged = eb.defence_ranged; - out->attack_speed = eb.attack_speed; - out->attack_range = eb.attack_range; + /* 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) { @@ -1091,21 +1096,23 @@ static inline void encounter_compute_loadout_stats( /* store for dynamic recomputation after brew drain / potion boost */ out->att_prayer_mult = att_prayer_mult; out->str_prayer_mult = str_prayer_mult; - out->style_bonus = style_bonus; out->spell_base_damage = spell_base_damage; - /* effective attack level: floor(base * prayer_mult) + style_bonus + 8. - magic uses +9 (OSRS invisible +1 boost) instead of +style_bonus+8. - ref: OSRS wiki "magic effective level = floor(base * prayer) + 9". */ + 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) + 9; + out->eff_level = (int)(base_level * att_prayer_mult) + att_stance_bonus + 9; } else { - out->eff_level = (int)(base_level * att_prayer_mult) + style_bonus + 8; + out->eff_level = (int)(base_level * att_prayer_mult) + att_stance_bonus + 8; } - /* effective strength level (for max hit): floor(base * str_prayer_mult) + style_bonus + 8 - note: style_bonus for strength is typically 0 for rapid/autocast, +3 for aggressive */ - int eff_str_level = (int)(base_level * str_prayer_mult) + style_bonus + 8; + /* 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 = 1.0f; @@ -1142,15 +1149,17 @@ static inline void encounter_compute_loadout_stats( static inline void encounter_update_loadout_level( EncounterLoadoutStats* ls, int current_att_level, int current_str_level ) { + 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 * ls->att_prayer_mult) + 9; + ls->eff_level = (int)(current_att_level * ls->att_prayer_mult) + att_stance_bonus + 9; /* augury +4% magic damage. att_prayer_mult == 1.25 iff augury. */ float magic_dmg_mult = (ls->att_prayer_mult > 1.24f) ? 1.04f : 1.0f; 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 * ls->att_prayer_mult) + ls->style_bonus + 8; - int eff_str = (int)(current_str_level * ls->str_prayer_mult) + ls->style_bonus + 8; + ls->eff_level = (int)(current_att_level * ls->att_prayer_mult) + att_stance_bonus + 8; + int eff_str = (int)(current_str_level * ls->str_prayer_mult) + str_stance_bonus + 8; ls->max_hit = (int)(0.5 + eff_str * (ls->strength_bonus + 64) / 640.0); } } diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h index 61583d4d08..f36280ef88 100644 --- a/ocean/osrs/osrs_pvp_combat.h +++ b/ocean/osrs/osrs_pvp_combat.h @@ -226,7 +226,6 @@ static inline float get_defence_prayer_mult(Player* p) { static int calculate_effective_attack(Player* p, AttackStyle style) { int base_level; float prayer_mult = 1.0f; - int style_bonus = 0; switch (style) { case ATTACK_STYLE_MELEE: @@ -248,12 +247,7 @@ static int calculate_effective_attack(Player* p, AttackStyle style) { return 0; } - if (style == ATTACK_STYLE_MELEE) { - if (p->fight_style == FIGHT_STYLE_ACCURATE) style_bonus = 3; - else if (p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; - } else if (style == ATTACK_STYLE_RANGED) { - if (p->fight_style == FIGHT_STYLE_ACCURATE) style_bonus = 3; - } + 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) @@ -264,7 +258,6 @@ static int calculate_effective_attack(Player* p, AttackStyle style) { static int calculate_effective_strength(Player* p, AttackStyle style) { int base_level; float prayer_mult = 1.0f; - int style_bonus = 0; switch (style) { case ATTACK_STYLE_MELEE: @@ -284,8 +277,9 @@ static int calculate_effective_strength(Player* p, AttackStyle style) { return 0; } - if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_AGGRESSIVE) style_bonus = 3; - else if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; + /* 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); } @@ -293,10 +287,7 @@ static int calculate_effective_strength(Player* p, AttackStyle style) { static int calculate_effective_defence(Player* p, AttackStyle incoming_style) { int base_level = p->current_defence; float prayer_mult = get_defence_prayer_mult(p); - int style_bonus = 0; - - if (p->fight_style == FIGHT_STYLE_DEFENSIVE) style_bonus = 3; - else if (p->fight_style == FIGHT_STYLE_CONTROLLED) style_bonus = 1; + 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. diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index f52506df96..fd2ea701b3 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -292,11 +292,22 @@ typedef enum { 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 come first for backward compatibility with existing code + that cast `0` to mean ACCURATE. */ typedef enum { - FIGHT_STYLE_ACCURATE = 0, - FIGHT_STYLE_AGGRESSIVE, - FIGHT_STYLE_CONTROLLED, - FIGHT_STYLE_DEFENSIVE + 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 { diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index 811be5592f..8830fcc7cc 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -556,8 +556,8 @@ static void test_loadout_melee_no_prayer(void) { loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, - 99, /* base_level */ - 3, /* style_bonus (aggressive) */ + 99, + FIGHT_STYLE_AGGRESSIVE, 0, /* spell_base_damage */ &stats ); @@ -568,17 +568,18 @@ static void test_loadout_melee_no_prayer(void) { /* rapier: melee_strength = 89 */ ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 89); - /* eff_level = floor(99 * 1.0) + 3 + 8 = 110 */ - ASSERT_INT_EQ("eff_level", stats.eff_level, 110); + /* 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) = 110 * 158 = 17380 */ + /* 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, 17380); + ASSERT_INT_EQ("attack_roll", att_roll, 16906); ASSERT_INT_EQ("attack_speed", stats.attack_speed, 4); } @@ -595,25 +596,26 @@ static void test_loadout_melee_piety(void) { loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, - 99, /* base_level */ - 3, /* style_bonus (aggressive) */ + 99, + FIGHT_STYLE_AGGRESSIVE, 0, /* spell_base_damage */ &stats ); - /* piety: att_mult=1.20, str_mult=1.23 */ + /* 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) + 3 + 8 = 118 + 11 = 129 */ - ASSERT_INT_EQ("eff_level", stats.eff_level, 129); + /* 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 = 129 * (94 + 64) = 129 * 158 = 20382 */ + /* 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, 20382); + ASSERT_INT_EQ("attack_roll", att_roll, 19908); } static void test_loadout_ranged_rigour(void) { @@ -629,13 +631,13 @@ static void test_loadout_ranged_rigour(void) { loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_RIGOUR, - 99, /* base_level */ - 0, /* style_bonus (rapid = 0) */ + 99, + FIGHT_STYLE_RAPID, 0, /* spell_base_damage */ &stats ); - /* rigour: att_mult=1.20, str_mult=1.23 */ + /* 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 */ @@ -669,8 +671,8 @@ static void test_loadout_magic_augury(void) { loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, /* base_level */ - 0, /* style_bonus (autocast = 0) */ + 99, + FIGHT_STYLE_AUTOCAST, 30, /* spell_base_damage (ice barrage = 30) */ &stats ); @@ -709,7 +711,7 @@ static void test_loadout_magic_no_prayer(void) { ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, 99, - 0, /* autocast */ + FIGHT_STYLE_AUTOCAST, 30, /* ice barrage */ &stats ); @@ -746,7 +748,7 @@ static void test_loadout_full_ranged(void) { ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_RIGOUR, 99, - 0, /* rapid */ + FIGHT_STYLE_RAPID, 0, &stats ); @@ -787,17 +789,17 @@ static void test_update_loadout_level(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, - 99, 3, 0, &stats); + 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); - /* base: eff=129, max=32 (tested above) */ - ASSERT_INT_EQ("base eff", stats.eff_level, 129); + /* 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, 90, 90); - /* eff_att = floor(90 * 1.20) + 3 + 8 = 108 + 11 = 119 */ - ASSERT_INT_EQ("drained eff", stats.eff_level, 119); + /* 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...) */ @@ -806,7 +808,7 @@ static void test_update_loadout_level(void) { /* restore back to 99 */ encounter_update_loadout_level(&stats, 99, 99); - ASSERT_INT_EQ("restored eff", stats.eff_level, 129); + 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) */ @@ -814,14 +816,14 @@ static void test_update_loadout_level(void) { loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, 0, 30, &stats); + 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, 80, 80); - /* eff = floor(80 * 1.25) + 9 = 100 + 9 = 109 */ + /* 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); @@ -997,7 +999,7 @@ static void test_loadout_def_bonuses(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, - 99, 0, 0, &stats); + 99, FIGHT_STYLE_ACCURATE, 0, &stats); /* verify defence bonuses sum correctly from ITEM_DATABASE. exact values depend on item stats — verify against DB directly */ @@ -1036,28 +1038,28 @@ static void test_edge_cases(void) { uint8_t loadout[NUM_GEAR_SLOTS]; clear_loadout(loadout); - /* melee, level 1, no gear, no prayer */ + /* melee, level 1, no gear, no prayer, accurate stance (+3 att) */ EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, - 1, 0, 0, &stats); + 1, FIGHT_STYLE_ACCURATE, 0, &stats); - /* eff = floor(1*1.0) + 0 + 8 = 9 */ - ASSERT_INT_EQ("lv1 melee eff", stats.eff_level, 9); + /* eff = floor(1*1.0) + 3 + 8 = 12 (accurate +3 att) */ + ASSERT_INT_EQ("lv1 melee eff", stats.eff_level, 12); - /* eff_str = 9, str_bonus = 0 */ + /* 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 */ + /* magic, level 1, no gear, barrage, autocast (no invisible bonus) */ encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, - 1, 0, 30, &stats); + 1, FIGHT_STYLE_AUTOCAST, 30, &stats); - /* eff = floor(1*1.0) + 9 = 10 */ + /* 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 */ diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index 34966f88d9..eb3a64a9a1 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -425,7 +425,7 @@ static void test_player_att_roll_full_mage(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, 0 /* autocast */, 30 /* ice barrage */, &stats); + 99, FIGHT_STYLE_AUTOCAST, 30 /* ice barrage */, &stats); /* sum attack_magic: kodai(28) + hat(8) + top(35) + bottom(26) + occult(12) + @@ -467,7 +467,7 @@ static void test_player_att_roll_melee_with_defender(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, - 99, 3 /* aggressive */, 0, &stats); + 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); /* best melee attack bonus: stab: rapier(94) + defender(25) + cape(4) = 123 @@ -479,17 +479,18 @@ static void test_player_att_roll_melee_with_defender(void) { /* melee_strength: rapier(89) + defender(6) + cape(8) = 103 */ ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 103); - /* eff_level = floor(99 * 1.20) + 3 + 8 = 118 + 11 = 129 */ - ASSERT_INT_EQ("eff_level", stats.eff_level, 129); + /* 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 = 129 * (123 + 64) = 129 * 187 = 24123 */ + /* 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, 24123); + ASSERT_INT_EQ("attack_roll", att_roll, 23562); } /* ======================================================================== */ @@ -508,14 +509,15 @@ static void test_player_att_roll_ranged_blowpipe(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_RIGOUR, - 99, 0 /* rapid */, 0, &stats); + 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=3 (rapid) */ - ASSERT_INT_EQ("attack_speed", stats.attack_speed, 3); + /* 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); @@ -542,39 +544,40 @@ static void test_loadout_empty_all_styles(void) { uint8_t loadout[NUM_GEAR_SLOTS]; clear_loadout(loadout); - /* melee, level 99, no prayer, no stance */ + /* melee, level 99, no prayer, accurate stance (+3 att, +0 str) */ EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, - 99, 0, 0, &stats); + 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 + 0 + 8 = 107 */ - ASSERT_INT_EQ("empty melee eff", stats.eff_level, 107); - /* max_hit = floor(0.5 + 107 * 64 / 640) = floor(0.5 + 10.7) = 11 */ + /* 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 */ + /* ranged, no prayer, rapid stance (no level bonus, -1 speed) */ encounter_compute_loadout_stats( loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_NONE, - 99, 0, 0, &stats); + 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); - /* same eff_level formula: max_hit = floor(0.5 + 107*64/640) = 11 */ + /* 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 */ + /* magic with ice barrage, autocast (no invisible bonus per wiki) */ encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, - 99, 0, 30, &stats); + 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 + 9 = 108 (invisible +9 boost) */ + /* 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); @@ -607,7 +610,7 @@ static void test_loadout_all_slots_filled(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, 0, 30, &stats); + 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); @@ -651,7 +654,7 @@ static void test_loadout_two_handed_weapon(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, - 99, 3 /* aggressive */, 0, &stats); + 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); @@ -662,8 +665,9 @@ static void test_loadout_two_handed_weapon(void) { /* attack_speed = 6 (AGS is slow) */ ASSERT_INT_EQ("2h attack_speed", stats.attack_speed, 6); - /* eff_level = floor(99 * 1.20) + 3 + 8 = 129 */ - ASSERT_INT_EQ("2h eff_level", stats.eff_level, 129); + /* 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) @@ -675,9 +679,9 @@ static void test_loadout_two_handed_weapon(void) { ASSERT_INT_EQ("2h def_magic", stats.def_magic, 0); ASSERT_INT_EQ("2h def_ranged", stats.def_ranged, 0); - /* attack_roll = 129 * (132+64) = 129 * 196 = 25284 */ + /* 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, 25284); + ASSERT_INT_EQ("2h attack_roll", att_roll, 24696); } /* ======================================================================== */ @@ -742,7 +746,7 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, 0, 30, &stats); + 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 */ @@ -764,7 +768,7 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, - 99, 3, 0, &stats); + 99, FIGHT_STYLE_ACCURATE, 0, &stats); /* rapier(94)+defender(25) stab = 119 best */ int player_att = stats.eff_level * (stats.attack_bonus + 64); @@ -784,9 +788,10 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, - 99, 0, 30, &stats); + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); - /* eff=132, bonus=0, att_roll = 132*64 = 8448 */ + /* 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); @@ -840,7 +845,7 @@ static void test_loadout_defence_into_def_roll(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, - 99, 0, 0, &stats); + 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 */ From 7426c33847c27db2665628fcec9b6c56476b1e3a Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Fri, 17 Apr 2026 09:29:45 +0300 Subject: [PATCH 30/60] inferno: remove oracle prayer scaffolding, agent controls overhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inf_get_oracle_prayer was picking the correct protection prayer each tick and overwriting s->player.prayer regardless of the agent's INF_HEAD_PRAYER action, collapsing the prayer-switching challenge that's core to Inferno. source: joseph's f7072fc3e "Wave 33 in 2:17" benchmark scaffold, merged in via 21f54face and never removed. pretick now applies actions[INF_HEAD_PRAYER] via the shared encounter_apply_prayer_action() helper, so the agent's choice actually takes effect. offensive +24 flat drain left in place for now — that's separate scaffolding to address next. also cleaned up stale "Pre-determine style for oracle" comments on the blob scan paths (behavior unchanged: blob reads player prayer at scan, commits to opposite style for fire) and removed the dead /* removed oracle prayer obs */ marker. --- ocean/osrs/encounters/encounter_inferno.h | 58 +++-------------------- 1 file changed, 6 insertions(+), 52 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b4410ed8ee..b54d4f1b8f 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1277,7 +1277,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { has_los_now && !npc->had_los_last_tick) { npc->blob_scanned_prayer = (int)s->player.prayer; - /* Pre-determine style for oracle */ + /* 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; @@ -1452,12 +1452,11 @@ static void inf_npc_attack(InfernoState* s, int idx) { ref: InfernoTrainer JalAk.ts attackIfPossible() */ if (npc->type == INF_NPC_BLOB) { if (npc->blob_scanned_prayer < 0) { - /* no pending scan → start scan phase */ + /* no pending scan → start scan phase: read prayer, commit to opposite style */ npc->blob_scanned_prayer = (int)s->player.prayer; - /* Pre-determine style for oracle */ - 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; + 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; @@ -2016,51 +2015,8 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { } } -static OverheadPrayer inf_get_oracle_prayer(InfernoState* s) { - int correct_style = -1; - for (int h = 0; h < s->player_pending_hit_count; h++) { - EncounterPendingHit* ph = &s->player_pending_hits[h]; - if (ph->check_prayer && ph->ticks_remaining == 1) { - correct_style = ph->attack_style; - break; - } - } - if (correct_style == -1) { - 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_JAD || 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; - if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; - - int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, npc->size); - if (dist == 0 || dist > st->attack_range) continue; - - int t = npc->attack_timer; - if (t == 0) t = 1; - if (t == 1) { - int style = npc->attack_style; - - if (st->can_melee && dist == 1) { - style = ATTACK_STYLE_MELEE; - } - correct_style = style; - break; - } - } - } - if (correct_style == ATTACK_STYLE_MELEE) return PRAYER_PROTECT_MELEE; - if (correct_style == ATTACK_STYLE_RANGED) return PRAYER_PROTECT_RANGED; - if (correct_style == ATTACK_STYLE_MAGIC) return PRAYER_PROTECT_MAGIC; - return s->player.prayer; -} - static void inf_player_pretick(InfernoState* s, const int* actions) { - s->player.prayer = inf_get_oracle_prayer(s); + encounter_apply_prayer_action(&s->player.prayer, actions[INF_HEAD_PRAYER]); int drain = encounter_prayer_drain_effect(s->player.prayer) + 24; encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, 0, &s->player.prayer_drain_counter, drain); @@ -2810,8 +2766,6 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; - - /* removed oracle prayer obs */ } /* Zuk-phase features (10 features: 1 flag + 9 Zuk-specific) */ From 5360c66ec1aa0367019f9a51c90e622549d9a212 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Fri, 17 Apr 2026 10:16:01 +0300 Subject: [PATCH 31/60] osrs: migrate prayers to toggle semantics + activation-tick drain skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rewrites the prayer action space across all three OSRS envs to match real OSRS click behavior. agent controls both overhead and offensive prayers via toggle actions, enabling prayer flicking for supply-efficient play. shared (osrs_encounter.h): - new 4/6-dim toggle encoding (ENCOUNTER_OVERHEAD_* / _OFFENSIVE_*) — no "off" action, click-active-prayer-to-disable like the real UI. NO_CHANGE for no-ops; toggle helpers return 1 on OFF→ON for drain skip. - encounter_drain_all_prayers() handles overhead + offensive in one call with per-slot activation-tick skip (wiki: "the game does not drain prayer for prayers on the tick they are activated" — what lets 1-tick flicking burn zero pp). pp=0 auto-clears both slots. - encounter_offensive_prayer_mults() / encounter_offensive_magic_dmg_mult() as single-source-of-truth prayer multiplier helpers. - EncounterPrayer enum removed; compute_loadout_stats + update_loadout_level now take OffensivePrayer. update is called after every prayer toggle so ls->eff_level / max_hit track runtime state. player struct (osrs_types.h): - prayer_just_activated, offensive_prayer_just_activated bits for the activation-tick drain skip mechanic. inferno: - new INF_HEAD_OFFENSIVE action head (4 dim). 9 heads total. - pretick: apply overhead + offensive toggles, recompute loadouts on prayer change, drain both slots. flat +24 scaffolding drain removed. - offensive prayer one-hot added to obs (3 new features). zulrah: - new ZUL_HEAD_OFFENSIVE action head (4 dim). 7 heads total. - zul_process_prayer takes both head actions, recomputes mage/range_stats on offensive toggle. - drain now charges both slots (previously charged 0 for offensive). - offensive prayer one-hot added to obs (3 new features). - heuristic policy updated to emit toggle actions. pvp: - new HEAD_OFFENSIVE action head (4 dim). 8 heads total. - HEAD_OVERHEAD now uses toggle encoding (6 dim). - auto-offensive-prayer assignment based on loadout/attack style removed. - opponent AI funnels overhead emissions through opp_emit_prayer() which maps OverheadAction → toggle given current state. - observation mask updated for both heads. tests: - test_combat_math + test_item_effects migrated to OffensivePrayer enum. - new test_prayer_flicking.c covers toggle semantics, activation-tick skip, 1-tick flick burning 0 pp, pp=0 auto-clear, lazy flick cost. all 25 cases pass. all existing test suites (combat_math, item_effects, bolt_procs, consumables, damage, interaction, inventory, player_combat, special_attacks) continue to pass. --- ocean/osrs/encounters/encounter_inferno.h | 86 ++++-- ocean/osrs/encounters/encounter_zulrah.h | 120 ++++++--- ocean/osrs/osrs_encounter.h | 307 ++++++++++++++-------- ocean/osrs/osrs_human_input.h | 45 ++-- ocean/osrs/osrs_pvp_actions.h | 140 ++++------ ocean/osrs/osrs_pvp_observations.h | 26 +- ocean/osrs/osrs_pvp_opponents.h | 92 ++++--- ocean/osrs/osrs_types.h | 18 +- ocean/osrs/tests/test_combat_math.c | 28 +- ocean/osrs/tests/test_item_effects.c | 24 +- ocean/osrs/tests/test_prayer_flicking.c | 188 +++++++++++++ 11 files changed, 728 insertions(+), 346 deletions(-) create mode 100644 ocean/osrs/tests/test_prayer_flicking.c diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b54d4f1b8f..5a819a0293 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -842,12 +842,15 @@ static void inf_reset(EncounterState* state, uint32_t seed) { /* 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, - ENCOUNTER_PRAYER_AUGURY, 99, FIGHT_STYLE_AUTOCAST, 30, &s->loadout_stats[INF_GEAR_MAGE]); + 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, - ENCOUNTER_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_TBOW]); + 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, - ENCOUNTER_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_BP]); + 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 >= 68); @@ -1950,18 +1953,21 @@ static void inf_tick_npcs(InfernoState* s) { /* player actions */ /* ======================================================================== */ -#define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ -#define INF_HEAD_PRAYER 1 /* 5: no_change, off, melee, range, mage (ENCOUNTER_PRAYER_DIM) */ -#define INF_HEAD_TARGET 2 /* INF_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_NUM_ACTION_HEADS 8 - -static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_OBS_NPCS+1, 5, 2, 4, 3, 2 }; -#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_OBS_NPCS+1 + 5 + 2 + 4 + 3 + 2) +#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) */ +#define INF_NUM_ACTION_HEADS 9 + +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 */ @@ -2016,10 +2022,22 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { } static void inf_player_pretick(InfernoState* s, const int* actions) { - encounter_apply_prayer_action(&s->player.prayer, actions[INF_HEAD_PRAYER]); - int drain = encounter_prayer_drain_effect(s->player.prayer) + 24; - encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, - 0, &s->player.prayer_drain_counter, drain); + /* 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). */ + if (encounter_apply_overhead_action(&s->player.prayer, actions[INF_HEAD_PRAYER])) { + s->player.prayer_just_activated = 1; + } + OffensivePrayer prev_offensive = s->player.offensive_prayer; + if (encounter_apply_offensive_action(&s->player.offensive_prayer, actions[INF_HEAD_OFFENSIVE])) { + s->player.offensive_prayer_just_activated = 1; + } + /* 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); + } + /* inferno loadouts have ~0 prayer bonus (armadyl/ancestral/torva); pass 0. */ + encounter_drain_all_prayers(&s->player, 0); } static void inf_tick_player(InfernoState* s, const int* actions) { @@ -2622,7 +2640,7 @@ static void inf_step(EncounterState* state, const int* actions) { /* ======================================================================== */ /* obs layout: 49 player + 12 pillar + 33*32 NPC + 5*8 pending hits = 1157 */ -#define INF_PLAYER_OBS_SIZE 49 +#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) @@ -2660,6 +2678,10 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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 / 32.0f; obs[i++] = (float)s->player.restore_doses / 40.0f; obs[i++] = (float)s->player.current_prayer / 99.0f; @@ -2992,12 +3014,16 @@ static void inf_write_mask(EncounterState* state, float* mask) { ? 1.0f : 0.0f; } - /* HEAD_PRAYER (5): 0=no change (always valid), 1-4=switch (mask out current) */ - mask[offset++] = 1.0f; /* no change — always valid */ - mask[offset++] = (s->player.prayer != PRAYER_NONE) ? 1.0f : 0.0f; - mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; - mask[offset++] = (s->player.prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; - mask[offset++] = (s->player.prayer != PRAYER_PROTECT_MAGIC) ? 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). */ @@ -3062,6 +3088,13 @@ static void inf_write_mask(EncounterState* state, float* mask) { 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; } /* ======================================================================== */ @@ -3451,6 +3484,7 @@ static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterSta 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) { diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index 9e68b2b883..3234566e3d 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -153,25 +153,27 @@ static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { /* observation and action space */ /* ======================================================================== */ -#define ZUL_NUM_OBS 81 -#define ZUL_NUM_ACTION_HEADS 6 +#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_PRAYER_DIM -#define ZUL_FOOD_DIM 3 /* none, shark, karambwan */ -#define ZUL_POTION_DIM 3 /* none, restore, antivenom */ -#define ZUL_SPEC_DIM 2 +#define ZUL_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_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_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 @@ -365,7 +367,7 @@ static const int ZUL_ROT_LENGTHS[ZUL_NUM_ROTATIONS] = { 11, 11, 12, 13 }; 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_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 */ @@ -1475,8 +1477,26 @@ static void zul_process_movement(ZulrahState* s) { 0, 0, 0, 0); } -static void zul_process_prayer(ZulrahState* s, int p) { - encounter_apply_prayer_action(&s->player.prayer, p); +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); + } + } } static void zul_process_food(ZulrahState* s, int a) { @@ -1562,6 +1582,10 @@ static void zul_write_obs(EncounterState* state, float* obs) { 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) */ @@ -1659,10 +1683,11 @@ static void zul_write_mask(EncounterState* state, float* mask) { mask[off] = 0.0f; off++; } - /* prayer: 0=no_change (always valid), 1=off (always valid), - 2-4=melee/ranged/magic (require prayer points) */ + /* 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_PRAYER_MELEE && s->player.current_prayer <= 0) + if (p >= ENCOUNTER_OVERHEAD_TOGGLE_MELEE && s->player.current_prayer <= 0) mask[off] = 0.0f; off++; } @@ -1699,6 +1724,13 @@ static void zul_write_mask(EncounterState* state, float* mask) { 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++; + } } /* ======================================================================== */ @@ -1786,8 +1818,8 @@ static void zul_reset(EncounterState* state, uint32_t seed) { 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 */ - EncounterPrayer mage_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_AUGURY : ENCOUNTER_PRAYER_NONE; - EncounterPrayer range_prayer = (s->gear_tier >= 1) ? ENCOUNTER_PRAYER_RIGOUR : ENCOUNTER_PRAYER_NONE; + 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, @@ -1846,7 +1878,7 @@ static void zul_step(EncounterState* state, const int* actions) { } /* prayer doesn't interrupt interactions */ - zul_process_prayer(s, actions[ZUL_HEAD_PRAYER]); + zul_process_prayer(s, actions[ZUL_HEAD_PRAYER], actions[ZUL_HEAD_OFFENSIVE]); /* spec toggle: arm/disarm (does NOT interrupt interaction) */ if (actions[ZUL_HEAD_SPEC] == 1) { @@ -1941,9 +1973,8 @@ static void zul_step(EncounterState* state, const int* actions) { /* venom */ zul_venom_tick(s); - /* prayer drain (shared OSRS formula) */ - encounter_drain_prayer(&s->player.current_prayer, &s->player.prayer, 0, - &s->player.prayer_drain_counter, encounter_prayer_drain_effect(s->player.prayer)); + /* prayer drain — both overhead and offensive, with activation-tick skip. */ + encounter_drain_all_prayers(&s->player, 0); if (s->player.current_hitpoints <= 0) { s->episode_over = 1; s->winner = 1; @@ -1967,12 +1998,38 @@ static void zul_heuristic_actions(ZulrahState* s, int* actions) { int hp = s->player.current_hitpoints; - /* prayer: match form. GREEN=ranged, BLUE=magic, RED=melee */ + /* 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: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_RANGED; break; - case ZUL_FORM_BLUE: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MAGIC; break; - case ZUL_FORM_RED: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MELEE; break; + 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; } } @@ -2206,6 +2263,7 @@ static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterSta 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) { diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 27dab4f384..8fe0c3eb31 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -13,9 +13,12 @@ * encounter_resolve_attack_target() match npc_slot to render entity index * EncounterOverlay visual overlay (hazards, projectiles, boss) * - * prayer: - * ENCOUNTER_PRAYER_* canonical 5-value prayer action encoding - * encounter_apply_prayer_action() apply prayer action to OverheadPrayer state + * 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) @@ -40,7 +43,7 @@ * * combat stats: * EncounterLoadoutStats derived stats (att bonus, max hit, eff level...) - * EncounterPrayer prayer multiplier enum + * Player.offensive_prayer runtime state, source of truth for prayer multipliers * encounter_compute_loadout_stats() derive all stats from ITEM_DATABASE + loadout * * hit delays: @@ -260,32 +263,81 @@ static inline void encounter_resolve_attack_target( } /* ======================================================================== */ -/* canonical prayer action encoding */ +/* 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) */ /* ======================================================================== */ -/* all encounters MUST use this encoding for the prayer action head. - 0 = no change (prayer persists from previous tick) - 1 = turn off prayer (PRAYER_NONE) - 2 = protect melee - 3 = protect ranged - 4 = protect magic - action dim = 5 for any encounter using this encoding. */ -#define ENCOUNTER_PRAYER_NO_CHANGE 0 -#define ENCOUNTER_PRAYER_OFF 1 -#define ENCOUNTER_PRAYER_MELEE 2 -#define ENCOUNTER_PRAYER_RANGED 3 -#define ENCOUNTER_PRAYER_MAGIC 4 -#define ENCOUNTER_PRAYER_DIM 5 - -/* apply a prayer action to the active prayer state. 0=no change. */ -static inline void encounter_apply_prayer_action(OverheadPrayer* prayer, int action) { +/* 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_PRAYER_NO_CHANGE: break; - case ENCOUNTER_PRAYER_OFF: *prayer = PRAYER_NONE; break; - case ENCOUNTER_PRAYER_MELEE: *prayer = PRAYER_PROTECT_MELEE; break; - case ENCOUNTER_PRAYER_RANGED: *prayer = PRAYER_PROTECT_RANGED; break; - case ENCOUNTER_PRAYER_MAGIC: *prayer = PRAYER_PROTECT_MAGIC; break; + 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; } /* ======================================================================== */ @@ -921,7 +973,7 @@ static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t expli /* ======================================================================== */ /* shared prayer drain */ /* */ -/* ENCOUNTERS: call encounter_drain_prayer() each tick to drain prayer */ +/* 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. */ /* */ @@ -937,7 +989,7 @@ static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t expli /** drain effect values for overhead prayers. from the OSRS prayer table — higher values drain faster. used by both PvE encounters and PvP. */ -static inline int encounter_prayer_drain_effect(OverheadPrayer prayer) { +static inline int encounter_overhead_drain_effect(OverheadPrayer prayer) { switch (prayer) { case PRAYER_PROTECT_MELEE: return 12; case PRAYER_PROTECT_RANGED: return 12; @@ -948,38 +1000,57 @@ static inline int encounter_prayer_drain_effect(OverheadPrayer prayer) { } } -/** drain prayer points at the correct OSRS rate. call once per game tick. - drain_effect: total drain from all active prayers (overhead + offensive). - callers compute this by summing encounter_prayer_drain_effect() for overhead - and any offensive prayer drain (piety=24, rigour=24, augury=24, low=6). +/** 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). - drain_counter: persistent state, must be zero-initialized. uses the osrs-sdk - incrementing approach (PrayerController.ts:50-53). - deactivates overhead prayer when points reach 0. caller is responsible for - deactivating offensive prayers if applicable (PvP). */ -static inline void encounter_drain_prayer( - int* current_prayer, OverheadPrayer* active_prayer, - int prayer_bonus, int* drain_counter, int drain_effect -) { - if (*active_prayer == PRAYER_NONE || drain_effect <= 0) return; + 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; + + if (total <= 0 || p->current_prayer <= 0) return; - /* OSRS prayer drain: counter increments by drain_effect each tick. - when counter >= drain_resistance, a prayer point drains. - ref: osrs-sdk PrayerController.ts:50-53, RuneLite PrayerPlugin.java:387. */ int drain_resistance = 60 + prayer_bonus * 2; - *drain_counter += drain_effect; - while (*drain_counter > drain_resistance) { - (*current_prayer)--; - *drain_counter -= drain_resistance; - if (*current_prayer <= 0) { - *current_prayer = 0; - *drain_counter = 0; - *active_prayer = PRAYER_NONE; + 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 */ /* */ @@ -989,7 +1060,7 @@ static inline void encounter_drain_prayer( /* */ /* available structs/functions: */ /* EncounterLoadoutStats — computed combat stats for one gear loadout */ -/* EncounterPrayer — prayer enum (NONE, AUGURY, RIGOUR, PIETY) */ +/* OffensivePrayer — prayer enum (NONE, PIETY, RIGOUR, AUGURY, low-tiers) */ /* encounter_compute_loadout_stats() — derive stats from loadout + prayer */ /* ======================================================================== */ @@ -1014,13 +1085,32 @@ typedef struct { int spell_base_damage; } EncounterLoadoutStats; -/** overhead prayer multipliers for effective level computation. */ -typedef enum { - ENCOUNTER_PRAYER_NONE = 0, - ENCOUNTER_PRAYER_AUGURY, /* +25% magic attack, +25% magic defence */ - ENCOUNTER_PRAYER_RIGOUR, /* +20% ranged attack, +23% ranged strength */ - ENCOUNTER_PRAYER_PIETY, /* +20% melee attack, +23% melee strength, +25% defence */ -} EncounterPrayer; +/** 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, @@ -1029,15 +1119,18 @@ typedef enum { @param loadout gear array indexed by GEAR_SLOT_* (ITEM_NONE=255 for empty) @param style ATTACK_STYLE_MAGIC, ATTACK_STYLE_RANGED, or ATTACK_STYLE_MELEE - @param prayer prayer enum for level multiplier + @param 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 */ + @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, - EncounterPrayer prayer, + OffensivePrayer offensive_prayer, int base_level, FightStyle fight_style, int spell_base_damage, @@ -1074,26 +1167,11 @@ static inline void encounter_compute_loadout_stats( if (eb.attack_crush > out->attack_bonus) out->attack_bonus = eb.attack_crush; } - /* prayer multipliers */ - float att_prayer_mult = 1.0f; - float str_prayer_mult = 1.0f; - switch (prayer) { - case ENCOUNTER_PRAYER_AUGURY: - att_prayer_mult = 1.25f; - break; - case ENCOUNTER_PRAYER_RIGOUR: - att_prayer_mult = 1.20f; - str_prayer_mult = 1.23f; - break; - case ENCOUNTER_PRAYER_PIETY: - att_prayer_mult = 1.20f; - str_prayer_mult = 1.23f; - break; - case ENCOUNTER_PRAYER_NONE: - break; - } + /* 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 */ + /* 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; @@ -1115,8 +1193,7 @@ static inline void encounter_compute_loadout_stats( 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 = 1.0f; - if (prayer == ENCOUNTER_PRAYER_AUGURY) magic_dmg_prayer_mult = 1.04f; + 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) { @@ -1141,25 +1218,33 @@ static inline void encounter_compute_loadout_stats( /* ======================================================================== */ /** recompute eff_level and max_hit for a loadout using a (possibly drained/boosted) - current combat level. call after brew drain, super restore, or bastion boost. + current 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. */ + 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, int current_att_level, int current_str_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 * ls->att_prayer_mult) + att_stance_bonus + 9; - /* augury +4% magic damage. att_prayer_mult == 1.25 iff augury. */ - float magic_dmg_mult = (ls->att_prayer_mult > 1.24f) ? 1.04f : 1.0f; + 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 * ls->att_prayer_mult) + att_stance_bonus + 8; - int eff_str = (int)(current_str_level * ls->str_prayer_mult) + str_stance_bonus + 8; + 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); } } @@ -1237,21 +1322,22 @@ static inline void encounter_bastion_boost(Player* p) { if (p->current_defence > def_cap) p->current_defence = def_cap; } -/** recompute max hit for all loadouts after a stat change. - encounters should call this after brew_drain_stats, restore_stats, or bastion_boost. - ranged loadouts use current_ranged, magic uses current_magic, melee uses - current_attack/current_strength. */ +/** 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->current_ranged, p->current_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->current_magic, p->current_magic); + encounter_update_loadout_level(ls, p->offensive_prayer, p->current_magic, p->current_magic); } else { - encounter_update_loadout_level(ls, p->current_attack, p->current_strength); + encounter_update_loadout_level(ls, p->offensive_prayer, p->current_attack, p->current_strength); } } } @@ -1364,17 +1450,22 @@ static inline void encounter_translate_movement(HumanInput* hi, int* actions, } } -/** translate prayer: 0=no change, 1=off, 2=melee, 3=ranged, 4=magic. - writes to actions[head_prayer]. head_prayer < 0 = skip. */ +/** 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; - switch (hi->pending_prayer) { - case OVERHEAD_NONE: actions[head_prayer] = 1; break; - case OVERHEAD_MELEE: actions[head_prayer] = 2; break; - case OVERHEAD_RANGED: actions[head_prayer] = 3; break; - case OVERHEAD_MAGE: actions[head_prayer] = 4; break; - default: break; - } + 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. diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index c7bce5650d..f7840dc566 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -193,40 +193,34 @@ static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, 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 */ - /* map prayer to action — only actionable prayers */ + /* 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 = (p->prayer == PRAYER_PROTECT_MAGIC) - ? OVERHEAD_NONE : OVERHEAD_MAGE; + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_MAGIC; break; case GUI_PRAY_PROTECT_MISSILES: - hi->pending_prayer = (p->prayer == PRAYER_PROTECT_RANGED) - ? OVERHEAD_NONE : OVERHEAD_RANGED; + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_RANGED; break; case GUI_PRAY_PROTECT_MELEE: - hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MELEE) - ? OVERHEAD_NONE : OVERHEAD_MELEE; + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_MELEE; break; case GUI_PRAY_SMITE: - hi->pending_prayer = (p->prayer == PRAYER_SMITE) - ? OVERHEAD_NONE : OVERHEAD_SMITE; + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_SMITE; break; case GUI_PRAY_REDEMPTION: - hi->pending_prayer = (p->prayer == PRAYER_REDEMPTION) - ? OVERHEAD_NONE : OVERHEAD_REDEMPTION; + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION; break; case GUI_PRAY_PIETY: - hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) - ? 0 : 1; + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_PIETY; break; case GUI_PRAY_RIGOUR: - hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) - ? 0 : 2; + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR; break; case GUI_PRAY_AUGURY: - hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) - ? 0 : 3; + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY; break; default: break; /* non-actionable prayer */ @@ -402,19 +396,10 @@ static void human_to_pvp_actions(HumanInput* hi, int* actions, } } - /* offensive prayer: set via loadout-aware mechanism. - piety/rigour/augury are auto-set by the action system based on loadout, - so we handle this by setting the appropriate loadout if prayer changed. - for human play we just directly mutate the player's offensive prayer. */ - if (hi->pending_offensive_prayer >= 0) { - switch (hi->pending_offensive_prayer) { - case 0: agent->offensive_prayer = OFFENSIVE_PRAYER_NONE; break; - case 1: agent->offensive_prayer = OFFENSIVE_PRAYER_PIETY; break; - case 2: agent->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; break; - case 3: agent->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; break; - default: break; - } - } + /* offensive prayer: encounters wire their HEAD_OFFENSIVE_* in their own translate + helper via encounter_translate_offensive_prayer(). PvP's HEAD_OFFENSIVE is + routed by pvp_actions.h. nothing to do here — direct mutation was legacy. */ + (void)agent; } /* shared translate helpers (encounter_translate_movement/prayer/target) diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h index a0cb01586d..965215b23f 100644 --- a/ocean/osrs/osrs_pvp_actions.h +++ b/ocean/osrs/osrs_pvp_actions.h @@ -19,31 +19,18 @@ #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 // ============================================================================ -// overhead drain: use encounter_prayer_drain_effect() from osrs_encounter.h. -// offensive drain: get_offensive_drain_effect() below. -// drain math: encounter_drain_prayer() from osrs_encounter.h. +// 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 -/** get drain effect for an offensive prayer (PvP only — encounters don't use these yet). */ -static inline int get_offensive_drain_effect(OffensivePrayer prayer) { - switch (prayer) { - case OFFENSIVE_PRAYER_NONE: return 0; - case OFFENSIVE_PRAYER_MELEE_LOW: return 6; /* 1 point per 6 seconds */ - case OFFENSIVE_PRAYER_RANGED_LOW: return 6; - case OFFENSIVE_PRAYER_MAGIC_LOW: return 6; - case OFFENSIVE_PRAYER_PIETY: return 24; /* 1 point per 1.5 seconds */ - case OFFENSIVE_PRAYER_RIGOUR: return 24; - case OFFENSIVE_PRAYER_AUGURY: return 24; - default: return 0; - } -} // ============================================================================ // CONSUMABLE ACTIONS @@ -248,17 +235,15 @@ static void update_timers(Player* p) { if (p->freeze_immunity_ticks > 0) p->freeze_immunity_ticks--; if (p->veng_cooldown > 0) p->veng_cooldown--; - /* prayer drain — uses shared encounter_drain_prayer for the counter math. - LMS has no prayer drain (prayer points are unlimited). */ - if (p->current_prayer > 0 && !p->is_lms) { - int drain_effect = encounter_prayer_drain_effect(p->prayer) - + get_offensive_drain_effect(p->offensive_prayer); - encounter_drain_prayer(&p->current_prayer, &p->prayer, - PRAYER_BONUS, &p->prayer_drain_counter, drain_effect); - /* shared function deactivates overhead prayer; PvP also needs to - deactivate offensive prayer when prayer points run out. */ - if (p->current_prayer <= 0) - p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + /* 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)) { @@ -368,25 +353,48 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { // ========================================================================= 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; - switch (overhead_action) { - case OVERHEAD_MAGE: - if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MAGIC; - break; - case OVERHEAD_RANGED: - if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_RANGED; - break; - case OVERHEAD_MELEE: - if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MELEE; - break; - case OVERHEAD_SMITE: - if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_SMITE; - break; - case OVERHEAD_REDEMPTION: - if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_REDEMPTION; - break; + 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->clicks_this_tick++; + if (p->prayer != prev_prayer || p->offensive_prayer != prev_offensive) p->clicks_this_tick++; // ========================================================================= // PHASE 2: LOADOUT SWITCH - equips dynamic gear slots, returns # changed @@ -405,48 +413,12 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { } // ========================================================================= - // PHASE 3: AUTO-OFFENSIVE PRAYER - // Loadout determines prayer if switching, attack head is fallback for KEEP + // PHASE 3: OFFENSIVE PRAYER — agent-controlled via HEAD_OFFENSIVE, already + // applied in the overhead block above. auto-assignment based on loadout has + // been removed so the agent must manage offensive prayer like a real player + // (enabling prayer flicking). // ========================================================================= - if (p->current_prayer > 0 && p->base_prayer >= 70) { - AttackStyle pray_style = ATTACK_STYLE_NONE; - if (loadout_action != LOADOUT_KEEP) { - switch (loadout_action) { - case LOADOUT_MELEE: - case LOADOUT_SPEC_MELEE: - case LOADOUT_GMAUL: - pray_style = ATTACK_STYLE_MELEE; - break; - case LOADOUT_RANGE: - case LOADOUT_SPEC_RANGE: - pray_style = ATTACK_STYLE_RANGED; - break; - case LOADOUT_MAGE: - case LOADOUT_TANK: - case LOADOUT_SPEC_MAGIC: - pray_style = ATTACK_STYLE_MAGIC; - break; - } - } else { - int combat_action_val = actions[HEAD_COMBAT]; - pray_style = resolve_attack_style_for_action(p, combat_action_val); - } - switch (pray_style) { - case ATTACK_STYLE_MELEE: - p->offensive_prayer = OFFENSIVE_PRAYER_PIETY; - break; - case ATTACK_STYLE_RANGED: - p->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; - break; - case ATTACK_STYLE_MAGIC: - p->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; - break; - default: - break; - } - } - // ========================================================================= // PHASE 4: CONSUMABLES - eating delays attack timer // ========================================================================= diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h index fe745e370f..314efa6d0e 100644 --- a/ocean/osrs/osrs_pvp_observations.h +++ b/ocean/osrs/osrs_pvp_observations.h @@ -13,6 +13,7 @@ #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" /** @@ -729,14 +730,16 @@ static void compute_action_masks(OsrsEnv* env, int agent_idx) { mask[offset + MOVE_FARCAST_7] = can_move_now && can_move_to_farcast(p, t, 7, cmap); offset += COMBAT_DIM; - // OVERHEAD head (6 options) + // 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 + OVERHEAD_NONE] = 1; - mask[offset + OVERHEAD_MAGE] = has_prayer && (p->prayer != PRAYER_PROTECT_MAGIC); - mask[offset + OVERHEAD_RANGED] = has_prayer && (p->prayer != PRAYER_PROTECT_RANGED); - mask[offset + OVERHEAD_MELEE] = has_prayer && (p->prayer != PRAYER_PROTECT_MELEE); - mask[offset + OVERHEAD_SMITE] = has_prayer && !env->is_lms && (p->prayer != PRAYER_SMITE); - mask[offset + OVERHEAD_REDEMPTION] = has_prayer && !env->is_lms && (p->prayer != PRAYER_REDEMPTION); + 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) @@ -762,6 +765,15 @@ static void compute_action_masks(OsrsEnv* env, int agent_idx) { 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 index f82bd5c825..77da57a5a9 100644 --- a/ocean/osrs/osrs_pvp_opponents.h +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -452,15 +452,43 @@ static int opp_apply_consumables(OsrsEnv* env, OpponentState* opp, int* actions, return potion_used; } +/* convert an OverheadAction intent (legacy set-semantic: MAGE/RANGED/MELEE/etc) + into a toggle action given the opponent's current prayer state. if already + on target, emits NO_CHANGE (no-op). all opponent-AI prayer emissions go + through this so the new ENCOUNTER_OVERHEAD_TOGGLE_* encoding is respected. */ +static inline void opp_emit_prayer(int* actions, Player* self, int target_overhead_action) { + OverheadPrayer target_prayer = PRAYER_NONE; + int toggle = ENCOUNTER_OVERHEAD_NO_CHANGE; + switch (target_overhead_action) { + 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: return; /* OVERHEAD_NONE or invalid: no-op */ + } + if (self->prayer != target_prayer) 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) { +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; } - /* Delay reached 0 or was already 0: apply */ - actions[HEAD_OVERHEAD] = opp->pending_prayer_value; + 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; } @@ -505,7 +533,7 @@ static void opp_handle_delayed_prayer(OsrsEnv* env, OpponentState* opp, int* act } /* Process pending prayer (may apply this tick if delay=0) */ - opp_process_pending_prayer(opp, actions); + opp_process_pending_prayer(opp, actions, self); } /* ========================================================================= @@ -529,7 +557,7 @@ static void opp_panicking(OsrsEnv* env, OpponentState* opp, int* actions) { /* Set prayer if not already active */ if (!opp_has_prayer_active(self, opp->chosen_prayer)) { - actions[HEAD_OVERHEAD] = opp->chosen_prayer; + opp_emit_prayer(actions, self, opp->chosen_prayer); } /* Panic eat at 25% HP */ @@ -572,7 +600,7 @@ static void opp_weak_random(OsrsEnv* env, OpponentState* opp, int* actions) { /* Random prayer each tick (includes NONE) */ int prayers[] = {OVERHEAD_NONE, OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; - actions[HEAD_OVERHEAD] = prayers[rand_int(env, 4)]; + opp_emit_prayer(actions, self, prayers[rand_int(env, 4)]); /* Unreliable eating at 30% with 50% skip chance */ int eating = 0; @@ -614,7 +642,7 @@ static void opp_semi_random(OsrsEnv* env, OpponentState* opp, int* actions) { /* Random prayer each tick (no NONE) */ int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; - actions[HEAD_OVERHEAD] = prayers[rand_int(env, 3)]; + opp_emit_prayer(actions, self, prayers[rand_int(env, 3)]); /* Reliable eating at 30% */ int eating = 0; @@ -661,7 +689,7 @@ static void opp_sticky_prayer(OsrsEnv* env, OpponentState* opp, int* actions) { opp->current_prayer_set = 1; } if (!opp_has_prayer_active(self, opp->current_prayer)) { - actions[HEAD_OVERHEAD] = opp->current_prayer; + opp_emit_prayer(actions, self, opp->current_prayer); } /* Simple eating at 30% */ @@ -711,7 +739,7 @@ static void opp_random_eater(OsrsEnv* env, OpponentState* opp, int* actions) { opp->current_prayer_set = 1; } if (!opp_has_prayer_active(self, opp->current_prayer)) { - actions[HEAD_OVERHEAD] = opp->current_prayer; + opp_emit_prayer(actions, self, opp->current_prayer); } /* 2. Multi-threshold eating */ @@ -797,7 +825,7 @@ static void opp_prayer_rookie(OsrsEnv* env, OpponentState* opp, int* actions) { def_prayer = prayers[rand_int(env, 3)]; } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); /* 2. Multi-threshold eating */ if (hp_pct < 0.35f) { @@ -871,7 +899,7 @@ static void opp_improved(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Consumables: triple/double/single eat */ @@ -1013,7 +1041,7 @@ static void opp_novice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) */ @@ -1124,7 +1152,7 @@ static void opp_apprentice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) + drain restore */ @@ -1237,7 +1265,7 @@ static void opp_competent_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) + drain restore */ @@ -1374,7 +1402,7 @@ static void opp_intermediate_nh(OsrsEnv* env, OpponentState* opp, int* actions) } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) */ @@ -1510,7 +1538,7 @@ static void opp_advanced_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) + drain restore */ @@ -1651,7 +1679,7 @@ static void opp_proficient_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating + drain restore */ @@ -1795,7 +1823,7 @@ static void opp_expert_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) + drain restore */ @@ -1942,7 +1970,7 @@ static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Consumables (same thresholds as improved) */ @@ -2485,13 +2513,17 @@ static void opp_read_agent_action(OsrsEnv* env, OpponentState* opp) { opp->has_read_this_tick = 1; } - /* Extract overhead prayer */ + /* 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 == OVERHEAD_MAGE) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; - else if (overhead == OVERHEAD_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; - else if (overhead == OVERHEAD_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; - else if (overhead == OVERHEAD_SMITE) opp->read_agent_prayer = PRAYER_SMITE; - else if (overhead == OVERHEAD_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; + 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; /* Extract movement intent */ opp->read_agent_moving = is_move_action(attack) ? 1 : 0; @@ -2550,7 +2582,7 @@ static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Consumables (same as onetick) */ @@ -2775,7 +2807,7 @@ static void opp_veng_fighter(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as expert_nh) */ @@ -2899,7 +2931,7 @@ static void opp_blood_healer(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Reduced eating — relies on blood barrage for sustain above ~35%. @@ -3051,7 +3083,7 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating (same as improved) */ @@ -3220,7 +3252,7 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { } def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); if (!opp_has_prayer_active(self, def_prayer)) { - actions[HEAD_OVERHEAD] = def_prayer; + opp_emit_prayer(actions, self, def_prayer); } /* 2. Multi-threshold eating + emergency blood barrage sustain */ diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index fd2ea701b3..74556d485a 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -175,7 +175,7 @@ // 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 7 +#define NUM_ACTION_HEADS 8 // Action head indices #define HEAD_LOADOUT 0 @@ -185,19 +185,21 @@ #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 // NONE, MAGE, RANGED, MELEE, SMITE, REDEMPTION +#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 // 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 = 39 +// 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) + FOOD_DIM + POTION_DIM + KARAMBWAN_DIM + VENG_DIM + OFFENSIVE_DIM) // Per-head action dims array static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { @@ -208,6 +210,7 @@ static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { POTION_DIM, KARAMBWAN_DIM, VENG_DIM, + OFFENSIVE_DIM, }; // Number of item stats per item (for observations) @@ -614,6 +617,13 @@ typedef struct { 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; diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index 8830fcc7cc..883b576b8e 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -555,7 +555,7 @@ static void test_loadout_melee_no_prayer(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, - ENCOUNTER_PRAYER_NONE, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AGGRESSIVE, 0, /* spell_base_damage */ @@ -595,7 +595,7 @@ static void test_loadout_melee_piety(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MELEE, - ENCOUNTER_PRAYER_PIETY, + OFFENSIVE_PRAYER_PIETY, 99, FIGHT_STYLE_AGGRESSIVE, 0, /* spell_base_damage */ @@ -630,7 +630,7 @@ static void test_loadout_ranged_rigour(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_RANGED, - ENCOUNTER_PRAYER_RIGOUR, + OFFENSIVE_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, /* spell_base_damage */ @@ -670,7 +670,7 @@ static void test_loadout_magic_augury(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, - ENCOUNTER_PRAYER_AUGURY, + OFFENSIVE_PRAYER_AUGURY, 99, FIGHT_STYLE_AUTOCAST, 30, /* spell_base_damage (ice barrage = 30) */ @@ -709,7 +709,7 @@ static void test_loadout_magic_no_prayer(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_MAGIC, - ENCOUNTER_PRAYER_NONE, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, /* ice barrage */ @@ -746,7 +746,7 @@ static void test_loadout_full_ranged(void) { encounter_compute_loadout_stats( loadout, ATTACK_STYLE_RANGED, - ENCOUNTER_PRAYER_RIGOUR, + OFFENSIVE_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, @@ -788,7 +788,7 @@ static void test_update_loadout_level(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); /* base (aggressive: +3 str, +0 att): eff_att=126, max=32 */ @@ -796,7 +796,7 @@ static void test_update_loadout_level(void) { 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, 90, 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); @@ -807,7 +807,7 @@ static void test_update_loadout_level(void) { ASSERT_INT_EQ("drained max", stats.max_hit, 29); /* restore back to 99 */ - encounter_update_loadout_level(&stats, 99, 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); @@ -815,14 +815,14 @@ static void test_update_loadout_level(void) { clear_loadout(loadout); loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, + 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, 80, 80); + 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 */ @@ -998,7 +998,7 @@ static void test_loadout_def_bonuses(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_ACCURATE, 0, &stats); /* verify defence bonuses sum correctly from ITEM_DATABASE. @@ -1041,7 +1041,7 @@ static void test_edge_cases(void) { /* melee, level 1, no gear, no prayer, accurate stance (+3 att) */ EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, 1, FIGHT_STYLE_ACCURATE, 0, &stats); /* eff = floor(1*1.0) + 3 + 8 = 12 (accurate +3 att) */ @@ -1056,7 +1056,7 @@ static void test_edge_cases(void) { /* magic, level 1, no gear, barrage, autocast (no invisible bonus) */ encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 1, FIGHT_STYLE_AUTOCAST, 30, &stats); /* eff = floor(1*1.0) + 0 + 9 = 10 */ diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index eb3a64a9a1..f75272eb36 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -424,7 +424,7 @@ static void test_player_att_roll_full_mage(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, 99, FIGHT_STYLE_AUTOCAST, 30 /* ice barrage */, &stats); /* sum attack_magic: @@ -466,7 +466,7 @@ static void test_player_att_roll_melee_with_defender(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); /* best melee attack bonus: @@ -508,7 +508,7 @@ static void test_player_att_roll_ranged_blowpipe(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_RIGOUR, + loadout, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_RIGOUR, 99, FIGHT_STYLE_RAPID, 0, &stats); /* blowpipe: attack_ranged=30, ranged_strength=20 */ @@ -547,7 +547,7 @@ static void test_loadout_empty_all_styles(void) { /* melee, level 99, no prayer, accurate stance (+3 att, +0 str) */ EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_ACCURATE, 0, &stats); ASSERT_INT_EQ("empty melee att_bonus", stats.attack_bonus, 0); @@ -562,7 +562,7 @@ static void test_loadout_empty_all_styles(void) { /* ranged, no prayer, rapid stance (no level bonus, -1 speed) */ encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_RANGED, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &stats); ASSERT_INT_EQ("empty ranged att_bonus", stats.attack_bonus, 0); @@ -572,7 +572,7 @@ static void test_loadout_empty_all_styles(void) { /* magic with ice barrage, autocast (no invisible bonus per wiki) */ encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_NONE, + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &stats); ASSERT_INT_EQ("empty magic att_bonus", stats.attack_bonus, 0); @@ -609,7 +609,7 @@ static void test_loadout_all_slots_filled(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, + 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 */ @@ -653,7 +653,7 @@ static void test_loadout_two_handed_weapon(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); /* AGS: stab=0, slash=132, crush=80. best = 132 */ @@ -745,7 +745,7 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, 99, FIGHT_STYLE_AUTOCAST, 30, &stats); int player_att = stats.eff_level * (stats.attack_bonus + 64); /* 32076 */ @@ -767,7 +767,7 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_PIETY, + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, 99, FIGHT_STYLE_ACCURATE, 0, &stats); /* rapier(94)+defender(25) stab = 119 best */ @@ -787,7 +787,7 @@ static void test_hit_chance_player_vs_npc(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MAGIC, ENCOUNTER_PRAYER_AUGURY, + 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, @@ -844,7 +844,7 @@ static void test_loadout_defence_into_def_roll(void) { EncounterLoadoutStats stats; encounter_compute_loadout_stats( - loadout, ATTACK_STYLE_MELEE, ENCOUNTER_PRAYER_NONE, + 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 */ diff --git a/ocean/osrs/tests/test_prayer_flicking.c b/ocean/osrs/tests/test_prayer_flicking.c new file mode 100644 index 0000000000..44f15b31ec --- /dev/null +++ b/ocean/osrs/tests/test_prayer_flicking.c @@ -0,0 +1,188 @@ +/** + * @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); +} + +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(); + + printf("\n=== results: %d/%d passed ===\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} From 8b741fe87247cb3c9ee8bfed447cbb939d24e8cf Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Fri, 17 Apr 2026 10:19:57 +0300 Subject: [PATCH 32/60] osrs bindings: sync ACT_SIZES with new prayer action head layout three bindings had hardcoded ACT_SIZES arrays that didn't track the new 9-/7-/8-head layouts from the prayer-toggle migration: - osrs_inferno: add ENCOUNTER_OFFENSIVE_DIM slot, fix PRAYER dim to ENCOUNTER_OVERHEAD_DIM_PVE (4 instead of 5), fix TARGET dim to INF_OBS_NPCS+1 (matches header) instead of INF_MAX_NPCS+1. - osrs_zulrah: add ZUL_OFFENSIVE_DIM slot. - osrs_pvp: add OFFENSIVE_DIM slot. without this the vecenv get_act_sizes() array underreads and metal_pufferlib.mm's mask-width sum overflows negatively, corrupting input_size (the encoder feature count). --- ocean/osrs_inferno/binding.c | 2 +- ocean/osrs_pvp/binding.c | 2 +- ocean/osrs_zulrah/binding.c | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index dea32feb5e..d5140f169a 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -65,7 +65,7 @@ typedef struct { #define OBS_SIZE INF_TOTAL_OBS #define NUM_ATNS INF_NUM_ACTION_HEADS -#define ACT_SIZES { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 } +#define 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 diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c index 464eccc963..ab4f47512e 100644 --- a/ocean/osrs_pvp/binding.c +++ b/ocean/osrs_pvp/binding.c @@ -40,7 +40,7 @@ typedef struct { #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 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 diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 7c8f45d71d..1aaf59893d 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -46,7 +46,7 @@ typedef struct { #define OBS_SIZE ZUL_TOTAL_OBS #define NUM_ATNS ZUL_NUM_ACTION_HEADS -#define ACT_SIZES {ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM} +#define 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 From d2e43227e0c6005c9d15396a4df04fade1ed65a1 Mon Sep 17 00:00:00 2001 From: Joseph Suarez Date: Fri, 17 Apr 2026 17:29:35 +0000 Subject: [PATCH 33/60] Fix camera --- ocean/osrs/osrs_render.h | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 71e4d964a4..ce09652ea4 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -1193,7 +1193,7 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { 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; + 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; } @@ -1204,14 +1204,14 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { if (rc->human_input.enabled) { /* orbit */ rc->cam_yaw -= delta.x * 0.005f; - rc->cam_pitch -= delta.y * 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; + 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) { From ec3c293329537988e8776d6028735490ba0f6c50 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 12:49:31 +0300 Subject: [PATCH 34/60] osrs pvp: opp_emit_prayer handles OVERHEAD_NONE deactivate under the new toggle-semantic overhead encoding, opp_emit_prayer silently dropped OVERHEAD_NONE intents, so scripted opponents that rolled "none" never deactivated their overhead once one was set. now maps NONE -> toggle matching the currently-active prayer. --- ocean/osrs/osrs_pvp_opponents.h | 48 ++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h index 77da57a5a9..2693169d60 100644 --- a/ocean/osrs/osrs_pvp_opponents.h +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -452,22 +452,44 @@ static int opp_apply_consumables(OsrsEnv* env, OpponentState* opp, int* actions, return potion_used; } -/* convert an OverheadAction intent (legacy set-semantic: MAGE/RANGED/MELEE/etc) +/* 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 an OverheadAction intent (legacy set-semantic: MAGE/RANGED/MELEE/NONE/etc) into a toggle action given the opponent's current prayer state. if already - on target, emits NO_CHANGE (no-op). all opponent-AI prayer emissions go - through this so the new ENCOUNTER_OVERHEAD_TOGGLE_* encoding is respected. */ + on target, emits NO_CHANGE (no-op). if target is NONE, emits the toggle + matching the currently-active prayer to deactivate it. all opponent-AI + prayer emissions go through this so the new ENCOUNTER_OVERHEAD_TOGGLE_* + encoding is respected. */ static inline void opp_emit_prayer(int* actions, Player* self, int target_overhead_action) { - OverheadPrayer target_prayer = PRAYER_NONE; - int toggle = ENCOUNTER_OVERHEAD_NO_CHANGE; + OverheadPrayer target_prayer; switch (target_overhead_action) { - 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: return; /* OVERHEAD_NONE or invalid: no-op */ - } - if (self->prayer != target_prayer) actions[HEAD_OVERHEAD] = toggle; + 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. */ From 6d38fd2bf1e302cf97077657abc4f176c732a0e4 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 13:06:07 +0300 Subject: [PATCH 35/60] osrs anim: fix Y-rotation sign (arms/limbs yaw direction was mirrored) Y rotation had both sin_y terms sign-inverted vs Model.java:1074-1080 in the deobbed runelite client. Z and X rotations matched; only yaw was wrong. Symptom: bow-draw arms fling outward instead of together, Zuk's attack plays with arms stretched wide behind back. Fixed in both non-interleaved (anim_apply_frame) and interleaved (anim_apply_single_transform) paths. --- ocean/osrs/osrs_anim.h | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ocean/osrs/osrs_anim.h b/ocean/osrs/osrs_anim.h index 4c1972eb45..5f9bde2ac9 100644 --- a/ocean/osrs/osrs_anim.h +++ b/ocean/osrs/osrs_anim.h @@ -404,9 +404,10 @@ static void anim_apply_frame( int rz = (vy * sin_x + vz * cos_x) >> 16; vy = ry; vz = rz; - /* Y rotation */ - rx = (vx * cos_y - vz * sin_y) >> 16; - rz = (vx * sin_y + vz * cos_y) >> 16; + /* 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); @@ -502,8 +503,8 @@ static void anim_apply_single_transform( ry = (vy * cos_x - vz * sin_x) >> 16; int rz = (vy * sin_x + vz * cos_x) >> 16; vy = ry; vz = rz; - rx = (vx * cos_y - vz * sin_y) >> 16; - rz = (vx * sin_y + vz * cos_y) >> 16; + 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); From a02191d2a44e9fab8216ec4d40eb9700d20a909c Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 18:49:03 +0300 Subject: [PATCH 36/60] osrs: drain clears prayer enum at pp<=0 entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encounter_drain_all_prayers combined two early-exits (total<=0 and current_prayer<=0), so when pp entered the tick at 0 the prayer enum stayed set indefinitely. triggered by: agent toggles on a prayer at pp=0 (apply_*_action doesn't gate on pp), activation-tick skip zeros this tick's drain, next tick drain bails on pp<=0. same path hits PvP when smite drains defender pp to 0 while overhead is active. split the checks: pp<=0 now clears both overhead and offensive slots before returning; total<=0 early return stays after. shared across inferno, zulrah, pvp — all three encounter paths fixed. tests: 30/30 prayer flicking (25 existing + 5 regression), 155/155 combat, 41/41 player combat. --- ocean/osrs/osrs_encounter.h | 14 +++++++++++- ocean/osrs/tests/test_prayer_flicking.c | 29 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 8fe0c3eb31..64560c142d 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -1033,7 +1033,19 @@ static inline void encounter_drain_all_prayers(Player* p, int prayer_bonus) { p->prayer_just_activated = 0; p->offensive_prayer_just_activated = 0; - if (total <= 0 || p->current_prayer <= 0) return; + /* 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; diff --git a/ocean/osrs/tests/test_prayer_flicking.c b/ocean/osrs/tests/test_prayer_flicking.c index 44f15b31ec..94b56bbbe4 100644 --- a/ocean/osrs/tests/test_prayer_flicking.c +++ b/ocean/osrs/tests/test_prayer_flicking.c @@ -175,6 +175,34 @@ static void test_lazy_flick_partial_cost(void) { 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(); @@ -182,6 +210,7 @@ int main(void) { 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; From 4ffa25e11f43ae50a1a81d1ce972c9a3211bb7cd Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 18:53:04 +0300 Subject: [PATCH 37/60] osrs inferno/zulrah: recompute loadout cache after prayer drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drain can clear offensive_prayer at pp<=0 (auto-clear path), but the recompute was only wired to apply-side changes. consequence: player runs out of prayer mid-fight, enum clears to NONE, but cached max_hit and eff_level still have piety/rigour/augury multipliers baked in — subsequent attacks use stale buffed stats. inferno: move prev_offensive capture before apply, recompute after drain. zulrah: drain lives in a later tick phase than zul_process_prayer; add a second prev_offensive+recompute window around the drain call. pvp unaffected — combat math reads p->offensive_prayer live per attack, no cache to stale. --- ocean/osrs/encounters/encounter_inferno.h | 8 +++++--- ocean/osrs/encounters/encounter_zulrah.h | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 5a819a0293..13238366ed 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -2024,20 +2024,22 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { 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; } - OffensivePrayer prev_offensive = s->player.offensive_prayer; 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); } - /* inferno loadouts have ~0 prayer bonus (armadyl/ancestral/torva); pass 0. */ - encounter_drain_all_prayers(&s->player, 0); } static void inf_tick_player(InfernoState* s, const int* actions) { diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index 3234566e3d..4b5bffb84d 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -1973,8 +1973,21 @@ static void zul_step(EncounterState* state, const int* actions) { /* venom */ zul_venom_tick(s); - /* prayer drain — both overhead and offensive, with activation-tick skip. */ + /* 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->player.current_hitpoints <= 0) { s->episode_over = 1; s->winner = 1; From 171640c5144c72f43b59c503ab645751d3851df9 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 19:05:41 +0300 Subject: [PATCH 38/60] osrs: NPCs no longer stall in attack range when LOS is blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encounter_npc_step_toward short-circuited with early-return when dist<=attack_range, so a ranged NPC in range but with LOS blocked by a pillar would stand still instead of walking around to find a shot. ref: InfernoTrainer Unit.ts:383 'canMove = !hasLOS' — movement has no range or LOS check; the reference stops a mob only once LOS is clear. remove the range-stop from the helper. inferno caller already gates ranged NPCs on LOS (encounter_inferno.h:1155). melee NPCs adjacent to the player naturally no-op because the player tile is blocked. add test_npc_movement.c: 14 cases locking in the no-range-stop behavior and verifying greedy diagonal/cardinal cascade still works. --- ocean/osrs/osrs_encounter.h | 14 ++- ocean/osrs/tests/test_npc_movement.c | 127 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 ocean/osrs/tests/test_npc_movement.c diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 64560c142d..f61b93b77b 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -765,16 +765,22 @@ static inline int encounter_npc_y_edge_clear( corner safespot: if diagonal would land NPC on player, cancel Y component. ref: InfernoTrainer Mob.ts:143-146. + this function does NOT gate on attack range or LOS — the reference's + canMove() (Unit.ts:383) is `!hasLOS && !frozen && !stunned && !dying`, + with NO range check. caller is responsible for skipping the call when + the NPC shouldn't move (hasLOS, frozen, etc). for melee mobs adjacent + to the player, the step naturally fails because the player tile is + occupied — no explicit range gate needed. + + attack_range param is retained for signature compatibility but unused. + returns 1 if moved, 0 if blocked or already at target. */ static inline int encounter_npc_step_toward( int* x, int* y, int tx, int ty, int npc_size, int target_size, int attack_range, encounter_npc_blocked_fn is_blocked, void* ctx ) { - int dist = encounter_entity_footprint_distance(*x, *y, npc_size, - tx, ty, target_size); - if (dist >= 1 && dist <= attack_range) return 0; - + (void)attack_range; int size = npc_size; int dx = 0, dy = 0; if (tx > *x) dx = 1; diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c new file mode 100644 index 0000000000..24a4840e0f --- /dev/null +++ b/ocean/osrs/tests/test_npc_movement.c @@ -0,0 +1,127 @@ +/** + * @file test_npc_movement.c + * @brief Tests for encounter_npc_step_toward (shared greedy NPC chase step). + * + * Regression coverage for: ranged NPCs that are in attack range but without LOS + * (e.g., pillar between them and the player) must keep walking toward the + * player. Reference: InfernoTrainer Unit.ts:383 `canMove = !hasLOS`. The + * helper itself does not gate on range or LOS — those decisions belong to + * the caller. This test locks in the no-range-stop behavior. + * + * 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" + +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, 10, 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, 10, 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, 10, 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_adjacent_natural_stop(void) { + printf("--- melee adjacent: helper tries to step, blocked by player tile ---\n"); + /* NPC at (4,5), target at (5,5), target_size=1 (player). + is_blocked returns 1 for the player tile so NPC can't land there. */ + BlockRect player = { 5, 5, 1 }; + int x = 4, y = 5; + int moved = encounter_npc_step_toward(&x, &y, 5, 5, 1, 1, 1, blocked_rect, &player); + /* greedy: diagonal (5,6)? target (5,5), step (5,6) — not overlap → clear. + but we want the scenario where all forward moves land on player. + retry: player tile (5,5) blocks destination (5,5). diagonal (5,4) or (5,6) free. moves there. + this is OSRS "wiggle around the player" behavior — expected. */ + ASSERT("adjacent melee moves around player tile", moved == 0 || moved == 1); + /* more strict: NPC didn't land ON the player tile */ + ASSERT("NPC not on player tile", !(x == 5 && y == 5)); +} + +/* --- 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, 1, blocked_never, NULL); + ASSERT_EQ("moved", moved, 1); + ASSERT_EQ("diagonal step x+1", x, 1); + ASSERT_EQ("diagonal step y+1", y, 1); +} + +int main(void) { + test_in_range_still_steps(); + test_pillar_stuck(); + test_pillar_diagonal_path(); + test_melee_adjacent_natural_stop(); + test_far_npc_walks(); + + printf("\n=== results: %d/%d passed ===\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} From 85d685893b766ea6713643fcb06cee3297d7e8a9 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 19:19:57 +0300 Subject: [PATCH 39/60] osrs inferno: fix wave 67/68 jad spawns, pillar cleanup, triple-jad stagger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit three wave-67/68 bugs, all from the jad waves falling through the generic random-spawn path in INF_WAVES: 1. wave 67 single jad spawned at a shuffled INF_SPAWN_POS slot instead of the fixed position. ref InfernoRegion.ts:441-451: jad at (23, 27) with player at (18, 25). 2. wave 68 triple jads all used stats->stun_on_spawn = 0, so they fired first attack on the same tick — impossible to prayer-flick. ref: stunTimers = [1, 4, 7].sort(random) — each jad gets a unique stagger. implement with Fisher-Yates on [1, 4, 7]. 3. pillars stayed alive across the 66→67 transition. ref: InfernoRegion.ts:359 adds pillars only when wave < 67 || >= 70, so jad and zuk waves are pillar-free. clear on wave spawn for any wave >= 66 so mid-episode progression also collapses them. coord transform: pillar positions confirm ours_y = 57 - ref_y across all three pillars. applied same transform to jad + player positions. --- ocean/osrs/encounters/encounter_inferno.h | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 13238366ed..36e5f18a9e 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -958,6 +958,64 @@ static void inf_spawn_wave(InfernoState* s) { ? 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 */ + 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 */ + /* 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 */ From 1ecf3f68159f379508baa94e4ec7802d943efdf7 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 19:31:16 +0300 Subject: [PATCH 40/60] osrs inferno: gate barrages on magic level, fix jad healer spawn stun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ice barrage (req magic 94) and blood barrage (req 92) were castable at any magic level. sara brew drains magic to ~88, bats chip it down further — agent could cast a spell the real game wouldn't even offer. gate both in the action mask (spell greyed out below req) and at cast time (attack skipped, attack_timer unchanged so agent retries). also fix jad healer stun_on_spawn: overlay had 0, ref YtHurKot.ts:50 sets stunned=1. one tick of stagger before the first heal lands. --- ocean/osrs/encounters/encounter_inferno.h | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 36e5f18a9e..bed73d7913 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -189,7 +189,7 @@ static const InfNPCOverlay INF_NPC_OVERLAY[INF_NUM_NPC_TYPES] = { [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, 0, 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, 0, 1 }, + [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 }, }; @@ -2280,7 +2280,15 @@ static void inf_tick_player(InfernoState* s, const int* actions) { int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, target_npc->x, target_npc->y, target_npc->size); - if (encounter_player_can_attack(s->player.x, s->player.y, + /* 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 mage_blocked = (s->weapon_set == INF_GEAR_MAGE) && + (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 */ @@ -3137,11 +3145,15 @@ static void inf_write_mask(EncounterState* state, float* mask) { ? 1.0f : 0.0f; /* HEAD_SPELL (3): no_change, blood_barrage, ice_barrage. - noop always valid. blood masked at full HP. both spells masked when not in mage gear. */ + 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) ? 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 */ From d11fde915a3f75822cfedcd706ea2fa441236e93 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 19:39:19 +0300 Subject: [PATCH 41/60] osrs inferno: jad prayer check at T+3, not at projectile land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JalTokJad in InfernoTrainer wraps the damage + prayer check inside a DelayedAction(JAD_PROJECTILE_DELAY=3). animation fires at attack tick T but the prayer match is evaluated at T+3, then super.attack() registers the projectile with reduceDelay=3 so it lands at max(T+3, T+normal_delay). this is the core difficulty knob — agent must commit to the right prayer within 3 ticks of seeing the anim, regardless of distance. our previous behavior checked prayer at projectile LAND (T+hit_delay), which scales 1-3 ticks by distance. close-range jad gave the agent only 1 tick to flick, far-range gave up to 6 — neither matches ref. add prayer_check_delay field to EncounterPendingHit: the deferred check fires when this counter reaches 0, locking damage (zero if prayer matched) independent of flight time. jad queues hits with prayer_check_delay=3 and ticks_remaining=max(3, hit_delay) so the projectile never lands before the check resolves. all other pending- hit insertions explicitly set delay=0 (pre-checked at attack time). shared EncounterPendingHit struct used by inferno only for player hits — zulrah/pvp unaffected. tests green (combat 155, prayer 30, player combat 41, npc movement 14). --- ocean/osrs/encounters/encounter_inferno.h | 10 ++++++++- ocean/osrs/osrs_encounter.h | 25 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index bed73d7913..cba9adcfc3 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1566,6 +1566,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { ph->ticks_remaining = 4; ph->attack_style = ATTACK_STYLE_NONE; /* typeless — not blockable */ ph->check_prayer = 0; + ph->prayer_check_delay = 0; } s->last_hit_by_type = INF_NPC_ZUK; npc->attacked_this_tick = 1; @@ -1671,9 +1672,16 @@ static void inf_npc_attack(InfernoState* s, int idx) { EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; ph->active = 1; ph->damage = dmg; - ph->ticks_remaining = hit_delay; + /* jad: reference InfernoTrainer JalTokJad.ts registers the projectile with + reduceDelay=3 INSIDE a DelayedAction at T+3, so the hit never lands + before T+3 regardless of distance. clamp ticks_remaining to at least 3. */ + ph->ticks_remaining = is_jad && hit_delay < 3 ? 3 : hit_delay; ph->attack_style = actual_style; ph->check_prayer = is_jad ? 1 : 0; + /* jad prayer check is deferred 3 ticks (the DelayedAction window). + other NPCs had their prayer pre-checked above (damage already zeroed + if prayer matched), so delay=0 and the deferred path no-ops. */ + ph->prayer_check_delay = is_jad ? 3 : 0; } } diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index f61b93b77b..ef9697db90 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -84,7 +84,11 @@ typedef struct { int damage; int ticks_remaining; /* countdown to landing */ int attack_style; /* ATTACK_STYLE_* for prayer check at land time */ - int check_prayer; /* 1 = re-check prayer when hit lands (jad) */ + int 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 */ } EncounterPendingHit; @@ -916,10 +920,29 @@ static inline void encounter_resolve_player_pending_hits( 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) { + /* legacy path: delay was 0 and check_prayer never decremented. + happens if encounter sets check_prayer=1 with no delay — check now. */ if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { dmg = 0; if (prayer_correct_count) (*prayer_correct_count)++; From 4f04f8ea3c0e52442f66b4adae2803454179755e Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 19:47:05 +0300 Subject: [PATCH 42/60] osrs inferno: jad hit delay is fixed at 4 ticks, not distance-based MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit you were right that jad's hit delay doesn't vary with range in practice. reference JalTokJad.ts uses reduceDelay=3 and the Projectile clamps remainingDelay>=1, so the effective land time is T + max(4, formula(dist)). for every realistic fight distance the formula gives ≤ 4, so it collapses to exactly T+4 regardless of where the player is standing. our previous code was max(3, hit_delay), which produced variable (3-6 tick) land times. swap to a flat 4 to match the reference. other inferno mobs (mager/ranger/bat/blob) still use distance-based delay per SDK — only jad overrides. jad attack range is 50 (effectively the whole arena, ref verified), so there's never a case where the player is out of range and jad needs to walk. if LOS is somehow blocked — which can't happen in waves 67-69 since we clear pillars — inf_npc_move correctly walks jad toward the player via encounter_npc_step_toward. --- ocean/osrs/encounters/encounter_inferno.h | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index cba9adcfc3..b2179549b4 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1672,10 +1672,14 @@ static void inf_npc_attack(InfernoState* s, int idx) { EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; ph->active = 1; ph->damage = dmg; - /* jad: reference InfernoTrainer JalTokJad.ts registers the projectile with - reduceDelay=3 INSIDE a DelayedAction at T+3, so the hit never lands - before T+3 regardless of distance. clamp ticks_remaining to at least 3. */ - ph->ticks_remaining = is_jad && hit_delay < 3 ? 3 : hit_delay; + /* jad: fixed 4-tick land delay regardless of distance. ref + JalTokJad registers the projectile inside DelayedAction(T+3) with + reduceDelay=3, and Projectile clamps remainingDelay>=1, so the + effective land time is T + max(4, formula(dist)). for every + realistic fight distance formula(dist) ≤ 4, so land is always + exactly T+4. model as a flat constant — matches in-game behavior + where jads hit on a predictable tick regardless of position. */ + ph->ticks_remaining = is_jad ? 4 : hit_delay; ph->attack_style = actual_style; ph->check_prayer = is_jad ? 1 : 0; /* jad prayer check is deferred 3 ticks (the DelayedAction window). From a174425869c682e0a31c6b9b8db6bf2153e0ba47 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 20:01:10 +0300 Subject: [PATCH 43/60] osrs inferno: min-hp progress reward (no farming via heal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit old reward was r = damage_dealt - hp_restored per tick, clamped at 0. agent could farm by attacking on ticks where no heal landed (net positive), then losing no reward on separate heal ticks. net episode reward drifted up without real progress. switch to irreversible-progress: each npc tracks min_hp_reached; each tick's reward is the sum of new hp dropped below that floor. heals raise current hp without touching the floor, so re-damaging up to the old floor pays 0. full kill = full max_hp worth of reward. resurrected mobs inherit min_hp=0 (already paid once) — re-kill gives nothing, so agent learns to kill the mager first. zuk phase override kept: while zuk healers are alive, progress is restricted to damage_zuk_healers_this_tick so agent prioritizes them over zuk. once they're down, full progress flows. per-npc min tracking, zero optimizations. accrual runs after all damage/heal has resolved, right before reward compute. tests green (combat 155, prayer 30, npc movement 14). --- ocean/osrs/encounters/encounter_inferno.h | 78 +++++++++++++++++------ 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b2179549b4..0d76e98f36 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -383,6 +383,10 @@ typedef struct { InfNPCType type; int x, y; int hp, max_hp; + int min_hp_reached; /* lowest hp this npc has ever been at; reward accrues only + when current hp drops below this. prevents farming damage + that gets healed back — healing raises current hp but min + stays, so re-damaging up to min gives 0 reward. */ int size; int attack_timer; /* ticks until next attack */ int attack_style; /* current attack style (may differ from default for blobs) */ @@ -569,11 +573,14 @@ typedef struct { float damage_dealt_this_tick; float damage_zuk_healers_this_tick; float damage_received_this_tick; - /* HP restored to the enemy side this tick — subtracted from damage_dealt - in inf_compute_reward so the agent gets no credit for damage that's - immediately undone. sources: zuk healer heals (landing tick), jad healer - heals (landing tick), mager resurrection (the resurrected mob's HP). */ + /* HP restored to the enemy side this tick — kept as diagnostic only. + the reward signal now uses min-HP progress, which inherently ignores + heals (they raise current hp without touching the min-reached floor). */ float hp_restored_this_tick; + /* irreversible min-HP progress this tick: sum across all NPCs of how much + their HP dropped below their previous min_hp_reached. healing and + resurrection can't contribute here — only new damage below the floor. */ + float min_hp_progress_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 */ @@ -903,6 +910,7 @@ static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y npc->type = type; npc->hp = stats->hp; npc->max_hp = stats->hp; + npc->min_hp_reached = stats->hp; npc->size = stats->size; npc->attack_timer = stats->attack_speed; npc->attack_style = stats->default_style; @@ -1752,9 +1760,10 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { 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; - /* resurrection restores HP the agent already paid to remove — treat like a - heal for reward purposes so re-kills don't double-count as progress. */ - s->hp_restored_this_tick += (float)dm->hp; + /* agent already got paid for driving this mob to 0 the first time — lock + min_hp_reached at 0 so re-killing the resurrected copy yields no new + min-HP-progress reward. encourages killing mager before it can rez. */ + s->npcs[slot].min_hp_reached = 0; /* remove from dead store (swap with last) */ s->dead_mobs[di] = s->dead_mobs[s->dead_mob_count - 1]; @@ -2453,6 +2462,22 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* reward */ /* ======================================================================== */ +/* walk every active NPC, bank any new HP-low-watermark progress, and update + the min_hp_reached floor. call once per tick, after all damage/heal has + resolved. */ +static void inf_accrue_min_hp_progress(InfernoState* s) { + float progress = 0.0f; + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + if (npc->hp < npc->min_hp_reached) { + progress += (float)(npc->min_hp_reached - npc->hp); + npc->min_hp_reached = npc->hp; + } + } + s->min_hp_progress_this_tick = progress; +} + static float inf_compute_reward(InfernoState* s) { /* accumulate diagnostic stats BEFORE terminal check so the killing blow's damage is counted in total_damage_received */ @@ -2475,11 +2500,17 @@ static float inf_compute_reward(InfernoState* s) { //if (s->behind_shield_this_tick) // r += 0.005f; - /* phased damage reward: - - Zuk wave with healers alive: only reward damage to healers (priority kill) - - otherwise: net damage = dealt - hp_restored, so mager resurrections and - any other in-flight healing don't look like free progress. clamp at 0 — - a pure-healing tick is zero-reward, never negative. */ + /* irreversible-progress reward: only new HP below each NPC's low-water + mark counts. damage that gets healed back is worth 0 on re-application; + farming is impossible because hitting the same HP twice only pays the + first time. killing healers gives their full HP as progress since they + can't be healed back up. naturally incentivizes: kill healers first, + then finish the boss. + + zuk-phase override kept: while zuk healers are alive, zuk progress is + zeroed so the agent doesn't learn to race zuk while healers tick him + back up. once the healers are cleared, all progress (including zuk's) + flows normally. */ int zuk_healers_alive = 0; for (int i = 0; i < INF_MAX_NPCS; i++) { if (s->npcs[i].active && s->npcs[i].type == INF_NPC_HEALER_ZUK && s->npcs[i].death_ticks == 0) { @@ -2488,14 +2519,19 @@ static float inf_compute_reward(InfernoState* s) { } } + float progress = s->min_hp_progress_this_tick; if (zuk_healers_alive) { - if (s->damage_zuk_healers_this_tick > 0.0f) - r += 0.001f * s->damage_zuk_healers_this_tick; - } else { - float net_damage = s->damage_dealt_this_tick - s->hp_restored_this_tick; - if (net_damage > 0.0f) - r += 0.001f * net_damage; - } + /* recompute progress ignoring zuk itself so only healer damage counts. */ + progress = 0.0f; + /* re-walk the delta we just banked: we no longer have per-npc deltas, + so use the already-banked damage_zuk_healers_this_tick which tracks + landed damage on zuk-healers specifically. this is a rough match — + it overcounts by healers-healing-zuk-healers but that path doesn't + exist, so it's effectively equal to the min-hp progress on healers. */ + progress = s->damage_zuk_healers_this_tick; + } + if (progress > 0.0f) + r += 0.001f * progress; return r; } @@ -2514,6 +2550,7 @@ static void inf_step(EncounterState* state, const int* actions) { s->damage_zuk_healers_this_tick = 0.0f; s->damage_received_this_tick = 0.0f; s->hp_restored_this_tick = 0.0f; + s->min_hp_progress_this_tick = 0.0f; s->prayer_correct_this_tick = 0; s->off_prayer_hits_this_tick = 0; s->tick_styles_fired = 0; @@ -2664,6 +2701,9 @@ static void inf_step(EncounterState* state, const int* actions) { 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. */ + inf_accrue_min_hp_progress(s); s->reward = inf_compute_reward(s); s->episode_return += s->reward; From e1a9832fab9baf773b4da0e77d264ade9dca857e Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 20:08:33 +0300 Subject: [PATCH 44/60] osrs inferno: exclude jad healers from min-hp reward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tagging (one hit) switches the healer's aggro from jad to player — that's all you need to stop the heal chain. killing the healer is pure wasted dps. if healer damage contributed to min-hp progress the agent would learn to finish them off, which is strictly worse play. skip INF_NPC_HEALER_JAD in the accrual loop. the tag still pays off indirectly: tagged healers stop healing jad, so jad's min-hp floor keeps dropping from player damage instead of getting stuck while heals undo progress. --- ocean/osrs/encounters/encounter_inferno.h | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 0d76e98f36..f301f5de99 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -2464,12 +2464,20 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* walk every active NPC, bank any new HP-low-watermark progress, and update the min_hp_reached floor. call once per tick, after all damage/heal has - resolved. */ + resolved. + + jad healers (Yt-HurKot) are excluded: in practice you tag them once to + switch their aggro from jad to you, then ignore them while you burn jad. + killing them outright is wasted DPS. if damage on healers paid reward the + agent would learn to finish them off instead of just tagging. the + indirect signal — tagged healers stop healing jad, so jad progress stops + getting undone — already makes tagging optimal under min-hp progress. */ static void inf_accrue_min_hp_progress(InfernoState* s) { float progress = 0.0f; for (int i = 0; i < INF_MAX_NPCS; i++) { InfNPC* npc = &s->npcs[i]; if (!npc->active) continue; + if (npc->type == INF_NPC_HEALER_JAD) continue; if (npc->hp < npc->min_hp_reached) { progress += (float)(npc->min_hp_reached - npc->hp); npc->min_hp_reached = npc->hp; From 319713b051fed415e629acc801c3e2ae252586c0 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 20:26:35 +0300 Subject: [PATCH 45/60] osrs inferno: zuk hit is no-tick-eat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zuk hit still lands at T+4 (projectile still flies visually) but eating between fire and land can't save you — matches in-game rule that zuk is the one inferno mob you can't tick-eat. add hp_at_fire to EncounterPendingHit. zuk stamps the player's HP at fire time when queueing. at resolve, if current HP is above the checkpoint (agent ate while the projectile was airborne), clamp back down to hp_at_fire first — that undoes the heal — then apply damage as normal. the undone HP is booked to damage_received so reward signals it. other damage sources (bat bites, etc.) still bring HP below the checkpoint, since we only clamp as a ceiling. all other pending-hit insertion sites explicitly zero hp_at_fire to keep the clamp disabled. --- ocean/osrs/encounters/encounter_inferno.h | 9 ++++++++- ocean/osrs/osrs_encounter.h | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index f301f5de99..7f4ef9f52f 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1564,7 +1564,12 @@ static void inf_npc_attack(InfernoState* s, int idx) { npc->attack_visual_target = si; } else { /* typeless hit on player — not blockable by prayer, no accuracy roll. - queued as pending hit with 4-tick delay (InfernoTrainer: setDelay=4). */ + hit lands 4 ticks later like any other projectile, but zuk is the + one inferno mob you cannot tick-eat. model it by checkpointing + the player's fire-time HP in the pending hit: at land we clamp + HP back down to fire_hp - damage, so any eating done between + fire and land is undone when the hit resolves. other damage + sources are still additive (clamp is a ceiling, not a floor). */ 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) { @@ -1575,6 +1580,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { ph->attack_style = ATTACK_STYLE_NONE; /* typeless — not blockable */ ph->check_prayer = 0; ph->prayer_check_delay = 0; + ph->hp_at_fire = s->player.current_hitpoints; /* zuk-style no-tick-eat */ } s->last_hit_by_type = INF_NPC_ZUK; npc->attacked_this_tick = 1; @@ -1694,6 +1700,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { other NPCs had their prayer pre-checked above (damage already zeroed if prayer matched), so delay=0 and the deferred path no-ops. */ ph->prayer_check_delay = is_jad ? 3 : 0; + ph->hp_at_fire = 0; /* no-tick-eat clamp disabled for non-zuk mobs */ } } diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index ef9697db90..c4727917a7 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -89,6 +89,10 @@ typedef struct { 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 hp_at_fire; /* player HP at fire tick, >0 enables no-tick-eat clamp. + at land: player HP = min(current, hp_at_fire - damage), + so heals between fire and land are undone. used for zuk + (the one mob that can't be tick-eaten). 0 = disabled. */ int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ } EncounterPendingHit; @@ -960,6 +964,17 @@ static inline void encounter_resolve_player_pending_hits( if (off_prayer_hit_count) (*off_prayer_hit_count)++; } + /* no-tick-eat clamp (zuk): when hp_at_fire was set at queue time, + the hit enforces the fire-tick outcome. we first clamp current + HP back down to fire_hp (undoing any eating done while the + projectile was in the air), then apply damage as usual. other + damage taken between fire and land stays — min() only removes + heal, never adds damage. */ + if (hits[i].hp_at_fire > 0 && player->current_hitpoints > hits[i].hp_at_fire) { + int undone = player->current_hitpoints - hits[i].hp_at_fire; + player->current_hitpoints = hits[i].hp_at_fire; + if (damage_received_acc) *damage_received_acc += (float)undone; + } encounter_damage_player(player, dmg, damage_received_acc); hits[i] = hits[--(*hit_count)]; i--; From acb9e7a6bd87708eafa1b4cedbc62c05a04c47a9 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 20:31:50 +0300 Subject: [PATCH 46/60] osrs inferno: revert zuk no-tick-eat clamp to simple delayed-hit model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the hp_at_fire clamp was a speculative guess at real-game mechanics. going back to the simple model: zuk rolls 0..max_hit, queues a pending hit at T that lands unchanged at T+4. eating between fire and land is allowed to save you on non-lethal rolls — if this turns out to matter we can revisit with a better-grounded mechanic. drops the hp_at_fire field from EncounterPendingHit, the clamp logic in encounter_resolve_player_pending_hits, and the per-site set/reset in the npc attack paths. --- ocean/osrs/encounters/encounter_inferno.h | 9 +-------- ocean/osrs/osrs_encounter.h | 15 --------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 7f4ef9f52f..5dc9728e9f 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1564,12 +1564,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { npc->attack_visual_target = si; } else { /* typeless hit on player — not blockable by prayer, no accuracy roll. - hit lands 4 ticks later like any other projectile, but zuk is the - one inferno mob you cannot tick-eat. model it by checkpointing - the player's fire-time HP in the pending hit: at land we clamp - HP back down to fire_hp - damage, so any eating done between - fire and land is undone when the hit resolves. other damage - sources are still additive (clamp is a ceiling, not a floor). */ + 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) { @@ -1580,7 +1575,6 @@ static void inf_npc_attack(InfernoState* s, int idx) { ph->attack_style = ATTACK_STYLE_NONE; /* typeless — not blockable */ ph->check_prayer = 0; ph->prayer_check_delay = 0; - ph->hp_at_fire = s->player.current_hitpoints; /* zuk-style no-tick-eat */ } s->last_hit_by_type = INF_NPC_ZUK; npc->attacked_this_tick = 1; @@ -1700,7 +1694,6 @@ static void inf_npc_attack(InfernoState* s, int idx) { other NPCs had their prayer pre-checked above (damage already zeroed if prayer matched), so delay=0 and the deferred path no-ops. */ ph->prayer_check_delay = is_jad ? 3 : 0; - ph->hp_at_fire = 0; /* no-tick-eat clamp disabled for non-zuk mobs */ } } diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index c4727917a7..ef9697db90 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -89,10 +89,6 @@ typedef struct { 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 hp_at_fire; /* player HP at fire tick, >0 enables no-tick-eat clamp. - at land: player HP = min(current, hp_at_fire - damage), - so heals between fire and land are undone. used for zuk - (the one mob that can't be tick-eaten). 0 = disabled. */ int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ } EncounterPendingHit; @@ -964,17 +960,6 @@ static inline void encounter_resolve_player_pending_hits( if (off_prayer_hit_count) (*off_prayer_hit_count)++; } - /* no-tick-eat clamp (zuk): when hp_at_fire was set at queue time, - the hit enforces the fire-tick outcome. we first clamp current - HP back down to fire_hp (undoing any eating done while the - projectile was in the air), then apply damage as usual. other - damage taken between fire and land stays — min() only removes - heal, never adds damage. */ - if (hits[i].hp_at_fire > 0 && player->current_hitpoints > hits[i].hp_at_fire) { - int undone = player->current_hitpoints - hits[i].hp_at_fire; - player->current_hitpoints = hits[i].hp_at_fire; - if (damage_received_acc) *damage_received_acc += (float)undone; - } encounter_damage_player(player, dmg, damage_received_acc); hits[i] = hits[--(*hit_count)]; i--; From 0c2a0cd1159d44f47e5e5d078fbb012739b8970d Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 20:37:59 +0300 Subject: [PATCH 47/60] osrs inferno: lethal npc damage kills the tick, eating can't revive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encounter_damage_player clamps hp at 0 on overkill (so the negative value isn't exposed to downstream code). that's fine on its own, but the tick order is resolve npc damage to player inf_tick_player (player can eat a brew here) end-of-tick death check if an npc hit lands for more than the player's current hp, the clamp zeroes hp then the brew in inf_tick_player lifts it back above zero and the end-of-tick death check sees a live player. observed in training: zuk 140 lands on 115 hp → clamp to 0 → brew +16 → alive at 16. explains 'tick-eating zuk' agent behavior (which shouldn't be possible at all under our ordering). fix: death check directly after the damage-resolve step, before the player action phase. a corpse can't eat. episode ends cleanly. --- ocean/osrs/encounters/encounter_inferno.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 5dc9728e9f..3e9742f347 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -2644,6 +2644,22 @@ static void inf_step(EncounterState* state, const int* actions) { &s->damage_received_this_tick, &s->prayer_correct_this_tick, &s->off_prayer_hits_this_tick); 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); From 3b4496f97a1d482fefc357e52922ba81542c1672 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 21:31:56 +0300 Subject: [PATCH 48/60] osrs inferno: fix zuk healer spark coords, projectile model, mager/ranger visual timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zuk healer aoe was queuing 2 of its 3 sparks with y=14+rand(0..3) as if our coord system matched the InfernoTrainer reference. our y is flipped (ours_y = 57 - ref_y) so those sparks were landing on the opposite side of the arena — visible as projectiles shooting to narnia instead of the ground near the player. translate to y=40..43. healer projectile model was reusing INFERNO_ZEK_PROJECTILE (mager's orange ball). reference uses tekton_meteor.glb — not in our cache exports. switch to TZHAAR_FIRE_SPIT_TRAVEL (GFX 448) as the closest meteor-shaped flight model and the most distinct from mager/zuk projectiles. proper fix would export a tekton meteor spotanim into the manifest. mager projectile was ~3 ticks too slow visually. reference (JalZek MagicWeapon) has visualDelayTicks=2 and visualHitEarlyTicks=-1: invisible for 2 ticks, then arrives 1 tick after the hit lands. set start_delay to 2 ticks and duration to (hit_delay - 1) ticks to match. ranger: same structural fix for visualDelayTicks=3 (JalXil RangedWeapon). flagged but not fixed: ranger's reduceDelay=-2 (hit delay +2 ticks) is still missing sim-side, so damage lands 2 ticks earlier than reference. --- ocean/osrs/encounters/encounter_inferno.h | 50 ++++++++++++++++------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 3e9742f347..b9a7c982a8 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1949,17 +1949,20 @@ static void inf_queue_zuk_healer_sparks(InfernoState* s, const InfNPC* npc) { 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, - 14 + encounter_rand_int(&s->rng_state, 4), + 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, - 14 + encounter_rand_int(&s->rng_state, 4), + 40 + encounter_rand_int(&s->rng_state, 4), 5 + encounter_rand_int(&s->rng_state, 6), 4); } @@ -3505,25 +3508,36 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) ? INF_GFX_448_MODEL : INF_GFX_447_MODEL; break; case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; - /* InfernoTrainer uses a tekton_meteor-style model for Jal-MejJak. - OSRS cache doesn't have a dedicated healer spotanim exported here, - so we reuse INFERNO_ZEK_PROJECTILE (orange ball, closest meteor - shape) until a proper healer spotanim is added to the manifest. */ - case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1376_MODEL; break; + /* InfernoTrainer uses tekton_meteor.glb for Jal-MejJak projectiles. + OSRS cache doesn't have a dedicated meteor spotanim exported here, + so we reuse TZHAAR_FIRE_SPIT_TRAVEL (GFX 448) — a fiery orb, the + closest meteor-shaped flight model in the current manifest and + distinct from mager/zuk projectiles. proper fix: export tekton + meteor spotanim into the manifest. */ + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_448_MODEL; break; default: break; } /* NPC-specific flight overrides */ switch (npc->type) { case INF_NPC_MAGER: - duration += 60; /* visual delay ~2 ticks */ + /* 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: - /* SDK: reduceDelay=-2 (adds 2 ticks to hit), visualDelayTicks=3 - (projectile invisible for first 3 ticks). net visual effect: - +2 ticks to flight - 3 ticks hidden = -1 tick visual duration. */ - duration += 60 - 90; /* +2 ticks hit delay, -3 ticks visual delay */ - if (duration < 30) duration = 30; /* minimum 1 game tick visible */ + /* 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. NOTE: sim-side hit delay + is currently NOT adjusted by +2 — damage lands 2 ticks + earlier than reference. */ + duration = (hit_delay - 1) * 30; + if (duration < 30) duration = 30; break; case INF_NPC_JAD: if (actual_style == ATTACK_STYLE_MAGIC) { @@ -3558,6 +3572,14 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { /* Jad: 3-tick visual delay (InfernoTrainer JAD_PROJECTILE_DELAY=3) */ if (pi >= 0 && npc->type == INF_NPC_JAD) ov->projectiles[pi].start_delay = 3 * 30; + + /* 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++) { @@ -3570,7 +3592,7 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* 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_1376_MODEL); + 4 * 30, 96, 64, 16, 3.0f, 0, 1, 1, INF_GFX_448_MODEL); spark->visual_emitted = 1; } From 84ff5df9dfc1036bab72b402d957b15727503b07 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sat, 18 Apr 2026 21:32:20 +0300 Subject: [PATCH 49/60] osrs inferno: LOS-stop uses actual target, not hardcoded player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit regression from 7d72438f7 (range-stop removed from the shared helper): the movement gate in inf_npc_move only fired when aggro_target was the player (aggro_target < 0) and called inf_npc_has_los(s, idx) which is hardcoded to check LOS to the player. for zuk-wave mager/ranger with aggro_target = shield_idx, the gate was skipped entirely — they walked straight into melee of the shield. fix: select the target first, then check LOS+range against that target. use npc_has_line_of_sight directly with (tx, ty) from target selection, which already resolves to player, aggroed NPC, or pillar. drop the aggro_target < 0 condition since the gate is now target-aware. pillar-stuck behavior preserved: when LOS to the target is blocked, the gate doesn't trigger and the greedy helper proceeds. melee NPCs still walk until player-tile blocking stops them. add 4 LOS tests to test_npc_movement.c locking in the generic-target contract of npc_has_line_of_sight: in-range clear (non-player), blocked by pillar, out-of-range, and player-target control. 18/18 passing. --- ocean/osrs/encounters/encounter_inferno.h | 38 ++++++++-------- ocean/osrs/tests/test_npc_movement.c | 55 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b9a7c982a8..7220a43730 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1215,15 +1215,9 @@ static void inf_npc_move(InfernoState* s, int idx) { } } - /* ranged/magic NPCs stop moving when they have LOS to the player. - this is the core OSRS mechanic: NPCs only walk toward their target - while they cannot see it. once LOS is established, they attack. */ - if (npc->type != INF_NPC_NIBBLER && stats->attack_range > 1) { - if (npc->aggro_target < 0 && inf_npc_has_los(s, idx)) return; - } - - /* target selection */ + /* 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) { @@ -1241,11 +1235,13 @@ static void inf_npc_move(InfernoState* s, int idx) { } 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; @@ -1255,18 +1251,24 @@ static void inf_npc_move(InfernoState* s, int idx) { npc->target_x = tx; npc->target_y = ty; - /* greedy step toward target using shared helper. - target_size=1 for player, attack_range from NPC stats. - the shared function stops automatically when within attack range. */ + /* ranged/magic NPCs stop moving once they can see their CURRENT target + within attack range — not just the player. the previous gate only + triggered when aggro_target was the player, so shield-aggroed + mager/ranger in the zuk wave walked right into melee. + reference: InfernoTrainer Unit.ts:383 canMove = !hasLOS (where + hasLOS is relative to the NPC's current aggro target). */ + if (stats->attack_range > 1 && npc->type != INF_NPC_NIBBLER) { + if (npc_has_line_of_sight(s->los_blockers, s->los_blocker_count, + npc->x, npc->y, npc->size, + tx, ty, stats->attack_range)) return; + } + + /* greedy step toward target using shared helper. the helper no longer + gates on range/LOS (per the commit removing early-return); that's + handled above for ranged NPCs and naturally by player-tile blocking + for melee NPCs. */ int ox = npc->x, oy = npc->y; InfMoveCtx mc = { s, idx }; - int target_size = 1; - if (npc->type == INF_NPC_NIBBLER) { - target_size = INF_PILLAR_SIZE; - } else if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && - s->npcs[npc->aggro_target].active) { - target_size = s->npcs[npc->aggro_target].size; - } encounter_npc_step_toward(&npc->x, &npc->y, tx, ty, npc->size, target_size, stats->attack_range, inf_npc_blocked, &mc); diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c index 24a4840e0f..fd88ebf6be 100644 --- a/ocean/osrs/tests/test_npc_movement.c +++ b/ocean/osrs/tests/test_npc_movement.c @@ -16,6 +16,7 @@ #include #include #include "osrs_encounter.h" +#include "osrs_collision.h" static int tests_run = 0; static int tests_passed = 0; @@ -115,6 +116,55 @@ static void test_far_npc_walks(void) { ASSERT_EQ("diagonal step y+1", y, 1); } +/* ======================================================================== */ +/* npc_has_line_of_sight: regression for the "ranged NPC walks into melee" */ +/* bug (inferno-encounter). inf_npc_move must gate movement using LOS to */ +/* the NPC's CURRENT target (player OR shield/other NPC), not hardcoded to */ +/* the player. these tests lock in the generic target semantics of the */ +/* shared LOS helper so any caller can trust it for arbitrary targets. */ +/* ======================================================================== */ + +/* --- ranged NPC sees a non-player target (shield-like) in range, clear --- */ +static void test_los_to_npc_target_in_range_clear(void) { + printf("--- LOS to non-player target: in range, clear ray ---\n"); + /* mager at (20, 36) size 4, shield at (23, 44) size 5 (zuk wave layout). + no blockers. attack_range=15. closest NPC corner to target (23, 44): + cx = clamp(23, [20, 23]) = 23, cy = clamp(44, [36, 39]) = 39. + trace (23, 44) -> (23, 39): dy=-5, dx=0, within range, clear. */ + int has = npc_has_line_of_sight(NULL, 0, 20, 36, 4, 23, 44, 15); + ASSERT_EQ("has_los to shield = 1", has, 1); +} + +/* --- ranged NPC sees a non-player target, pillar blocks ray --- */ +static void test_los_to_npc_target_blocked_by_pillar(void) { + printf("--- LOS to non-player target: in range, pillar blocks ---\n"); + /* mager at (10, 40) size 4 (footprint 10..13 × 40..43), target at + (23, 40). closest NPC corner to target: (13, 40). horizontal ray. + pillar at (15, 40) size 3 (covers 15..17 × 40..42) sits on the ray. */ + LOSBlocker pillar = { 15, 40, 3, LOS_FULL_MASK }; + int has = npc_has_line_of_sight(&pillar, 1, 10, 40, 4, 23, 40, 15); + ASSERT_EQ("has_los through pillar = 0", has, 0); +} + +/* --- ranged NPC too far from non-player target: out of range --- */ +static void test_los_to_npc_target_out_of_range(void) { + printf("--- LOS to non-player target: out of range ---\n"); + /* mager at (0, 0) size 4, target at (25, 25) size 1. Chebyshev from + closest NPC corner (3, 3) to (25, 25) is 22. attack_range=15. */ + int has = npc_has_line_of_sight(NULL, 0, 0, 0, 4, 25, 25, 15); + ASSERT_EQ("out of range = 0", has, 0); +} + +/* --- symmetric check: LOS to player coords same function, same contract --- */ +static void test_los_to_player_target_in_range_clear(void) { + printf("--- LOS to player target: in range, clear ray (control) ---\n"); + /* ranger at (14, 32) size 3, player at (22, 25) size 1. + closest NPC corner (16, 32). trace (22, 25) -> (16, 32): dx=-6, dy=7. + range=15. clear. */ + int has = npc_has_line_of_sight(NULL, 0, 14, 32, 3, 22, 25, 15); + ASSERT_EQ("has_los to player = 1", has, 1); +} + int main(void) { test_in_range_still_steps(); test_pillar_stuck(); @@ -122,6 +172,11 @@ int main(void) { test_melee_adjacent_natural_stop(); test_far_npc_walks(); + test_los_to_npc_target_in_range_clear(); + test_los_to_npc_target_blocked_by_pillar(); + test_los_to_npc_target_out_of_range(); + test_los_to_player_target_in_range_clear(); + printf("\n=== results: %d/%d passed ===\n", tests_passed, tests_run); return (tests_passed == tests_run) ? 0 : 1; } From de25561817a5fc51541f3945b4dd241f6eab8a69 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 21 Apr 2026 13:35:00 +0300 Subject: [PATCH 50/60] osrs passive item effects + inferno gear --- ocean/osrs/data/item_models.h | 6 +- ocean/osrs/encounters/encounter_inferno.h | 122 +++--- ocean/osrs/encounters/encounter_zulrah.h | 101 +++-- ocean/osrs/osrs_combat.h | 9 +- ocean/osrs/osrs_damage.h | 63 +-- ocean/osrs/osrs_encounter.h | 23 +- ocean/osrs/osrs_gui.h | 5 + ocean/osrs/osrs_item_effects.h | 365 ++++++++++++++++++ ocean/osrs/osrs_items.h | 69 +++- ocean/osrs/osrs_items_generated.h | 280 +++++++------- ocean/osrs/osrs_pvp_actions.h | 20 +- ocean/osrs/osrs_pvp_api.h | 8 +- ocean/osrs/osrs_pvp_combat.h | 35 +- ocean/osrs/osrs_pvp_gear.h | 30 +- ocean/osrs/osrs_pvp_observations.h | 3 +- ocean/osrs/osrs_types.h | 64 ++- ocean/osrs/scripts/ExportItemSprites.java | 6 +- .../scripts/export_collision_map_modern.py | 19 +- ocean/osrs/scripts/export_items.sh | 5 +- ocean/osrs/scripts/export_models.py | 6 +- ocean/osrs/scripts/export_sprites_modern.py | 6 +- ocean/osrs/scripts/modern_cache_reader.py | 4 +- ocean/osrs/tests/test_combat_math.c | 14 +- ocean/osrs/tests/test_item_effects.c | 92 ++++- ocean/osrs/tests/test_item_effects_core.c | 251 ++++++++++++ ocean/osrs/tests/test_special_attacks.c | 11 +- ocean/osrs/tools/generate_items.py | 29 +- ocean/osrs/tools/items_manifest.json | 42 +- 28 files changed, 1272 insertions(+), 416 deletions(-) create mode 100644 ocean/osrs/osrs_item_effects.h create mode 100644 ocean/osrs/tests/test_item_effects_core.c diff --git a/ocean/osrs/data/item_models.h b/ocean/osrs/data/item_models.h index 0a3cf62e28..4b2d92f249 100644 --- a/ocean/osrs/data/item_models.h +++ b/ocean/osrs/data/item_models.h @@ -11,7 +11,7 @@ typedef struct { uint8_t has_sleeves; } ItemModelMapping; -#define ITEM_MODEL_COUNT 99 +#define ITEM_MODEL_COUNT 103 static const ItemModelMapping ITEM_MODEL_MAP[] = { { 10828, 21938, 917504, 0 }, @@ -113,6 +113,10 @@ static const ItemModelMapping ITEM_MODEL_MAP[] = { { 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/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 7220a43730..34ac9c367b 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -491,12 +491,12 @@ static const uint8_t INF_MAGE_LOADOUT[NUM_GEAR_SLOTS] = { [GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE, [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, [GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND, - [GEAR_SLOT_SHIELD] = ITEM_CRYSTAL_SHIELD, - [GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP, - [GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM, - [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, - [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, - [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, + [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] = { @@ -509,8 +509,8 @@ static const uint8_t INF_RANGE_TBOW_LOADOUT[NUM_GEAR_SLOTS] = { [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, - [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, - [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, + [GEAR_SLOT_FEET] = ITEM_AVERNIC_TREADS, + [GEAR_SLOT_RING] = ITEM_VENATOR_RING, }; static const uint8_t INF_RANGE_BP_LOADOUT[NUM_GEAR_SLOTS] = { @@ -523,8 +523,8 @@ static const uint8_t INF_RANGE_BP_LOADOUT[NUM_GEAR_SLOTS] = { [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, - [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, - [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, + [GEAR_SLOT_FEET] = ITEM_AVERNIC_TREADS, + [GEAR_SLOT_RING] = ITEM_VENATOR_RING, }; /* pointer array for loadout switching */ @@ -535,10 +535,6 @@ static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { }; /* tank overlay items (justiciar) */ -#define INF_TANK_HEAD ITEM_JUSTICIAR_FACEGUARD -#define INF_TANK_BODY ITEM_JUSTICIAR_CHESTGUARD -#define INF_TANK_LEGS ITEM_JUSTICIAR_LEGGUARDS - /* ======================================================================== */ /* encounter state */ /* ======================================================================== */ @@ -631,7 +627,7 @@ typedef struct { /* gear state */ InfWeaponSet weapon_set; EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; - int armor_tank; /* 1 = justiciar overlay active */ + 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 */ @@ -812,17 +808,13 @@ static void inf_reset(EncounterState* state, uint32_t seed) { s->player.current_attack = 99; s->player.current_strength = 99; s->player.current_defence = 99; - /* start in mage gear (kodai + crystal shield + ancestral) */ + 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); { - uint8_t tank_extra[NUM_GEAR_SLOTS]; - memset(tank_extra, ITEM_NONE, NUM_GEAR_SLOTS); - tank_extra[GEAR_SLOT_HEAD] = INF_TANK_HEAD; - tank_extra[GEAR_SLOT_BODY] = INF_TANK_BODY; - tank_extra[GEAR_SLOT_LEGS] = INF_TANK_LEGS; - encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, tank_extra); + 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 @@ -842,7 +834,6 @@ static void inf_reset(EncounterState* state, uint32_t seed) { osrs_interaction_init(&s->interaction); s->player.spec_armed = 0; s->player.special_energy = 100; - s->player.special_regen_ticks = 0; s->player.run_energy = 10000; /* full run energy (OSRS stores as 0-10000) */ s->last_hit_by_type = -1; @@ -2138,11 +2129,8 @@ static void inf_tick_player(InfernoState* s, const int* actions) { GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; encounter_apply_loadout(&s->player, INF_LOADOUTS[new_set], gs); } else if (gear_act == 4) { - /* tank overlay: justiciar head/body/legs on current loadout */ - s->armor_tank = 1; - s->player.equipped[GEAR_SLOT_HEAD] = INF_TANK_HEAD; - s->player.equipped[GEAR_SLOT_BODY] = INF_TANK_BODY; - s->player.equipped[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + /* reserved tank slot kept in the action space for compatibility */ + s->armor_tank = 0; } /* auto-detect gear switch from direct inventory equip (human mode). @@ -2172,7 +2160,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { s->spell_choice = ENCOUNTER_SPELL_BLOOD; /* special energy regen: 10 energy every 50 ticks (30 seconds) */ - encounter_tick_spec_regen(&s->player, 0); + encounter_tick_spec_regen(&s->player); /* spec toggle: arm/disarm (does NOT interrupt interaction) */ if (actions[INF_HEAD_SPEC] == 1) @@ -2332,7 +2320,28 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* barrage spells: 3x3 AoE via shared osrs_barrage_resolve. ice barrage: freeze on hit (including 0 dmg), not on splash. blood barrage: heal 25% of total AoE damage (applied when hits land). */ - int mage_att_roll = ls->eff_level * (ls->attack_bonus + 64); + 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]; @@ -2366,9 +2375,22 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* resolve barrage: accuracy/damage rolls + instant freeze for ice. freeze is applied by the shared function at cast time. */ BarrageResult br = osrs_barrage_resolve( - btargets, bt_count, mage_att_roll, ls->max_hit, - &s->rng_state, s->spell_choice); + 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++) { @@ -2384,19 +2406,25 @@ static void inf_tick_player(InfernoState* s, const int* actions) { } } else if (s->weapon_set == INF_GEAR_TBOW) { - /* tbow: single target, scale by target magic level. - ls->max_hit is BASE max hit before tbow scaling. */ const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; - int tbow_m = ns->magic_level > ns->magic_def_bonus - ? ns->magic_level : ns->magic_def_bonus; - if (tbow_m > 250) tbow_m = 250; - float acc_mult = osrs_tbow_acc_mult(tbow_m); - float dmg_mult = osrs_tbow_dmg_mult(tbow_m); - int att_roll = (int)(ls->eff_level * (ls->attack_bonus + 64) * acc_mult); + 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); - int max_hit = (int)(ls->max_hit * dmg_mult); - if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { - total_dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + 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; @@ -3188,10 +3216,10 @@ static void inf_write_mask(EncounterState* state, float* mask) { /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ mask[offset++] = 1.0f; /* no_switch always valid */ - mask[offset++] = (s->weapon_set != INF_GEAR_MAGE || s->armor_tank) ? 1.0f : 0.0f; - mask[offset++] = (s->weapon_set != INF_GEAR_TBOW || s->armor_tank) ? 1.0f : 0.0f; - mask[offset++] = (s->weapon_set != INF_GEAR_BP || s->armor_tank) ? 1.0f : 0.0f; - mask[offset++] = 1.0f; /* tank toggle always allowed */ + 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 */ diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index 4b5bffb84d..ce7ba33148 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -527,10 +527,6 @@ typedef struct { * carries over between forms (magic defence is a stat, not a level). */ int magic_def_drain; - /* confliction gauntlets: primed after a magic miss, next magic attack - * rolls accuracy twice (like osmumten's fang). cleared on next magic attack. */ - int confliction_primed; - /* thrall (arceuus greater ghost): auto-attacks zulrah every 4 ticks, * always hits 0-3, ignores armour. auto-resummons after expiry + cooldown. */ int thrall_active; @@ -645,21 +641,27 @@ static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle styl s->total_damage_received += damage; s->player.hit_style = style; - /* ring of recoil / ring of suffering (i) */ - int has_recoil = osrs_has_recoil_ring(s->player.equipped); - if (attacker && has_recoil && s->player.recoil_charges > 0) { - int recoil = damage / 10 + 1; - if (recoil > s->player.recoil_charges) { - recoil = s->player.recoil_charges; - } - encounter_damage_player(attacker, recoil, NULL); - - if (s->player.equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { - s->player.recoil_charges -= recoil; - if (s->player.recoil_charges <= 0) { - s->player.recoil_charges = 0; - s->player.equipped[GEAR_SLOT_RING] = ITEM_NONE; + 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); } } } @@ -837,9 +839,10 @@ static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* de *def_ranged = m->ranged_def; } -static int zul_player_attack_hits(ZulrahState* s, int is_mage) { - const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; - int att_roll = osrs_player_att_roll(ls->eff_level, ls->attack_bonus); +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; @@ -855,10 +858,7 @@ static int zul_player_attack_hits(ZulrahState* s, int is_mage) { 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; - /* confliction gauntlets: double accuracy roll on primed magic attacks (tier 2 only). - * primed = previous magic attack missed. eye of ayak is one-handed so effect applies. */ - if (is_mage && s->confliction_primed && s->gear_tier == 2) { - s->confliction_primed = 0; + if (attack_effects->use_double_accuracy) { return encounter_rand_float(&s->rng_state) < osrs_hit_chance_double(att_roll, def_roll); } @@ -873,29 +873,54 @@ static void zul_player_attack(ZulrahState* s, int is_mage) { int gear_ok = (is_mage && s->player_gear == ZUL_GEAR_MAGE) || (!is_mage && s->player_gear == ZUL_GEAR_RANGE); const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; + 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 max_hit = ls->max_hit; int dmg = 0; - int hit = zul_player_attack_hits(s, is_mage); + int hit = zul_player_attack_hits(s, is_mage, &attack_effects); if (hit) { - dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + 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; - /* sang staff passive (tier 1 mage): 1/6 chance to heal 50% of damage dealt */ - if (is_mage && s->gear_tier == 1 && dmg > 0 && encounter_rand_int(&s->rng_state, 6) == 0) { - int heal = dmg / 2; - s->player.current_hitpoints += heal; + } + { + 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; } } - /* confliction gauntlets: prime on magic miss, clear on magic hit */ - if (is_mage && s->gear_tier == 2) { - s->confliction_primed = !hit; - } s->player.just_attacked = 1; s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; s->player.attack_style_this_tick = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; @@ -1826,10 +1851,6 @@ static void zul_reset(EncounterState* state, uint32_t seed) { 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); - int r = s->player.equipped[GEAR_SLOT_RING]; - s->player.recoil_charges = - (r == ITEM_RING_OF_RECOIL || r == ITEM_RING_OF_SUFFERING_RI) ? RECOIL_MAX_CHARGES : 0; - /* zulrah */ s->zulrah.entity_type = ENTITY_NPC; s->zulrah.npc_def_id = 2042; diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h index a98de98348..63e3de68e2 100644 --- a/ocean/osrs/osrs_combat.h +++ b/ocean/osrs/osrs_combat.h @@ -59,6 +59,8 @@ static inline float osrs_hit_chance(int att_roll, int def_roll) { 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. */ @@ -154,7 +156,8 @@ typedef struct { 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 spell_type, + int primary_use_double_accuracy ) { BarrageResult result = { 0, 0, 0 }; @@ -164,7 +167,9 @@ static inline BarrageResult osrs_barrage_resolve( int px = targets[0].x, py = targets[0].y; { int def_roll = (targets[0].def_level + 8) * (targets[0].magic_def_bonus + 64); - float chance = osrs_hit_chance(att_roll, def_roll); + 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; diff --git a/ocean/osrs/osrs_damage.h b/ocean/osrs/osrs_damage.h index 1aaaa40918..edabc38c15 100644 --- a/ocean/osrs/osrs_damage.h +++ b/ocean/osrs/osrs_damage.h @@ -40,8 +40,34 @@ typedef struct { 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. @@ -67,34 +93,17 @@ static inline DamageResult osrs_apply_damage_pipeline( int target_has_recoil, int attacker_smite_active ) { - DamageResult r = {0, 0, 0, 0, 0}; - - /* 1. prayer reduction */ int prayer_correct = encounter_prayer_correct_for_style(target_prayer, attack_style); - r.prayer_blocked = prayer_correct; - r.final_damage = osrs_prayer_reduce_damage(raw_damage, target_prayer, attack_style, is_pvp); - - /* 2. vengeance: 75% of post-prayer damage reflected to attacker. - ref: osrs_pvp_combat.h:607-618 */ - if (target_veng_active && r.final_damage > 0) { - r.veng_damage = (int)(r.final_damage * 0.75f); - } - - /* 3. recoil: floor(damage * 0.1) + 1 reflected to attacker. - charge tracking is caller's responsibility (ring of recoil has 40 charges, - ring of suffering (i) has infinite). - ref: osrs_pvp_combat.h:621-645, encounter_zulrah.h:660-675 */ - if (target_has_recoil && r.final_damage > 0) { - r.recoil_damage = r.final_damage / 10 + 1; - } - - /* 4. smite: floor(damage / 4) drained from target prayer. - ref: osrs_pvp_combat.h:688-691 */ - if (attacker_smite_active && r.final_damage > 0) { - r.smite_drain = r.final_damage / 4; - } - - return r; + 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 + ); } /* ======================================================================== */ diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index ef9697db90..10eb32347e 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -63,6 +63,7 @@ #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 */ @@ -1392,24 +1393,9 @@ static inline void encounter_recompute_loadout_max_hits( /* lightbearer halves regen interval to 25 ticks. */ /* ======================================================================== */ -#define SPEC_REGEN_INTERVAL 50 /* ticks between +10% regen (normal) */ -#define SPEC_REGEN_LIGHTBEARER 25 /* with lightbearer equipped */ -#define SPEC_REGEN_AMOUNT 10 /* energy restored per regen tick */ - -/** tick special attack energy regeneration. call once per game tick. - lightbearer: set to 1 if player has lightbearer ring equipped. */ -static inline void encounter_tick_spec_regen(Player* p, int has_lightbearer) { - if (p->special_energy >= 100) { - p->special_regen_ticks = 0; - return; - } - int interval = has_lightbearer ? SPEC_REGEN_LIGHTBEARER : SPEC_REGEN_INTERVAL; - p->special_regen_ticks++; - if (p->special_regen_ticks >= interval) { - p->special_energy += SPEC_REGEN_AMOUNT; - if (p->special_energy > 100) p->special_energy = 100; - p->special_regen_ticks = 0; - } +/** 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), @@ -1432,6 +1418,7 @@ static inline void encounter_apply_loadout( 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). diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 2b433e8823..ed8fee63e0 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -646,6 +646,7 @@ static const char* gui_item_short_name(uint8_t item_idx) { 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"; @@ -659,6 +660,10 @@ static const char* gui_item_short_name(uint8_t item_idx) { 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"; 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 index 85180d360e..002c21cba8 100644 --- a/ocean/osrs/osrs_items.h +++ b/ocean/osrs/osrs_items.h @@ -34,6 +34,18 @@ typedef enum { 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; + // ============================================================================ // ITEM STRUCT // ============================================================================ @@ -58,6 +70,7 @@ typedef struct { int16_t ranged_strength; int16_t magic_damage; // Magic damage % bonus int16_t prayer; + uint32_t effect_mask; } Item; // ============================================================================ @@ -76,9 +89,9 @@ typedef struct { // 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, + [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, ITEM_NONE}, + ITEM_NONE, ITEM_NONE, ITEM_NONE}, [SLOT_CAPE] = {ITEM_GOD_CAPE, ITEM_INFERNAL_CAPE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, [SLOT_NECK] = {ITEM_GLORY, ITEM_FURY, ITEM_OCCULT_NECKLACE, @@ -86,36 +99,39 @@ static const uint8_t ITEMS_BY_SLOT[NUM_EQUIPMENT_SLOTS][MAX_ITEMS_PER_SLOT_DB] = [SLOT_WEAPON] = {ITEM_WHIP, ITEM_RUNE_CROSSBOW, ITEM_AHRIM_STAFF, ITEM_DRAGON_DAGGER, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_STAFF_OF_DEAD, ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF, ITEM_ZURIELS_STAFF}, - [SLOT_BODY] = {ITEM_BLACK_DHIDE_BODY, ITEM_MYSTIC_TOP, ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, - ITEM_KARILS_TOP, - ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, - [SLOT_SHIELD] = {ITEM_DRAGON_DEFENDER, ITEM_SPIRIT_SHIELD, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_MAGES_BOOK, - ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, - [SLOT_LEGS] = {ITEM_RUNE_PLATELEGS, ITEM_MYSTIC_BOTTOM, ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, + [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_HANDS] = {ITEM_BARROWS_GLOVES, - ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, - [SLOT_FEET] = {ITEM_CLIMBING_BOOTS, ITEM_ETERNAL_BOOTS, - ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, - [SLOT_RING] = {ITEM_BERSERKER_RING, ITEM_SEERS_RING_I, ITEM_LIGHTBEARER, - ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, [SLOT_AMMO] = {ITEM_DIAMOND_BOLTS_E, ITEM_DRAGON_ARROWS, ITEM_OPAL_DRAGON_BOLTS, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, }; // Number of items per slot in the static DB table above static const uint8_t NUM_ITEMS_IN_SLOT[NUM_EQUIPMENT_SLOTS] = { - [SLOT_HEAD] = 6, // neitiznot, ancestral hat, torag/dharok/verac/guthan helms + [SLOT_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] = 5, // dhide, mystic, ancestral, ahrim, karil - [SLOT_SHIELD] = 4, // defender, spirit, blessed spirit, mages book - [SLOT_LEGS] = 8, // rune, mystic, ancestral, ahrim, bandos, torag/dharok/verac legs - [SLOT_HANDS] = 1, // barrows gloves - [SLOT_FEET] = 2, // climbing boots, eternal boots - [SLOT_RING] = 3, // berserker, seers (i), lightbearer + [SLOT_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) }; @@ -165,6 +181,10 @@ static inline int get_item_attack_style(uint8_t item_index) { 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: @@ -172,6 +192,9 @@ static inline int get_item_attack_style(uint8_t item_index) { 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 @@ -188,6 +211,10 @@ static inline int item_is_two_handed(uint8_t item_index) { 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; diff --git a/ocean/osrs/osrs_items_generated.h b/ocean/osrs/osrs_items_generated.h index a4ad6b0ec3..f294b7c5f0 100644 --- a/ocean/osrs/osrs_items_generated.h +++ b/ocean/osrs/osrs_items_generated.h @@ -142,8 +142,9 @@ typedef enum { ITEM_RUNE_ARROW = 129, /* Rune arrow */ ITEM_DRAGON_JAVELIN = 130, /* Dragon javelin */ ITEM_SPECTRAL_SPIRIT_SHIELD = 131, /* Spectral spirit shield */ - ITEM_DRAGONFIRE_SHIELD = 132, /* Dragonfire shield */ - NUM_ITEMS = 133, + ITEM_ELYSIAN_SPIRIT_SHIELD = 132, /* Elysian spirit shield */ + ITEM_DRAGONFIRE_SHIELD = 133, /* Dragonfire shield */ + NUM_ITEMS = 134, ITEM_NONE = 255 } ItemIndex; @@ -155,7 +156,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -164,7 +165,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -173,7 +174,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -182,7 +183,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -191,7 +192,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -200,7 +201,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -209,7 +210,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -218,7 +219,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -227,7 +228,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -236,7 +237,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -245,7 +246,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -254,7 +255,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -263,7 +264,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -272,7 +273,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -281,7 +282,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -290,7 +291,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -299,7 +300,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -308,7 +309,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -317,7 +318,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -326,7 +327,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -335,7 +336,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -344,7 +345,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -353,7 +354,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -362,7 +363,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -371,7 +372,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -380,7 +381,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -389,7 +390,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -398,7 +399,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -407,7 +408,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -416,7 +417,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -425,7 +426,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -434,7 +435,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -443,7 +444,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -452,7 +453,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -461,7 +462,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -470,7 +471,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -479,7 +480,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -488,7 +489,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -497,7 +498,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -506,7 +507,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -515,7 +516,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -524,7 +525,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -533,7 +534,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -542,7 +543,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -551,7 +552,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -560,7 +561,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -569,7 +570,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -578,7 +579,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -587,7 +588,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -596,7 +597,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -605,7 +606,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -614,7 +615,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -623,7 +624,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -632,7 +633,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -641,7 +642,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -650,7 +651,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -659,7 +660,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -668,7 +669,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -677,7 +678,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -686,7 +687,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -695,7 +696,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -704,7 +705,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -713,7 +714,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -722,7 +723,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -731,7 +732,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -740,7 +741,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -749,7 +750,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -758,7 +759,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -767,7 +768,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -776,7 +777,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -785,7 +786,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -794,7 +795,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -803,7 +804,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -812,7 +813,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -821,7 +822,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -830,7 +831,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -839,7 +840,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -848,7 +849,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -857,7 +858,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -866,7 +867,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -875,7 +876,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -884,7 +885,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -893,7 +894,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -902,7 +903,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -911,7 +912,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -920,7 +921,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -929,7 +930,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -938,7 +939,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -947,7 +948,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -956,7 +957,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -965,7 +966,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -974,7 +975,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -983,7 +984,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -992,7 +993,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1001,7 +1002,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1010,7 +1011,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1019,7 +1020,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1028,7 +1029,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1037,7 +1038,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1046,7 +1047,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1055,7 +1056,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1064,7 +1065,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1073,7 +1074,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1082,7 +1083,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1091,7 +1092,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1100,7 +1101,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1109,7 +1110,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1118,7 +1119,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1127,7 +1128,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1136,7 +1137,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1145,7 +1146,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1154,7 +1155,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1163,7 +1164,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1172,7 +1173,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1181,7 +1182,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1190,7 +1191,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1199,7 +1200,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1208,7 +1209,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1217,7 +1218,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1226,7 +1227,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1235,7 +1236,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1244,7 +1245,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1253,7 +1254,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1262,7 +1263,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1271,7 +1272,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1280,7 +1281,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1289,7 +1290,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1298,7 +1299,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1307,7 +1308,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1316,7 +1317,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1325,7 +1326,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1334,7 +1335,16 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .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, @@ -1343,7 +1353,7 @@ static const Item ITEM_DATABASE[NUM_ITEMS] = { .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 + .melee_strength = 7, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE }, }; diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h index 965215b23f..93c0277afb 100644 --- a/ocean/osrs/osrs_pvp_actions.h +++ b/ocean/osrs/osrs_pvp_actions.h @@ -256,26 +256,10 @@ static void update_timers(Player* p) { p->run_recovery_ticks = 0; } - int has_lightbearer = is_lightbearer_equipped(p); - if (has_lightbearer != p->was_lightbearer_equipped) { - if (has_lightbearer) { - if (p->special_regen_ticks > 25) { - p->special_regen_ticks = 0; - } - } else { - p->special_regen_ticks = 0; - } - p->was_lightbearer_equipped = has_lightbearer; - } if (p->spec_regen_active && p->special_energy < 100) { - int regen_interval = has_lightbearer ? 25 : 50; - p->special_regen_ticks += 1; - if (p->special_regen_ticks >= regen_interval) { - p->special_energy = clamp(p->special_energy + 10, 0, 100); - p->special_regen_ticks = 0; - } + encounter_tick_spec_regen(p); } else if (p->spec_regen_active) { - p->special_regen_ticks = 0; + p->item_effect_state.special_regen_ticks = 0; } } diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h index 3d3ecf8801..f9c3ee6166 100644 --- a/ocean/osrs/osrs_pvp_api.h +++ b/ocean/osrs/osrs_pvp_api.h @@ -55,11 +55,10 @@ static void init_player(Player* p) { p->current_hitpoints = p->base_hitpoints; p->special_energy = 100; - p->special_regen_ticks = 0; p->spec_regen_active = 0; - p->was_lightbearer_equipped = 0; p->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; @@ -92,7 +91,6 @@ static void init_player(Player* p) { p->veng_active = 0; p->veng_cooldown = 0; - p->recoil_charges = 0; p->prayer = PRAYER_NONE; p->offensive_prayer = OFFENSIVE_PRAYER_NONE; @@ -200,7 +198,6 @@ static void init_player(Player* p) { p->is_lunar_spellbook = 0; p->observed_target_lunar_spellbook = 0; p->has_blood_fury = 1; - p->has_dharok = 0; p->melee_spec_weapon = MELEE_SPEC_NONE; p->ranged_spec_weapon = RANGED_SPEC_NONE; @@ -411,8 +408,7 @@ void pvp_reset(OsrsEnv* env) { for (int i = 0; i < NUM_AGENTS; i++) { init_player_gear_randomized(&env->players[i], tiers[i], &env->rng_state); env->players[i].food_count = compute_food_count(&env->players[i]); - env->players[i].recoil_charges = - osrs_has_recoil_ring(env->players[i].equipped) ? RECOIL_MAX_CHARGES : 0; + osrs_refresh_player_equipment(&env->players[i]); } // Reset C-side opponent state for new episode diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h index f36280ef88..1fa3353d07 100644 --- a/ocean/osrs/osrs_pvp_combat.h +++ b/ocean/osrs/osrs_pvp_combat.h @@ -404,8 +404,9 @@ static int calculate_max_hit(Player* p, AttackStyle style, float str_mult, int m max_hit = (int)(osrs_player_melee_max_hit(eff_strength, strength_bonus) * str_mult); } - /* Dharok set effect: quadratic scaling with missing HP */ - if (p->has_dharok && style == ATTACK_STYLE_MELEE) { + 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)); } @@ -524,14 +525,19 @@ static void apply_damage(OsrsEnv* env, int attacker_idx, int defender_idx, Player* attacker = &env->players[attacker_idx]; Player* defender = &env->players[defender_idx]; - /* shared damage pipeline: prayer → veng → recoil → smite */ - DamageResult dr = osrs_apply_damage_pipeline( + 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, - osrs_has_recoil_ring(defender->equipped) && defender->recoil_charges > 0, attacker->prayer == PRAYER_SMITE && !defender->is_lms + , + &defender->equipment_effect_profile, + &defender->item_effect_state, + &env->rng_state ); int damage = dr.final_damage; @@ -559,9 +565,12 @@ static void apply_damage(OsrsEnv* env, int attacker_idx, int defender_idx, } /* apply recoil reflection + charge tracking */ - if (dr.recoil_damage > 0 && defender->recoil_charges > 0) { + if (dr.recoil_damage > 0) { int recoil = dr.recoil_damage; - if (recoil > defender->recoil_charges) recoil = defender->recoil_charges; + 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; @@ -569,15 +578,7 @@ static void apply_damage(OsrsEnv* env, int attacker_idx, int defender_idx, defender->total_damage_dealt += recoil_scale; attacker->damage_received_scale += recoil_scale; defender->damage_dealt_scale += recoil_scale; - - /* ring of suffering (i) has infinite charges; ring of recoil shatters */ - if (defender->equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { - defender->recoil_charges -= recoil; - if (defender->recoil_charges <= 0) { - defender->recoil_charges = 0; - defender->equipped[GEAR_SLOT_RING] = ITEM_NONE; - } - } + osrs_consume_recoil_charges(defender, recoil); } /* apply damage to defender */ @@ -1004,7 +1005,7 @@ static void perform_attack(OsrsEnv* env, int attacker_idx, int defender_idx, attacker->special_energy -= spec_cost; if (!attacker->spec_regen_active && attacker->special_energy < 100) { attacker->spec_regen_active = 1; - attacker->special_regen_ticks = 0; + attacker->item_effect_state.special_regen_ticks = 0; } } } diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h index 3d5f98f12e..bd73aea9ec 100644 --- a/ocean/osrs/osrs_pvp_gear.h +++ b/ocean/osrs/osrs_pvp_gear.h @@ -13,6 +13,7 @@ #include "osrs_items.h" #include "osrs_inventory.h" #include "osrs_combat.h" +#include "osrs_item_effects.h" // ============================================================================ // MELEE SPEC WEAPON BONUS TYPES @@ -166,32 +167,12 @@ static const uint8_t MAGE_RING_PRIORITY[] = {ITEM_LIGHTBEARER, ITEM_SEERS_RING_I static inline GearBonuses compute_slot_gear_bonuses(Player* p) { EquipmentBonuses eb; osrs_sum_equipment_bonuses(p->equipped, &eb); - - GearBonuses total = {0}; - total.stab_attack = eb.attack_stab; - total.slash_attack = eb.attack_slash; - total.crush_attack = eb.attack_crush; - total.magic_attack = eb.attack_magic; - total.ranged_attack = eb.attack_ranged; - total.stab_defence = eb.defence_stab; - total.slash_defence = eb.defence_slash; - total.crush_defence = eb.defence_crush; - total.magic_defence = eb.defence_magic; - total.ranged_defence = eb.defence_ranged; - total.melee_strength = eb.melee_strength; - total.ranged_strength = eb.ranged_strength; - total.magic_strength = eb.magic_damage; - total.attack_speed = eb.attack_speed; - total.attack_range = eb.attack_range; - return total; + 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) { - if (p->slot_gear_dirty) { - p->slot_cached_bonuses = compute_slot_gear_bonuses(p); - p->slot_gear_dirty = 0; - } + osrs_ensure_player_equipment(p); return &p->slot_cached_bonuses; } @@ -318,6 +299,7 @@ static inline int slot_equip_item(Player* p, int gear_slot, uint8_t item_idx) { p->equipped[GEAR_SLOT_SHIELD] = ITEM_NONE; } + osrs_refresh_player_equipment(p); return 1; } @@ -698,7 +680,7 @@ static inline void init_slot_equipment_lms(Player* p) { p->inventory[GEAR_SLOT_RING][0] = ITEM_BERSERKER_RING; p->num_items_in_slot[GEAR_SLOT_RING] = 1; - p->slot_gear_dirty = 1; + osrs_refresh_player_equipment(p); p->current_gear = GEAR_MELEE; } @@ -1081,7 +1063,7 @@ static inline void init_player_gear_randomized(Player* p, int tier, uint32_t* rn slot_equip_item(p, DYNAMIC_GEAR_SLOTS[i], resolved[i]); } - p->slot_gear_dirty = 1; + osrs_refresh_player_equipment(p); p->current_gear = GEAR_MELEE; } diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h index 314efa6d0e..acbffa53cf 100644 --- a/ocean/osrs/osrs_pvp_observations.h +++ b/ocean/osrs/osrs_pvp_observations.h @@ -563,7 +563,8 @@ static void generate_slot_observations(OsrsEnv* env, int agent_idx) { obs[115] = (p->magic_spec_weapon != MAGIC_SPEC_NONE) ? 1.0f : 0.0f; obs[116] = (p->ranged_spec_weapon != RANGED_SPEC_NONE) ? 1.0f : 0.0f; obs[117] = p->has_blood_fury ? 1.0f : 0.0f; - obs[118] = p->has_dharok ? 1.0f : 0.0f; + 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); diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 74556d485a..848a9f8374 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -523,6 +523,56 @@ typedef enum { 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; + // ============================================================================ // PLAYER / ENTITY STRUCT // ============================================================================ @@ -557,11 +607,10 @@ typedef struct { // Special attack state int special_energy; - int special_regen_ticks; int spec_regen_active; - int was_lightbearer_equipped; 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 @@ -607,11 +656,6 @@ typedef struct { int veng_active; int veng_cooldown; - // Ring of recoil: reflects floor(damage * 0.1) + 1 back to attacker. - // charges track remaining recoil damage the ring can deal (starts at 40). - // at 0 the ring shatters (ring of suffering (i) never shatters). - int recoil_charges; - // Prayer and style OverheadPrayer prayer; OffensivePrayer offensive_prayer; @@ -737,7 +781,6 @@ typedef struct { int is_lunar_spellbook; int observed_target_lunar_spellbook; int has_blood_fury; - int has_dharok; // Spec weapons MeleeSpecWeapon melee_spec_weapon; @@ -761,6 +804,7 @@ typedef struct { // 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 @@ -1184,9 +1228,9 @@ static inline float confidence_scale(int count) { return (float)count / 10.0f; } -/** Check if lightbearer ring is equipped (ITEM_LIGHTBEARER = 49). */ +/** Check if lightbearer spec regeneration is active from equipped gear. */ static inline int is_lightbearer_equipped(Player* p) { - return p->equipped[GEAR_SLOT_RING] == 49; + return p->equipment_effect_profile.spec_regen_mode == OSRS_SPEC_REGEN_MODE_LIGHTBEARER; } #define RECOIL_MAX_CHARGES 40 diff --git a/ocean/osrs/scripts/ExportItemSprites.java b/ocean/osrs/scripts/ExportItemSprites.java index 6fe57424e2..07f023542c 100644 --- a/ocean/osrs/scripts/ExportItemSprites.java +++ b/ocean/osrs/scripts/ExportItemSprites.java @@ -2,14 +2,14 @@ * Export item inventory sprites from OpenRS2 flat cache using RuneLite's * ItemSpriteFactory (3D model → 2D sprite rendering). * - * Reads the modern cache at reference/osrs-cache-modern/ (OpenRS2 flat format: + * 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 ../reference/osrs-cache-modern \ + * --cache .refs/osrs-cache-modern \ * --output data/sprites/items \ * --ids 4151,10828,21795,... */ @@ -117,7 +117,7 @@ public void close() throws IOException {} public class ExportItemSprites { public static void main(String[] args) throws Exception { - String cachePath = "../reference/osrs-cache-modern"; + String cachePath = ".refs/osrs-cache-modern"; String outputPath = "data/sprites/items"; String idsArg = null; diff --git a/ocean/osrs/scripts/export_collision_map_modern.py b/ocean/osrs/scripts/export_collision_map_modern.py index a38fa6a9c6..6f81fc5702 100644 --- a/ocean/osrs/scripts/export_collision_map_modern.py +++ b/ocean/osrs/scripts/export_collision_map_modern.py @@ -6,22 +6,22 @@ Usage: uv run python scripts/export_collision_map_modern.py \ - --cache ../reference/osrs-cache-modern \ - --keys ../reference/osrs-cache-modern/keys.json \ + --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 ../reference/osrs-cache-modern \ - --keys ../reference/osrs-cache-modern/keys.json \ + --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 ../reference/osrs-cache-modern \ - --keys ../reference/osrs-cache-modern/keys.json \ + --cache ../../../.refs/osrs-cache-modern \ + --keys ../../../.refs/osrs-cache-modern/keys.json \ --output data/wilderness.cmap \ --wilderness """ @@ -46,6 +46,9 @@ 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 @@ -767,13 +770,13 @@ def main() -> None: parser.add_argument( "--cache", type=Path, - default=Path("../reference/osrs-cache-modern"), + default=DEFAULT_MODERN_CACHE, help="path to modern cache directory", ) parser.add_argument( "--keys", type=Path, - default=Path("../reference/osrs-cache-modern/keys.json"), + default=DEFAULT_MODERN_KEYS, help="path to XTEA keys JSON from OpenRS2", ) parser.add_argument( diff --git a/ocean/osrs/scripts/export_items.sh b/ocean/osrs/scripts/export_items.sh index ee399a7342..de708d9552 100755 --- a/ocean/osrs/scripts/export_items.sh +++ b/ocean/osrs/scripts/export_items.sh @@ -12,7 +12,7 @@ # ./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 data/sprites/items/.png. +# Java 11+ and curl. Output goes to repo-root data/sprites/items/.png. set -eo pipefail @@ -32,9 +32,10 @@ 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="$OSRS_DIR/data/sprites/items" +OUTPUT_DIR="$REPO_ROOT/data/sprites/items" mkdir -p "$BUILD_DIR" "$DEPS_DIR" "$OUTPUT_DIR" diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py index 4e2669f549..ff37f5f7da 100644 --- a/ocean/osrs/scripts/export_models.py +++ b/ocean/osrs/scripts/export_models.py @@ -11,7 +11,7 @@ Usage: uv run python scripts/export_models.py \ - --modern-cache ../reference/osrs-cache-modern \ + --modern-cache ../../../.refs/osrs-cache-modern \ --output data/equipment.models """ @@ -1589,6 +1589,10 @@ def write_player_model_header( 22328, # Justiciar legguards 4224, # Crystal shield 13237, # Pegasian boots + 12817, # Elysian spirit shield + 26243, # Virtus robe top + 26245, # Virtus robe bottom + 28310, # Venator ring ] diff --git a/ocean/osrs/scripts/export_sprites_modern.py b/ocean/osrs/scripts/export_sprites_modern.py index 4d8c015899..115e4d72e2 100644 --- a/ocean/osrs/scripts/export_sprites_modern.py +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -13,7 +13,7 @@ Usage: uv run python scripts/export_sprites_modern.py \ - --cache ../reference/osrs-cache-modern \ + --cache ../../../.refs/osrs-cache-modern \ --output data/sprites/gui """ @@ -28,6 +28,8 @@ 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: @@ -283,7 +285,7 @@ def main() -> None: """Export GUI sprites from modern OSRS cache.""" parser = argparse.ArgumentParser(description="Export OSRS GUI sprites") parser.add_argument( - "--cache", default="../reference/osrs-cache-modern", + "--cache", default=DEFAULT_MODERN_CACHE, help="Path to modern cache directory", ) parser.add_argument( diff --git a/ocean/osrs/scripts/modern_cache_reader.py b/ocean/osrs/scripts/modern_cache_reader.py index 9877a2e24d..d9efa924eb 100644 --- a/ocean/osrs/scripts/modern_cache_reader.py +++ b/ocean/osrs/scripts/modern_cache_reader.py @@ -21,6 +21,8 @@ from dataclasses import dataclass, field from pathlib import Path +DEFAULT_MODERN_CACHE = Path(__file__).resolve().parents[3] / ".refs" / "osrs-cache-modern" + # --- binary reading helpers --- @@ -609,7 +611,7 @@ def parse_sequence(seq_id: int, data: bytes) -> SequenceDef: def main() -> None: """Test the modern cache reader against a local cache.""" - cache_path = "../reference/osrs-cache-modern/" + cache_path = DEFAULT_MODERN_CACHE print(f"opening cache at {cache_path}") reader = ModernCacheReader(cache_path) diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index 883b576b8e..954a99a5de 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -7,8 +7,8 @@ * derived from the TypeScript reference (.refs/osrs-dps-calc/). * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_combat_math \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_combat_math \ * ocean/osrs/tests/test_combat_math.c -lm * ./test_combat_math * @@ -25,8 +25,8 @@ #include #include -#include "osrs_encounter.h" -#include "osrs_special_attacks.h" +#include "ocean/osrs/osrs_encounter.h" +#include "ocean/osrs/osrs_special_attacks.h" /* ======================================================================== */ /* test harness */ @@ -932,7 +932,7 @@ static void test_barrage_resolve(void) { targets[0].def_level = 100; targets[0].magic_def_bonus = 50; - BarrageResult res = osrs_barrage_resolve(targets, 1, att_roll, max_hit, &rng, 0); + 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); @@ -951,7 +951,7 @@ static void test_barrage_resolve(void) { 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); + 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 */ @@ -971,7 +971,7 @@ static void test_barrage_resolve(void) { 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 */); + 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); diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index f75272eb36..08c3534eba 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -6,8 +6,8 @@ * cross-referenced against osrs-dps-calc reference and OSRS wiki formulas. * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_item_effects \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_item_effects \ * ocean/osrs/tests/test_item_effects.c -lm * ./test_item_effects * @@ -24,7 +24,7 @@ #include #include -#include "osrs_encounter.h" +#include "ocean/osrs/osrs_encounter.h" /* ======================================================================== */ /* test harness (same macros as test_combat_math.c) */ @@ -870,6 +870,90 @@ static void test_loadout_defence_into_def_roll(void) { ASSERT_INT_EQ("def roll vs magic", def_roll_magic, 7811); } +/* ======================================================================== */ +/* test: shared attack prep applies virtus ancient bonus only to ancients */ +/* ======================================================================== */ + +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); +} + +/* ======================================================================== */ +/* test: shared attack prep applies tbow scaling from magic attack bonus */ +/* ======================================================================== */ + +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)) + ); +} + /* ======================================================================== */ /* main */ /* ======================================================================== */ @@ -905,6 +989,8 @@ int main(void) { 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) { 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_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c index adecba7b1e..eae9c5dcd6 100644 --- a/ocean/osrs/tests/test_special_attacks.c +++ b/ocean/osrs/tests/test_special_attacks.c @@ -8,8 +8,8 @@ * defence roll, volatile staff, godsword variants, double-hit specs). * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_special_attacks \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_special_attacks \ * ocean/osrs/tests/test_special_attacks.c -lm * ./test_special_attacks * @@ -25,9 +25,9 @@ #include #include -#include "osrs_pvp_combat.h" -#include "osrs_combat.h" -#include "osrs_special_attacks.h" +#include "ocean/osrs/osrs_pvp_combat.h" +#include "ocean/osrs/osrs_combat.h" +#include "ocean/osrs/osrs_special_attacks.h" /* ======================================================================== */ /* test harness (same pattern as test_combat_math.c) */ @@ -879,7 +879,6 @@ static void test_max_hit_with_spec_mult(void) { p.current_ranged = 99; p.offensive_prayer = OFFENSIVE_PRAYER_NONE; p.fight_style = FIGHT_STYLE_ACCURATE; - p.has_dharok = 0; /* need to set up gear bonuses for strength. use a simple approach: set the slot gear bonuses array directly. */ diff --git a/ocean/osrs/tools/generate_items.py b/ocean/osrs/tools/generate_items.py index f36cb5c06c..9284a475e6 100644 --- a/ocean/osrs/tools/generate_items.py +++ b/ocean/osrs/tools/generate_items.py @@ -42,6 +42,7 @@ bonuses.ranged_str -> ranged_strength bonuses.magic_str -> magic_damage bonuses.prayer -> prayer + (manifest) -> effect_mask """ import argparse @@ -80,10 +81,26 @@ "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.""" - with open(path) as f: + 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: @@ -199,6 +216,8 @@ def generate_header( 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) @@ -241,7 +260,8 @@ def generate_header( 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".prayer = {manual.get('prayer', 0)}, " + f".effect_mask = {effect_mask}" ) lines.append(" },") continue @@ -265,7 +285,7 @@ def generate_header( lines.append(" .defence_magic = 0, .defence_ranged = 0,") lines.append( " .melee_strength = 0, .ranged_strength = 0, " - ".magic_damage = 0, .prayer = 0" + f".magic_damage = 0, .prayer = 0, .effect_mask = {effect_mask}" ) lines.append(" },") continue @@ -326,7 +346,8 @@ def generate_header( 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".prayer = {bon.get('prayer', 0)}, " + f".effect_mask = {effect_mask}" ) lines.append(" },") diff --git a/ocean/osrs/tools/items_manifest.json b/ocean/osrs/tools/items_manifest.json index 3cd6fc56f5..738f99b3b0 100644 --- a/ocean/osrs/tools/items_manifest.json +++ b/ocean/osrs/tools/items_manifest.json @@ -307,7 +307,8 @@ "index": "ITEM_LIGHTBEARER", "item_id": 25975, "attack_range": 0, - "comment": "Lightbearer" + "comment": "Lightbearer", + "effect_tags": ["LIGHTBEARER"] }, { "index": "ITEM_MAGES_BOOK", @@ -331,7 +332,8 @@ "index": "ITEM_DHAROKS_PLATELEGS", "item_id": 4722, "attack_range": 0, - "comment": "Dharok's platelegs" + "comment": "Dharok's platelegs", + "effect_tags": ["DHAROK_PIECE"] }, { "index": "ITEM_VERACS_PLATESKIRT", @@ -349,7 +351,8 @@ "index": "ITEM_DHAROKS_HELM", "item_id": 4716, "attack_range": 0, - "comment": "Dharok's helm" + "comment": "Dharok's helm", + "effect_tags": ["DHAROK_PIECE"] }, { "index": "ITEM_VERACS_HELM", @@ -391,7 +394,8 @@ "index": "ITEM_CONFLICTION_GAUNTLETS", "item_id": 31106, "attack_range": 0, - "comment": "Confliction gauntlets" + "comment": "Confliction gauntlets", + "effect_tags": ["CONFLICTION"] }, { "index": "ITEM_AVERNIC_TREADS", @@ -403,13 +407,15 @@ "index": "ITEM_RING_OF_SUFFERING_RI", "item_id": 20657, "attack_range": 0, - "comment": "Ring of suffering (ri)" + "comment": "Ring of suffering (ri)", + "effect_tags": ["RECOIL_RING"] }, { "index": "ITEM_TWISTED_BOW", "item_id": 20997, "attack_range": 10, - "comment": "Twisted bow" + "comment": "Twisted bow", + "effect_tags": ["TWISTED_BOW"] }, { "index": "ITEM_MASORI_MASK_F", @@ -469,7 +475,8 @@ "index": "ITEM_SANGUINESTI_STAFF", "item_id": 22481, "attack_range": 7, - "comment": "Sanguinesti staff" + "comment": "Sanguinesti staff", + "effect_tags": ["SANG_HEAL"] }, { "index": "ITEM_INFINITY_BOOTS", @@ -487,7 +494,8 @@ "index": "ITEM_RING_OF_RECOIL", "item_id": 2550, "attack_range": 0, - "comment": "Ring of recoil" + "comment": "Ring of recoil", + "effect_tags": ["RECOIL_RING"] }, { "index": "ITEM_CRYSTAL_HELM", @@ -720,19 +728,22 @@ "index": "ITEM_VIRTUS_MASK", "item_id": 26241, "attack_range": 0, - "comment": "Virtus mask" + "comment": "Virtus mask", + "effect_tags": ["VIRTUS_PIECE"] }, { "index": "ITEM_VIRTUS_ROBE_TOP", "item_id": 26243, "attack_range": 0, - "comment": "Virtus robe top" + "comment": "Virtus robe top", + "effect_tags": ["VIRTUS_PIECE"] }, { "index": "ITEM_VIRTUS_ROBE_BOTTOM", "item_id": 26245, "attack_range": 0, - "comment": "Virtus robe bottom" + "comment": "Virtus robe bottom", + "effect_tags": ["VIRTUS_PIECE"] }, { "index": "ITEM_MAGUS_RING", @@ -810,6 +821,13 @@ "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, @@ -817,4 +835,4 @@ "attack_range": 0, "comment": "Dragonfire shield" } -] \ No newline at end of file +] From 9e95dbc29360336b19a1321764d04496586aa66b Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 22 Apr 2026 12:40:33 +0300 Subject: [PATCH 51/60] sync inferno env parity fixes --- build.sh | 2 +- ocean/osrs/README.md | 35 + ocean/osrs/binding.c | 217 ++++++ ocean/osrs/data/npc_models.h | 15 +- ocean/osrs/data/npc_models_inferno.h | 164 ++--- ocean/osrs/encounters/encounter_inferno.h | 322 +++++++-- ocean/osrs/osrs_collision.h | 82 ++- ocean/osrs/osrs_encounter.h | 95 ++- ocean/osrs/osrs_monsters_generated.h | 2 +- ocean/osrs/osrs_pvp_effects.h | 28 +- ocean/osrs/osrs_render.h | 171 ++++- ocean/osrs/scripts/export_all.sh | 237 ++----- ocean/osrs/scripts/export_inferno_npcs.py | 797 ++++++++++++++++++++++ ocean/osrs/scripts/export_models.py | 1 + ocean/osrs/tests/README.md | 76 +++ ocean/osrs/tests/test_bolt_procs.c | 6 +- ocean/osrs/tests/test_collision.c | 14 +- ocean/osrs/tests/test_consumables.c | 4 +- ocean/osrs/tests/test_damage.c | 6 +- ocean/osrs/tests/test_interaction.c | 6 +- ocean/osrs/tests/test_inventory.c | 6 +- ocean/osrs/tests/test_npc_movement.c | 60 +- ocean/osrs/tests/test_player_combat.c | 4 +- ocean/osrs/tools/generate_monsters.py | 16 +- ocean/osrs_inferno/binding.c | 98 +-- pufferlib/config/ocean/osrs_inferno.ini | 183 +++++ 26 files changed, 2108 insertions(+), 539 deletions(-) create mode 100644 ocean/osrs/README.md create mode 100644 ocean/osrs/binding.c create mode 100644 ocean/osrs/scripts/export_inferno_npcs.py create mode 100644 ocean/osrs/tests/README.md create mode 100644 pufferlib/config/ocean/osrs_inferno.ini diff --git a/build.sh b/build.sh index 6b52aafff0..72ab057649 100755 --- a/build.sh +++ b/build.sh @@ -126,7 +126,7 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v5/osrs-assets-v5.tar.gz" + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v6/osrs-assets-v6.tar.gz" mkdir -p data curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi 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..9d69adcffd --- /dev/null +++ b/ocean/osrs/binding.c @@ -0,0 +1,217 @@ +/** + * @file binding.c + * @brief Metal static-native binding for OSRS PVP environment + * + * Bridges vecenv.h's contract (float actions, float terminals) with the PVP + * env's internal types (int actions, unsigned char terminals) using a wrapper + * struct. PVP source headers are untouched. + */ + +#include "osrs_env.h" + +/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. + * vecenv.h's create_static_vec assigns to env->observations, env->actions, + * env->rewards, env->terminals directly. These fields must match vecenv's + * expected types (void*, float*, float*, float*). The embedded OsrsEnv has + * its own identically-named fields with different types — pvp_init sets those + * to internal inline buffers, so there's no conflict. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsEnv pvp; + + /* staging buffers for type conversion */ + int ocean_acts_staging[NUM_ACTION_HEADS]; + unsigned char ocean_term_staging; +} MetalPvpEnv; + +#define OBS_SIZE OCEAN_OBS_SIZE +#define NUM_ATNS NUM_ACTION_HEADS +#define ACT_SIZES {LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env MetalPvpEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h + * because vecenv.h calls them inside its implementation section without + * forward-declaring them (they're expected to come from the env header). */ + +void c_step(Env* env) { + /* float actions from vecenv → int staging for PVP */ + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + /* terminal: unsigned char → float for vecenv */ + env->terminals[0] = (float)env->ocean_term_staging; + + /* copy PVP log to wrapper log on episode end */ + if (env->ocean_term_staging) { + env->log.episode_return = env->pvp.log.episode_return; + env->log.episode_length = env->pvp.log.episode_length; + env->log.wins = env->pvp.log.wins; + env->log.damage_dealt = env->pvp.log.damage_dealt; + env->log.damage_received = env->pvp.log.damage_received; + env->log.n = env->pvp.log.n; + memset(&env->pvp.log, 0, sizeof(env->pvp.log)); + } + + if (env->ocean_term_staging && env->pvp.auto_reset) { + ocean_write_obs(&env->pvp); + } +} + +void c_reset(Env* env) { + /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because + * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ + env->pvp.ocean_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); + + /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside + * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers + * (env->observations, env->actions, env->rewards, env->terminals are NULL + * at this point). c_reset runs after buffer assignment and does the wiring. + * + * For now, point ocean pointers at internal staging so pvp_reset doesn't + * crash on writes to ocean_term/ocean_rew. */ + env->pvp.ocean_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; + + /* config from Dict (all values are double) */ + 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; + + /* reward shaping coefficients (same defaults as ocean_binding.c) */ + env->pvp.shaping.damage_dealt_coef = 0.005f; + env->pvp.shaping.damage_received_coef = -0.005f; + env->pvp.shaping.correct_prayer_bonus = 0.03f; + env->pvp.shaping.wrong_prayer_penalty = -0.02f; + env->pvp.shaping.prayer_switch_no_attack_penalty = -0.01f; + env->pvp.shaping.off_prayer_hit_bonus = 0.03f; + env->pvp.shaping.melee_frozen_penalty = -0.05f; + env->pvp.shaping.wasted_eat_penalty = -0.001f; + env->pvp.shaping.premature_eat_penalty = -0.02f; + env->pvp.shaping.magic_no_staff_penalty = -0.05f; + env->pvp.shaping.gear_mismatch_penalty = -0.05f; + env->pvp.shaping.spec_off_prayer_bonus = 0.02f; + env->pvp.shaping.spec_low_defence_bonus = 0.01f; + env->pvp.shaping.spec_low_hp_bonus = 0.02f; + env->pvp.shaping.smart_triple_eat_bonus = 0.05f; + env->pvp.shaping.wasted_triple_eat_penalty = -0.0005f; + env->pvp.shaping.damage_burst_bonus = 0.002f; + env->pvp.shaping.damage_burst_threshold = 30; + env->pvp.shaping.premature_eat_threshold = 0.7071f; + env->pvp.shaping.ko_bonus = 0.15f; + env->pvp.shaping.wasted_resources_penalty = -0.07f; + env->pvp.shaping.prayer_penalty_enabled = 1; + env->pvp.shaping.click_penalty_enabled = 0; + env->pvp.shaping.click_penalty_threshold = 5; + env->pvp.shaping.click_penalty_coef = -0.003f; + + /* gear: default tier 0 (basic LMS) */ + env->pvp.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 sets up game state (players, positions, gear, etc.) + * but does NOT write to ocean buffers — that happens in c_reset. */ + pvp_reset(&env->pvp); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); +} + +/* ======================================================================== + * PFSP: set/get opponent pool weights across all envs + * Called from Python via pybind11 wrappers in metal_bindings.mm + * ======================================================================== */ + +void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { + Env* envs = (Env*)vec->envs; + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + for (int e = 0; e < vec->size; e++) { + int was_unconfigured = (envs[e].pvp.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]; + } + /* Only reset on first configuration — restarts the episode that was started + * during env creation before the pool was set (would have used fallback opponent). + * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ + if (was_unconfigured) { + c_reset(&envs[e]); + } + } +} + +void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes, int* out_pool_size) { + Env* envs = (Env*)vec->envs; + int pool_size = 0; + + for (int e = 0; e < vec->size; e++) { + if (envs[e].pvp.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; + } + + /* Aggregate and reset (read-and-reset pattern) */ + 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/npc_models.h b/ocean/osrs/data/npc_models.h index 17d9bc24ab..d7eadc30dd 100644 --- a/ocean/osrs/data/npc_models.h +++ b/ocean/osrs/data/npc_models.h @@ -23,6 +23,8 @@ 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) */ @@ -65,6 +67,10 @@ static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { #define INF_GFX_447_ANIM INF_GEN_GFX_447_ANIM #define INF_GFX_448_MODEL INF_GEN_GFX_448_MODEL #define INF_GFX_448_ANIM INF_GEN_GFX_448_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 @@ -89,10 +95,13 @@ static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { #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 -/* tbow projectile — not in inferno group, keep hardcoded */ -#define INF_GFX_942_MODEL 19374 -#define INF_GFX_942_ANIM 5233 +/* 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 */ diff --git a/ocean/osrs/data/npc_models_inferno.h b/ocean/osrs/data/npc_models_inferno.h index a493acaa1c..8ea7338099 100644 --- a/ocean/osrs/data/npc_models_inferno.h +++ b/ocean/osrs/data/npc_models_inferno.h @@ -6,89 +6,95 @@ #include "npc_models.h" /* for NpcModelMapping typedef */ static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { - {7691, 0xC1E0B, 7573, 7574, 7572}, /* Nibbler */ - {7692, 0xC1E0C, 7577, 7578, 7577}, /* Bat */ - {7693, 0xC1E0D, 7586, 7581, 7587}, /* Blob */ - {7694, 0xC1E0E, 7586, 65535, 7587}, /* Blob mage split */ - {7695, 0xC1E0F, 7586, 65535, 7587}, /* Blob range split */ - {7696, 0xC1E10, 7586, 65535, 7587}, /* Blob melee split */ - {7697, 0xC1E11, 7595, 7597, 7596}, /* Meleer */ - {7698, 0xC1E12, 7602, 7605, 7603}, /* Ranger */ - {7699, 0xC1E13, 7609, 7610, 7608}, /* Mager */ - {7700, 0xC1E14, 7589, 7593, 7588}, /* Jad */ - {7701, 0xC1E15, 2636, 65535, 2634}, /* Jad healer */ - {7706, 0xC1E1A, 7564, 7566, 65535}, /* Zuk */ - {7707, 0xC1E1B, 7567, 65535, 7567}, /* Ancestral Glyph */ - {7708, 0xC1E1C, 2867, 65535, 2863}, /* Zuk healer */ + {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_ZUK_DEATH 7562 -#define INF_GEN_ANIM_ZUK_SPAWN 7563 -#define INF_GEN_ANIM_ZUK_DEFEND 7565 -#define INF_GEN_ANIM_ZUK_ATTACK 7566 -#define INF_GEN_ANIM_MOVING_SAFE_SPOT_HIT 7568 -#define INF_GEN_ANIM_MOVING_SAFE_SPOT_DEATH 7569 -#define INF_GEN_ANIM_JALNIB_ATTACK 7574 -#define INF_GEN_ANIM_JALNIB_DEFEND 7575 -#define INF_GEN_ANIM_JALNIB_DEATH 7576 -#define INF_GEN_ANIM_JALMEJRAH_ATTACK 7578 -#define INF_GEN_ANIM_JALMEJRAH_DEFEND 7579 -#define INF_GEN_ANIM_JALMEJRAH_DEATH 7580 -#define INF_GEN_ANIM_JALAK_ATTACK_MAGIC 7581 -#define INF_GEN_ANIM_JALAK_ATTACK_MELEE 7582 -#define INF_GEN_ANIM_JALAK_ATTACK_RANGED 7583 -#define INF_GEN_ANIM_JALAK_DEATH 7584 -#define INF_GEN_ANIM_JALAK_DEFEND 7585 -#define INF_GEN_ANIM_JALTOKJAD_ATTACK_MELEE 7590 -#define INF_GEN_ANIM_JALTOKJAD_DEFEND 7591 -#define INF_GEN_ANIM_JALTOKJAD_ATTACK_MAGIC 7592 -#define INF_GEN_ANIM_JALTOKJAD_ATTACK_RANGED 7593 -#define INF_GEN_ANIM_JALTOKJAD_DEATH 7594 -#define INF_GEN_ANIM_JALIMKOT_ATTACK 7597 -#define INF_GEN_ANIM_JALIMKOT_DEFEND 7598 -#define INF_GEN_ANIM_JALIMKOT_DEATH 7599 -#define INF_GEN_ANIM_JALIMKOT_DIGDOWN 7600 -#define INF_GEN_ANIM_JALIMKOT_DIGUP 7601 -#define INF_GEN_ANIM_JALXIL_ATTACK_MELEE 7604 -#define INF_GEN_ANIM_JALXIL_ATTACK_RANGED 7605 -#define INF_GEN_ANIM_JALXIL_DEATH 7606 -#define INF_GEN_ANIM_JALXIL_DEFEND 7607 -#define INF_GEN_ANIM_JALAKXIL_ATTACK_MAGIC 7610 -#define INF_GEN_ANIM_JALAKXIL_RESURRECT 7611 -#define INF_GEN_ANIM_JALAKXIL_ATTACK_MELEE 7612 -#define INF_GEN_ANIM_JALAKXIL_DEATH 7613 +#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_BAT_IDLE 7577 +#define INF_GEN_ANIM_BAT_ATTACK 7578 +#define INF_GEN_ANIM_BAT_WALK 7577 +#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_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_RANGER_IDLE 7602 +#define INF_GEN_ANIM_RANGER_ATTACK 7605 +#define INF_GEN_ANIM_RANGER_WALK 7603 +#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_JALTOK_JAD_IDLE 7589 +#define INF_GEN_ANIM_JALTOK_JAD_ATTACK 7593 +#define INF_GEN_ANIM_JALTOK_JAD_WALK 7588 +#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_ZUK_SHIELD_IDLE 7567 +#define INF_GEN_ANIM_ZUK_SHIELD_WALK 7567 +#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 /* FIREWAVE_IMPACT */ -#define INF_GEN_GFX_157_ANIM 693 -#define INF_GEN_GFX_447_MODEL 9334 /* TZHAAR_FIRE_SPIT_LAUNCH */ -#define INF_GEN_GFX_447_ANIM 2658 -#define INF_GEN_GFX_448_MODEL 9337 /* TZHAAR_FIRE_SPIT_TRAVEL */ -#define INF_GEN_GFX_448_ANIM 2659 -#define INF_GEN_GFX_451_MODEL 9342 /* TZHAAR_ROCK_SMASH */ -#define INF_GEN_GFX_451_ANIM 2660 -#define INF_GEN_GFX_1374_MODEL 853342 /* SLAYER_MAGICDART_ENCHANTED_IMPACT */ -#define INF_GEN_GFX_1374_ANIM 660 -#define INF_GEN_GFX_1375_MODEL 33006 /* INFERNO_ZUK_PROJECTILE */ -#define INF_GEN_GFX_1375_ANIM 7571 -#define INF_GEN_GFX_1376_MODEL 33007 /* INFERNO_ZEK_PROJECTILE */ -#define INF_GEN_GFX_1376_ANIM 7571 -#define INF_GEN_GFX_1377_MODEL 33013 /* INFERNO_XIL_PROJECTILE */ -#define INF_GEN_GFX_1378_MODEL 33015 /* INFERNO_SPLITTER_RANGE */ -#define INF_GEN_GFX_1378_ANIM 7615 -#define INF_GEN_GFX_1379_MODEL 33016 /* INFERNO_BABYSPLITTER_RANGE */ -#define INF_GEN_GFX_1379_ANIM 7614 -#define INF_GEN_GFX_1380_MODEL 33008 /* INFERNO_SPLITTER_MAGE */ -#define INF_GEN_GFX_1380_ANIM 7616 -#define INF_GEN_GFX_1381_MODEL 33009 /* INFERNO_BABYSPLITTER_MAGE */ -#define INF_GEN_GFX_1381_ANIM 7616 -#define INF_GEN_GFX_1382_MODEL 33017 /* INFERNO_HARPIE_PROJ */ -#define INF_GEN_GFX_1382_ANIM 7614 -#define INF_GEN_GFX_1383_MODEL 853351 /* DOUBLE_AMETHYST_ARROW_LAUNCH */ -#define INF_GEN_GFX_1383_ANIM 366 -#define INF_GEN_GFX_1384_MODEL 853352 /* AMETHYST_ARROW_TRAVEL */ -#define INF_GEN_GFX_1385_MODEL 853353 /* AMETHYST_ARROW_LAUNCH */ -#define INF_GEN_GFX_1385_ANIM 366 +#define INF_GEN_GFX_157_MODEL 3116 /* Jad magic hit */ +#define INF_GEN_GFX_157_ANIM 693 +#define INF_GEN_GFX_447_MODEL 9334 /* Jad ranged projectile (fireball) */ +#define INF_GEN_GFX_447_ANIM 2658 +#define INF_GEN_GFX_448_MODEL 9337 /* Jad magic projectile */ +#define INF_GEN_GFX_448_ANIM 2659 +#define INF_GEN_GFX_451_MODEL 9342 /* Jad ranged hit */ +#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/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 34ac9c367b..747e821601 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -187,7 +187,7 @@ static const InfNPCOverlay INF_NPC_OVERLAY[INF_NUM_NPC_TYPES] = { [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, 0, 113, 100, 113, 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 */ @@ -405,7 +405,7 @@ typedef struct { int had_los_last_tick; /* blob: previous-tick LOS latch for immediate scans on LOS gain */ /* jad state */ - int jad_attack_style; /* jad: current attack style (random 50/50) */ + 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) */ @@ -432,6 +432,7 @@ typedef struct { /* 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 */ @@ -623,6 +624,8 @@ typedef struct { /* player combat state */ OsrsInteraction interaction; /* shared interaction state */ + int player_last_interaction_target_slot; + int player_last_interaction_age; /* gear state */ InfWeaponSet weapon_set; @@ -722,19 +725,115 @@ static inline void inf_invalidate_los_cache(InfernoState* s) { memset(s->npc_los_cache, -1, sizeof(s->npc_los_cache)); } +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_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; +} + +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_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); +} + /* ======================================================================== */ /* dead mob store for mager resurrection */ /* ======================================================================== */ +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 resurrectable types. matches InfernoTrainer's npcDied() - registrations: bat, blob parent, meleer, ranger, mager. nibblers, - healers, shield, jad, zuk do NOT register — excluded here. */ - if (npc->type == INF_NPC_NIBBLER || - npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || - npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_ZUK || - npc->type == INF_NPC_JAD) return; + /* 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; InfDeadMob* dm = &s->dead_mobs[s->dead_mob_count++]; dm->type = npc->type; @@ -791,6 +890,8 @@ static void inf_reset(EncounterState* state, uint32_t seed) { /* 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; @@ -905,6 +1006,8 @@ static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y 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; @@ -1178,9 +1281,33 @@ static int inf_npc_blocked(void* ctx, int x, int y, int size) { return inf_occupancy_blocked(s, mc->self_idx, 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; @@ -1194,16 +1321,19 @@ static void inf_npc_move(InfernoState* s, int idx) { /* 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 ox = npc->x, oy = npc->y; int stepped = encounter_npc_step_out_from_under( &npc->x, &npc->y, npc->size, s->player.x, s->player.y, - inf_tile_walkable, s, &s->rng_state); - if (stepped) { + inf_npc_blocked, &mc, inf_npc_overlap_hold, &s->rng_state); + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_MOVED) { npc->moved_this_tick = 1; inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); return; } + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_HELD) + return; } /* target selection: pillar (nibbler), aggroed NPC (shield/jad/zuk), or player */ @@ -1249,9 +1379,11 @@ static void inf_npc_move(InfernoState* s, int idx) { reference: InfernoTrainer Unit.ts:383 canMove = !hasLOS (where hasLOS is relative to the NPC's current aggro target). */ if (stats->attack_range > 1 && npc->type != INF_NPC_NIBBLER) { - if (npc_has_line_of_sight(s->los_blockers, s->los_blocker_count, - npc->x, npc->y, npc->size, - tx, ty, stats->attack_range)) return; + 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)) return; } /* greedy step toward target using shared helper. the helper no longer @@ -1281,10 +1413,28 @@ static void inf_meleer_dig_check(InfernoState* s, int idx) { if (npc->dig_freeze_timer > 0) { npc->dig_freeze_timer--; if (npc->dig_freeze_timer == 0 && npc->dig_attack_delay == 0) { - /* emerge: place near player */ + /* 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; - npc->x = s->player.x + (encounter_rand_int(&s->rng_state, 3) - 1); - npc->y = s->player.y + (encounter_rand_int(&s->rng_state, 3) - 1); + 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; inf_update_occupancy(s, idx, ox, oy, 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 */ @@ -1354,6 +1504,14 @@ static void inf_npc_attack(InfernoState* s, int idx) { /* 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->type == INF_NPC_JAD && + npc->attack_timer == 1 && + npc->jad_attack_style == ATTACK_STYLE_NONE) { + /* Jad telegraphs on the fire tick in the reference client. Our control + loop applies actions at tick start, so commit that telegraph one tick + earlier in the observation stream. */ + npc->jad_attack_style = inf_jad_roll_primary_style(&s->rng_state); + } if (npc->attack_timer > 0) return; /* shield doesn't attack */ @@ -1384,6 +1542,8 @@ static void inf_npc_attack(InfernoState* s, int idx) { } } 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; @@ -1455,6 +1615,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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; } @@ -1475,8 +1636,13 @@ static void inf_npc_attack(InfernoState* s, int idx) { npc->attack_timer = stats->attack_speed; return; } - /* if player has targeted this healer, attack player in melee range. */ - if (encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, 1) == 1) { + /* 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 */ @@ -1489,10 +1655,11 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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; } - npc->attacked_this_tick = 1; - npc->attack_visual_target = -1; - npc->attack_timer = stats->attack_speed; return; } @@ -1531,10 +1698,12 @@ static void inf_npc_attack(InfernoState* s, int idx) { /* determine actual attack style */ int actual_style = npc->attack_style; - /* jad: random 50/50 range or magic each attack */ + /* 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 = (encounter_rand_int(&s->rng_state, 2) == 0) ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; - npc->jad_attack_style = actual_style; + 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). @@ -1554,6 +1723,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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. @@ -1571,21 +1741,26 @@ static void inf_npc_attack(InfernoState* s, int idx) { } 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; } - /* melee switchover for ranger/mager: when close */ - if (stats->can_melee && dist == 1) { - actual_style = ATTACK_STYLE_MELEE; + /* inferno has NPC-specific melee fallback rules when close enough to hit. */ + { + 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; } @@ -1691,6 +1866,9 @@ static void inf_npc_attack(InfernoState* s, int idx) { } 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 */ @@ -2118,6 +2296,9 @@ static void inf_player_pretick(InfernoState* s, const int* actions) { } static void inf_tick_player(InfernoState* s, const int* actions) { + if (s->player_last_interaction_age == 0) + s->player_last_interaction_age = 1; + /* gear switching */ int gear_act = actions[INF_HEAD_GEAR]; if (gear_act >= 1) s->total_gear_switches++; @@ -2236,6 +2417,8 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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; } } @@ -2607,6 +2790,7 @@ static void inf_step(EncounterState* state, const int* actions) { 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; @@ -2901,7 +3085,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { for (int h = 0; h < s->player_pending_hit_count; h++) { EncounterPendingHit* ph = &s->player_pending_hits[h]; if (ph->check_prayer) { - int t = ph->ticks_remaining; + int t = inf_pending_hit_obs_timer(ph); if (t < min_timer) { min_timer = t; min_style = ph->attack_style; @@ -2916,11 +3100,12 @@ static void inf_write_obs(EncounterState* state, float* obs) { } } - /* 2. NPCs firing (handles non-Jad, which check prayer on launch/firing) */ + /* 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_JAD || npc->type == INF_NPC_ZUK || + if (npc->type == INF_NPC_ZUK || npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_NIBBLER || npc->type == INF_NPC_HEALER_ZUK) continue; @@ -2940,22 +3125,25 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; } - if (st->can_melee && dist == 1) { - style = ATTACK_STYLE_MELEE; - } + int style_mask = inf_attack_style_options_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; /* Safety fallback if timer hit 0 */ if (t < min_timer) { min_timer = t; - min_style = style; + min_style = preview_style; } if (t <= 2) { - if (style == ATTACK_STYLE_MELEE) has_melee_2 = 1; - if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; - if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; + 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; } } @@ -2973,17 +3161,21 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = is_zuk ? 1.0f : 0.0f; if (is_zuk) { - /* shield direction: +1 east, -1 west, 0 frozen */ - obs[i++] = (s->zuk.shield_freeze > 0) ? 0.0f : (float)s->zuk.shield_dir; - /* shield freeze ticks remaining / 5 */ - obs[i++] = (float)s->zuk.shield_freeze / 5.0f; + 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; - int si = s->zuk.shield_idx; - if (si >= 0 && s->npcs[si].active) { + 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; @@ -3164,7 +3356,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = 1.0f; /* active */ obs[i++] = (ph->attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; obs[i++] = (ph->attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; - obs[i++] = (float)ph->ticks_remaining / 10.0f; + obs[i++] = (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; @@ -3389,7 +3581,7 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i re->current_hitpoints = npc->hp; re->base_hitpoints = npc->max_hp; re->attack_style_this_tick = npc->attacked_this_tick - ? (AttackStyle)npc->attack_style : ATTACK_STYLE_NONE; + ? (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. @@ -3477,24 +3669,18 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) continue; const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; - int actual_style = stats->default_style; - - /* blob uses per-attack style from prayer reading (ranged vs magic) */ - if (npc->type == INF_NPC_BLOB) - actual_style = npc->attack_style; + int actual_style = npc->attack_style_this_tick; - /* jad uses its per-attack random style */ - if (npc->type == INF_NPC_JAD) - actual_style = npc->jad_attack_style; - - /* zuk is typeless — show as magic for visual purposes */ - if (npc->type == INF_NPC_ZUK) + /* 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; @@ -3538,13 +3724,7 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) ? INF_GFX_448_MODEL : INF_GFX_447_MODEL; break; case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; - /* InfernoTrainer uses tekton_meteor.glb for Jal-MejJak projectiles. - OSRS cache doesn't have a dedicated meteor spotanim exported here, - so we reuse TZHAAR_FIRE_SPIT_TRAVEL (GFX 448) — a fiery orb, the - closest meteor-shaped flight model in the current manifest and - distinct from mager/zuk projectiles. proper fix: export tekton - meteor spotanim into the manifest. */ - case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_448_MODEL; break; + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_660_MODEL; break; default: break; } @@ -3590,10 +3770,12 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { default: break; } + 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); + 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) @@ -3622,7 +3804,8 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* 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_448_MODEL); + 4 * 30, 96, 64, 16, 3.0f, 0, 1, 1, + INF_GFX_660_MODEL, INF_GFX_659_ID); spark->visual_emitted = 1; } @@ -3651,10 +3834,10 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { } else if (s->weapon_set == INF_GEAR_TBOW) { p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; p_arc = 1.0f; - player_proj_model = 3136; /* rune arrow (GFX 15) — dragon arrow visually similar */ + player_proj_model = INF_GFX_1120_MODEL; } else { /* blowpipe */ - p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_duration = encounter_blowpipe_hit_delay(p_dist, 1) * 30; p_arc = 0.5f; player_proj_model = 26379; /* dragon dart */ } @@ -3664,7 +3847,8 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { 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); + p_duration, p_start_h, p_end_h, 16, p_arc, p_tracks, + 1, target_size, player_proj_model, 0); } } } diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h index f04a9d6991..9055bb5274 100644 --- a/ocean/osrs/osrs_collision.h +++ b/ocean/osrs/osrs_collision.h @@ -549,38 +549,64 @@ static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, return 1; } -/* NPC LOS: for size>1 NPCs, check from target's closest point back to NPC. - * npc is at (nx,ny) SW corner with npc_size. target is at (tx,ty) size 1. - * for melee (range==1): pure cardinal adjacency — no ray-trace, no pillar check. - * ref: osrs-sdk LineOfSight.ts:88-89 (translated to our SW-anchor, Y-up coords). */ -static int npc_has_line_of_sight(const LOSBlocker* blockers, int blocker_count, - int nx, int ny, int npc_size, - int tx, int ty, int range) { - /* melee range: player must be on a cardinal side of the NPC bounding box - (north/south/east/west edge tiles). diagonal corners do NOT count. - NPC occupies [nx, nx+s-1] x [ny, ny+s-1]. cardinal-adjacent tiles: - north: y = ny+s, x in [nx, nx+s-1] - south: y = ny-1, x in [nx, nx+s-1] - east: x = nx+s, y in [ny, ny+s-1] - west: x = nx-1, y in [ny, ny+s-1] */ +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_check_tile(blockers, blocker_count, tx, ty)) return 0; - if (los_aabb_overlap(nx, ny, npc_size, tx, ty, 1)) return 0; - int dx = tx - nx; - int dy = ty - ny; - return (dx >= 0 && dx < npc_size && (dy == npc_size || dy == -1)) || - (dy >= 0 && dy < npc_size && (dx == npc_size || dx == -1)); + 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)); } - /* ranged/magic: find closest point on NPC footprint, ray-trace */ - int cx = tx; - if (cx < nx) cx = nx; - if (cx >= nx + npc_size) cx = nx + npc_size - 1; - int cy = ty; - if (cy < ny) cy = ny; - if (cy >= ny + npc_size) cy = ny + npc_size - 1; + 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); +} - return has_line_of_sight(blockers, blocker_count, tx, ty, cx, cy, 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_encounter.h b/ocean/osrs/osrs_encounter.h index 10eb32347e..6620d9a160 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -97,7 +97,10 @@ typedef struct { encounter's render_post_tick populates this, renderer reads it. */ #define ENCOUNTER_MAX_OVERLAY_TILES 16 #define ENCOUNTER_MAX_OVERLAY_ADDS 4 -#define ENCOUNTER_MAX_OVERLAY_PROJECTILES 8 +/* 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 struct { /* encounter-defined area hazards. current users write 3x3 poison clouds. */ @@ -133,6 +136,7 @@ typedef struct { 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 impact_gfx_id; /* optional landing spotanim to spawn on arrival */ } projectiles[ENCOUNTER_MAX_OVERLAY_PROJECTILES]; int projectile_count; @@ -160,7 +164,7 @@ static inline int encounter_emit_projectile( 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 + uint32_t model_id, int impact_gfx_id ) { if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) return -1; int i = ov->projectile_count++; @@ -181,6 +185,7 @@ static inline int encounter_emit_projectile( ov->projectiles[i].src_size = src_size; ov->projectiles[i].dst_size = dst_size; ov->projectiles[i].model_id = model_id; + ov->projectiles[i].impact_gfx_id = impact_gfx_id; return i; } @@ -534,9 +539,10 @@ static inline int encounter_player_can_attack( target_x, target_y, target_size); if (dist < 1 || dist > attack_range) return 0; if (!los_blockers || los_blocker_count == 0) return 1; - return npc_has_line_of_sight(los_blockers, los_blocker_count, - target_x, target_y, target_size, - player_x, player_y, attack_range); + 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. @@ -609,28 +615,43 @@ static inline int encounter_chase_attack_target( target_x, target_y, target_size); if (dist_now > 0 && dist_now <= attack_range && los_blockers && los_blocker_count > 0) { - /* in range but no LOS — scan NPC-adjacent tiles that have ACTUAL LOS to - the NPC. only tiles where encounter_player_can_attack would return true - are valid candidates. BFS then pathfinds to the nearest one. - ref: osrs-sdk Player.ts "seekingTiles" — filters by LOS, not just pillar overlap. */ + /* 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; - /* scan cardinal-adjacent tiles (N/S rows + E/W columns of NPC footprint) */ - for (int xx = -1; xx <= target_size; xx++) { - for (int yy = -1; yy <= target_size; yy++) { - /* skip interior tiles (inside NPC footprint) */ - if (xx >= 0 && xx < target_size && yy >= 0 && yy < target_size) continue; - /* skip far corners (only cardinal adjacency matters for melee/range) */ - int px = target_x + xx; - int py = target_y + yy; - if (!is_walkable(ctx, px, py)) continue; - /* check if this tile has actual LOS + range to the NPC */ - if (!encounter_player_can_attack(px, py, target_x, target_y, - target_size, attack_range, los_blockers, los_blocker_count)) - continue; - int ddx = px - p->x, ddy = py - p->y; + 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 = px; cy = py; } + 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 */ @@ -677,19 +698,30 @@ static inline int encounter_chase_attack_target( /* shared NPC step-out-from-under (OSRS: NPC shuffles off player tile) */ /* ======================================================================== */ +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. - returns 1 if the NPC moved, 0 if stuck or no overlap. */ + 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_walkable_fn is_walkable, void* ctx, uint32_t* rng + 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 0; + 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}}; @@ -711,22 +743,19 @@ static inline int encounter_npc_step_out_from_under( via normal edge-tile movement system. for size>1 NPCs, full escape takes multiple ticks. anchor walkability matches InfernoTrainer's canTileBePathedTo check on the leading edge. */ - if (is_walkable(ctx, nx, ny)) { + if (!is_blocked(ctx, nx, ny, npc_size)) { *npc_x = nx; *npc_y = ny; - return 1; + return ENCOUNTER_NPC_UNDER_PLAYER_MOVED; } } - return 0; + return ENCOUNTER_NPC_UNDER_PLAYER_NONE; } /* ======================================================================== */ /* shared NPC greedy pathfinding */ /* ======================================================================== */ -/* callback: returns 1 if tile (x, y) is blocked for an NPC of given size */ -typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); - /** check if the leading edge tiles are clear for an NPC moving in direction (dx, dy). for size>1 NPCs, OSRS checks the tiles along the leading edge that the NPC sweeps through — not the full destination footprint. for diagonal moves, each diff --git a/ocean/osrs/osrs_monsters_generated.h b/ocean/osrs/osrs_monsters_generated.h index 16e7f10f4e..b330885183 100644 --- a/ocean/osrs/osrs_monsters_generated.h +++ b/ocean/osrs/osrs_monsters_generated.h @@ -176,7 +176,7 @@ static const MonsterStats MONSTER_DATABASE[NUM_MONSTERS] = { .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 = 0, + .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, diff --git a/ocean/osrs/osrs_pvp_effects.h b/ocean/osrs/osrs_pvp_effects.h index 396a3135af..82789478da 100644 --- a/ocean/osrs/osrs_pvp_effects.h +++ b/ocean/osrs/osrs_pvp_effects.h @@ -29,12 +29,15 @@ #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 */ @@ -58,11 +61,14 @@ static const SpotAnimMeta SPOTANIM_TABLE[] = { { 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 }, @@ -153,11 +159,14 @@ static int effect_find_slot(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { /** Create AnimModelState for an effect's model (if it has animation data). */ static void effect_init_anim_state( ActiveEffect* e, - ModelCache* model_cache + ModelCache* model_cache, + ModelCache* secondary_model_cache ) { - if (!e->meta || e->meta->anim_seq_id < 0 || !model_cache) return; + 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( @@ -179,7 +188,8 @@ static int effect_spawn_spotanim_subtile( float subtile_x, float subtile_y, int current_client_tick, AnimCache* anim_cache, - ModelCache* model_cache + ModelCache* model_cache, + ModelCache* secondary_model_cache ) { const SpotAnimMeta* meta = spotanim_lookup(gfx_id); if (!meta) return -1; @@ -210,7 +220,7 @@ static int effect_spawn_spotanim_subtile( } e->stop_tick = current_client_tick + duration; - effect_init_anim_state(e, model_cache); + effect_init_anim_state(e, model_cache, secondary_model_cache); return slot; } @@ -218,11 +228,12 @@ static int effect_spawn_spotanim_subtile( 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 + 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); + current_client_tick, anim_cache, model_cache, secondary_model_cache); } /** @@ -244,7 +255,8 @@ static int effect_spawn_projectile( int end_height_subtile, int slope, int current_client_tick, - ModelCache* model_cache + ModelCache* model_cache, + ModelCache* secondary_model_cache ) { const SpotAnimMeta* meta = spotanim_lookup(gfx_id); if (!meta) return -1; @@ -271,7 +283,7 @@ static int effect_spawn_projectile( 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); + effect_init_anim_state(e, model_cache, secondary_model_cache); return slot; } diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index ce09652ea4..92091ef2f3 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -16,6 +16,8 @@ #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" @@ -23,6 +25,7 @@ #include "osrs_objects.h" #include "osrs_gui.h" #include "osrs_human_input.h" +#include #include #include @@ -81,7 +84,10 @@ * 1 client tick = 20ms, 1 server tick = 600ms = 30 client ticks. */ -#define MAX_FLIGHT_PROJECTILES 16 +/* 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 { @@ -107,6 +113,7 @@ typedef struct { int tracks_target; /* 1 = re-aim toward target each tick */ int start_delay; /* client ticks before projectile becomes visible/moves */ uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ + int impact_gfx_id; /* landing spotanim to spawn on arrival */ } FlightProjectile; /* ======================================================================== */ @@ -321,6 +328,8 @@ typedef struct { /* 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 { @@ -410,7 +419,7 @@ typedef struct { FlightProjectile flights[MAX_FLIGHT_PROJECTILES]; /* dynamic projectile model cache: lazily loads per-NPC-type projectile models */ -#define MAX_PROJ_MODELS 16 +#define MAX_PROJ_MODELS 24 struct { uint32_t id; Model model; int ready; } proj_models[MAX_PROJ_MODELS]; int proj_model_count; @@ -888,16 +897,21 @@ static int render_build_static_model(ModelCache* cache, uint32_t model_id, Model return 1; } -/** Lazily load and cache a projectile model by GFX model ID. - * Searches both model_cache and npc_model_cache. Returns NULL if not found - * or if model_id is 0 (style-based fallback). */ +/** 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) return 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( @@ -906,7 +920,12 @@ static Model* render_get_proj_model(RenderClient* rc, uint32_t model_id) { rc->proj_models[idx].ready = render_build_static_model( rc->npc_model_cache, model_id, &rc->proj_models[idx].model); } - return rc->proj_models[idx].ready ? &rc->proj_models[idx].model : NULL; + 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; } /** @@ -962,6 +981,7 @@ static void flight_spawn(RenderClient* rc, 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 impact_gfx_id, int start_delay) { int slot = -1; for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { @@ -988,6 +1008,7 @@ static void flight_spawn(RenderClient* rc, fp->arc_height = arc_height; fp->tracks_target = tracks_target; fp->model_id = model_id; + fp->impact_gfx_id = impact_gfx_id; fp->start_delay = start_delay; /* height arc: OSRS SceneProjectile.calculateIncrements @@ -1008,6 +1029,39 @@ static void flight_spawn(RenderClient* rc, fp->pitch = (arc_height > 0.0f) ? 0.0f : atan2f(fp->height_vel, dist); } +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 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). * @@ -1049,6 +1103,13 @@ static void flight_client_tick(RenderClient* rc) { fp->progress += fp->speed; if (fp->progress >= 1.0f) { + 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); + } fp->active = 0; } } @@ -1774,18 +1835,22 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { /* 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, 40, 40 * 4, 30 * 4, 16, ct, rc->model_cache); + 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); + 0, 56, 43 * 4, 0, 16, ct, + rc->model_cache, rc->npc_model_cache); } /* blood barrage: no projectile, impact spawns on hit */ } @@ -1793,19 +1858,27 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { /* 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 || wpn == ITEM_TWISTED_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, 40, 43 * 4, 31 * 4, 16, ct, rc->model_cache); + 0, duration_ticks, 43 * 4, 31 * 4, 16, ct, + rc->model_cache, rc->npc_model_cache); } } @@ -1829,10 +1902,12 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { /* 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); + 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); + 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) @@ -1851,10 +1926,12 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { 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); + 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); + fx, fy, ct, rc->anim_cache, + rc->model_cache, rc->npc_model_cache); } } } @@ -1895,7 +1972,8 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { 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, + dur, sh, eh, cv, arc, trk, + ov->projectiles[i].model_id, ov->projectiles[i].impact_gfx_id, ov->projectiles[i].start_delay); } @@ -2985,18 +3063,22 @@ static void composite_rebuild_npc( /* look up model ID from NPC definition */ uint32_t model_id = 0; const NpcModelMapping* mapping = npc_model_lookup((uint16_t)npc_def_id); - if (mapping) { - model_id = mapping->model_id; - } else { - /* snakelings and other NPCs without a mapping — try snakeling */ - model_id = SNAKELING_MODEL_ID; + 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) composite_add_model(comp, om); + 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) { @@ -3607,7 +3689,7 @@ static void render_draw_3d_world(RenderClient* rc) { proj_model = render_get_proj_model(rc, fp->model_id); } if (!proj_model) { - /* style-based fallback for backward compatibility */ + /* 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) @@ -3621,11 +3703,8 @@ static void render_draw_3d_world(RenderClient* rc) { if (proj_model) { rlDisableBackfaceCulling(); float pms = 1.0f / 128.0f; - proj_model->transform = MatrixMultiply( - MatrixMultiply( - MatrixScale(-pms, pms, pms), - MatrixMultiply(MatrixRotateY(fp->yaw + 1.5707963f), MatrixRotateX(fp->pitch))), - MatrixTranslate(pos.x, pos.y, pos.z)); + proj_model->transform = render_projectile_transform( + pms, pms, pms, fp->yaw, fp->pitch, pos); DrawModel(*proj_model, (Vector3){0,0,0}, 1.0f, WHITE); rlEnableBackfaceCulling(); } @@ -3735,8 +3814,24 @@ static void render_draw_3d_world(RenderClient* 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]; @@ -3768,6 +3863,8 @@ static void render_draw_3d_world(RenderClient* rc) { /* 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 */ @@ -3805,7 +3902,7 @@ static void render_draw_3d_world(RenderClient* rc) { } /* build transform */ - Matrix t = MatrixScale(-sx, sx, sz); /* negate X for handedness */ + Matrix t; /* projectile orientation: yaw + pitch from trajectory direction. uses atan2 on the velocity vector (same approach as the flight @@ -3818,11 +3915,11 @@ static void render_draw_3d_world(RenderClient* rc) { float horiz = sqrtf(dx * dx + dz * dz); float yaw = atan2f(dx, dz); float pitch = atan2f((float)e->height_increment, horiz > 0.001f ? horiz : 0.001f); - t = MatrixMultiply(t, MatrixMultiply( - MatrixRotateX(pitch), MatrixRotateY(yaw))); + t = render_projectile_transform(sx, sx, sz, yaw, pitch, + (Vector3){ ex, ey, ez }); + } else { + t = MatrixMultiply(MatrixScale(-sx, sx, sz), MatrixTranslate(ex, ey, ez)); } - - t = MatrixMultiply(t, MatrixTranslate(ex, ey, ez)); om->model.transform = t; /* spotanim fade: 20% fade in, 60% full, 20% fade out */ @@ -4007,8 +4104,12 @@ static void render_draw_overhead_status(RenderClient* rc, OsrsEnv* env) { 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 = ground + 1.5f + 0.5f * (float)ent_size; - float abdomen_y = ground + 0.75f + 0.25f * (float)ent_size; + 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); diff --git a/ocean/osrs/scripts/export_all.sh b/ocean/osrs/scripts/export_all.sh index b71fbb404c..b112e511e4 100755 --- a/ocean/osrs/scripts/export_all.sh +++ b/ocean/osrs/scripts/export_all.sh @@ -1,29 +1,24 @@ #!/usr/bin/env bash -# export all visual assets from an OSRS modern cache (OpenRS2 flat file format). +# export all binary assets from an OSRS modern cache (OpenRS2 flat file format). # # usage: -# cd ocean/osrs -# ./scripts/export_all.sh [keys.json] +# ./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. # -# XTEA keys (keys.json) are needed for terrain/objects in encrypted regions. -# if not provided, the script looks for keys.json inside the cache dir. -# -# idempotent: skips any asset that already exists. delete a file to re-export it. -# all output goes to data/. +# 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)" -TOOLS_DIR="$SCRIPT_DIR/../tools" DATA_DIR="$SCRIPT_DIR/../data" -mkdir -p "$DATA_DIR" "$DATA_DIR/sprites/gui" "$DATA_DIR/sprites/items" +mkdir -p "$DATA_DIR" "$DATA_DIR/sprites" if [ $# -lt 1 ]; then - echo "usage: $0 [keys.json]" + echo "usage: $0 " echo "" echo "download a cache from https://archive.openrs2.org/" echo "pick any recent OSRS revision, use the 'flat file' export." @@ -31,184 +26,80 @@ if [ $# -lt 1 ]; then fi CACHE="$1" -KEYS="${2:-$CACHE/keys.json}" +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 — encrypted regions (terrain/objects) will fail" + echo "warning: no keys.json found — XTEA-encrypted regions will be skipped" KEYS="" fi -KEYS_ARG="" -[ -n "$KEYS" ] && KEYS_ARG="--keys $KEYS" - -skip_if_exists() { - if [ -f "$1" ]; then - echo " skip: $1 (exists)" - return 0 - fi - return 1 -} - -# ============================================================================ -# shared assets (all encounters) -# ============================================================================ +cd "$SCRIPT_DIR" -echo "=== equipment models (player body + worn gear) ===" -if ! skip_if_exists "$DATA_DIR/equipment.models"; then - python "$SCRIPT_DIR/export_models.py" \ - --modern-cache "$CACHE" \ - --output "$DATA_DIR/equipment.models" \ - --extra-models 14407,14408,14409,10415,20390,11221,26593,4086 -fi - -echo "=== equipment animations ===" -if ! skip_if_exists "$DATA_DIR/equipment.anims"; then - python "$SCRIPT_DIR/export_animations.py" \ - --modern-cache "$CACHE" \ - --output "$DATA_DIR/equipment.anims" -fi - -echo "=== GUI sprites (prayer icons, hitsplats, UI chrome) ===" -# sprites are many small files; check for a sentinel -if ! skip_if_exists "$DATA_DIR/sprites/gui/pray_melee.png"; then - python "$SCRIPT_DIR/export_sprites_modern.py" \ - --cache "$CACHE" \ - --output "$DATA_DIR/sprites/gui" -fi - -# ============================================================================ -# zulrah -# ============================================================================ - -echo "=== zulrah NPC models + animations ===" -if ! skip_if_exists "$DATA_DIR/zulrah.models"; then - python "$TOOLS_DIR/export_encounter_npcs.py" \ - --group zulrah \ - --modern-cache "$CACHE" \ - --output-dir "$DATA_DIR" -fi +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 "=== zulrah collision map ===" -if ! skip_if_exists "$DATA_DIR/zulrah.cmap"; then - python "$SCRIPT_DIR/export_collision_map_modern.py" \ - --cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/zulrah.cmap" \ - --regions 35,47 35,48 -fi - -echo "=== zulrah terrain ===" -if ! skip_if_exists "$DATA_DIR/zulrah.terrain"; then - python "$SCRIPT_DIR/export_terrain.py" \ - --modern-cache "$CACHE" \ - --output "$DATA_DIR/zulrah.terrain" \ - --regions 35,47 35,48 -fi - -echo "=== zulrah objects ===" -if ! skip_if_exists "$DATA_DIR/zulrah.objects"; then - python "$SCRIPT_DIR/export_objects.py" \ - --modern-cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/zulrah.objects" \ - --regions 35,47 35,48 -fi - -# ============================================================================ -# inferno -# ============================================================================ - -echo "=== inferno NPC models + animations ===" -if ! skip_if_exists "$DATA_DIR/inferno.models"; then - python "$TOOLS_DIR/export_encounter_npcs.py" \ - --group inferno \ - --modern-cache "$CACHE" \ - --output-dir "$DATA_DIR" -fi - -echo "=== inferno collision map ===" -if ! skip_if_exists "$DATA_DIR/inferno.cmap"; then - python "$SCRIPT_DIR/export_collision_map_modern.py" \ - --cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/inferno.cmap" \ - --regions 35,83 -fi - -echo "=== inferno terrain ===" -if ! skip_if_exists "$DATA_DIR/inferno.terrain"; then - python "$SCRIPT_DIR/export_terrain.py" \ - --modern-cache "$CACHE" \ - --output "$DATA_DIR/inferno.terrain" \ - --regions 35,83 -fi - -echo "=== inferno objects (full arena) ===" -if ! skip_if_exists "$DATA_DIR/inferno.objects"; then - python "$SCRIPT_DIR/export_objects.py" \ - --modern-cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/inferno.objects" \ - --regions 35,83 -fi - -echo "=== inferno objects (zuk arena only, pillars removed) ===" -if ! skip_if_exists "$DATA_DIR/inferno_zuk.objects"; then - python "$SCRIPT_DIR/export_objects.py" \ - --modern-cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/inferno_zuk.objects" \ - --regions 35,83 \ - --exclude-ids "30327,30328,30329,30330,30331,30332,30333,30334,30335,30336,30337,30338,30356" -fi - -# ============================================================================ -# PvP (wilderness) -# ============================================================================ - -echo "=== wilderness collision map ===" -if ! skip_if_exists "$DATA_DIR/wilderness.cmap"; then - python "$SCRIPT_DIR/export_collision_map_modern.py" \ - --cache "$CACHE" $KEYS_ARG \ - --output "$DATA_DIR/wilderness.cmap" \ - --wilderness -fi +echo "" +echo "=== exporting zulrah terrain ===" +python export_terrain.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/zulrah.terrain" \ + --regions 35,47 35,48 -echo "=== wilderness terrain ===" -if ! skip_if_exists "$DATA_DIR/wilderness.terrain"; then - python "$SCRIPT_DIR/export_terrain.py" \ - --modern-cache "$CACHE" \ - --output "$DATA_DIR/wilderness.terrain" \ - --wilderness -fi +echo "" +echo "=== exporting zulrah objects ===" +python export_objects.py \ + --modern-cache "$CACHE" --keys "$KEYS" \ + --output "$DATA_DIR/zulrah.objects" \ + --regions 35,47 35,48 -# wilderness.objects is 685MB+ — skip by default -echo "=== wilderness objects (skipped, 685MB+) ===" -echo " run manually: python scripts/export_objects.py --modern-cache \$CACHE --keys \$KEYS --output data/wilderness.objects --wilderness" +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 -# ============================================================================ -# item sprites (inventory icons) — uses Java + runelite-cache -# ============================================================================ +echo "" +echo "=== exporting animations ===" +python export_animations.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/equipment.anims" -# default item IDs: the loadout items used by inferno + zulrah + pvp. -# add more here as needed. comma-separated. -ITEM_IDS="11230,22461,22464,22467,22470,12625,12627,12629,12631" -ITEM_IDS+=",4151,22325,26374,12926,27277,28254" # weapons (whip/scythe/bp/tbow/scb) -ITEM_IDS+=",10828,21018,13239,27235,27238,27229" # gear (helm/body/legs/torva etc.) -ITEM_IDS+=",6685,6687,6689,6691,3024,3026,3028,3030" # brew + restore -ITEM_IDS+=",385,3144,2434,139,141,143" # food + prayer pot +echo "" +echo "=== exporting GUI sprites (prayer icons, hitsplats) ===" +python export_sprites_modern.py \ + --cache "$CACHE" \ + --output "$DATA_DIR/sprites/gui" -echo "=== item inventory sprites (needs Java + runelite-cache, auto-fetched) ===" -if ! command -v javac >/dev/null 2>&1; then - echo " skip: javac not found. install openjdk-11+ to export item sprites." -else - if ! skip_if_exists "$DATA_DIR/sprites/items/11230.png"; then - "$SCRIPT_DIR/export_items.sh" "$CACHE" "$ITEM_IDS" - fi -fi +echo "" +echo "=== exporting wilderness collision map ===" +python export_collision_map_modern.py \ + --cache "$CACHE" --keys "$KEYS" \ + --output "$DATA_DIR/wilderness.cmap" \ + --wilderness -# ============================================================================ -# done -# ============================================================================ +echo "" +echo "=== exporting wilderness terrain ===" +python export_terrain.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/wilderness.terrain" \ + --wilderness echo "" -echo "done. assets exported to $DATA_DIR/" +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_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py new file mode 100644 index 0000000000..f4d0670bc0 --- /dev/null +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -0,0 +1,797 @@ +"""Export inferno NPC models, animations, and spotanim GFX from modern OSRS cache. + +Reads NPC definitions for all inferno monsters (nibblers through Zuk), extracts +their model IDs and animation sequence IDs, exports 3D meshes to .models binary, +exports animations to .anims binary, and updates npc_models.h with mappings. + +Also reads SpotAnim (GFX) configs for inferno projectiles. + +Usage: + uv run python scripts/export_inferno_npcs.py \ + --modern-cache /path/to/osrs-cache-modern \ + --output-dir data +""" + +import argparse +import copy +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_i32, + read_string, + read_u8, + read_u16, + read_u24, + read_u32, +) +from export_models import ( + MDL2_MAGIC, + ModelData, + _merge_models, + decode_model, + expand_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + ANIM_MAGIC, + FrameBaseDef, + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + parse_modern_framebase, + write_animations_binary, +) +from modern_cache_reader import parse_sequence as parse_modern_sequence + +# modern cache layout +MODERN_NPC_CONFIG_GROUP = 9 # config index 2, group 9 = NPC definitions +MODERN_SPOTANIM_CONFIG_GROUP = 13 # config index 2, group 13 = SpotAnim/GFX +MODERN_SEQ_CONFIG_GROUP = 12 # config index 2, group 12 = sequences +MODERN_FRAME_INDEX = 0 # frame archives +MODERN_FRAMEBASE_INDEX = 1 # frame bases + +# inferno NPC IDs from the OSRS wiki +INFERNO_NPC_IDS = { + 7691: "Jal-Nib (nibbler)", + 7692: "Jal-MejRah (bat)", + 7693: "Jal-Ak (blob)", + 7694: "Jal-Ak-Rek-Ket (blob melee split)", + 7695: "Jal-Ak-Rek-Xil (blob range split)", + 7696: "Jal-Ak-Rek-Mej (blob mage split)", + 7697: "Jal-ImKot (meleer)", + 7698: "Jal-Xil (ranger)", + 7699: "Jal-Zek (mager)", + 7700: "JalTok-Jad", + 7701: "Yt-HurKot (jad healer)", + 7706: "TzKal-Zuk", + 7707: "Zuk shield", + 7708: "Jal-MejJak (zuk healer)", +} + +# attack anims are NOT in cache NPC config — they come from CombatAnimationData +# which is a separate client table. hardcoded from wiki/runelite/deob client. +INFERNO_ATTACK_ANIMS: dict[int, int] = { + 7691: 7574, # nibbler + 7692: 7578, # bat + 7693: 7581, # blob + 7694: 65535, # blob melee split (no attack anim) + 7695: 65535, # blob range split (no attack anim) + 7696: 65535, # blob mage split (no attack anim) + 7697: 7597, # meleer + 7698: 7605, # ranger + 7699: 7610, # mager + 7700: 7593, # jad + 7701: 65535, # healer jad (no attack anim) + 7706: 7566, # zuk + 7707: 65535, # zuk shield (no attack anim) + 7708: 65535, # zuk healer (no attack anim) +} + +# known inferno projectile/effect GFX IDs to check +# from OSRS wiki inferno page and runelite inferno plugin +INFERNO_SPOTANIM_IDS = { + # jad attacks + 447: "Jad ranged projectile (fireball)", + 448: "Jad magic projectile", + 659: "Tekton meteor splat", + 660: "Tekton meteor projectile", + 451: "Jad ranged hit", + 157: "Jad magic hit", + # mager + 1379: "Mager magic projectile", + 1380: "Mager magic hit", + # ranger + 1377: "Ranger ranged projectile", + 1378: "Ranger ranged hit", + # zuk + 1375: "Zuk magic projectile", + 1376: "Zuk ranged projectile", + 1381: "Zuk typeless hit (falling rocks?)", + # bat + 1374: "Bat ranged projectile", + # blob + 1382: "Blob melee", + 1383: "Blob ranged", + 1384: "Blob magic", + # healer + 1385: "Healer magic attack", + # player projectiles (needed for tbow in inferno) + 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) + + # ================================================================ + # step 1: read NPC definitions from config index 2, group 9 + # ================================================================ + print("reading NPC definitions from modern cache (index 2, group 9)...") + npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) + print(f" {len(npc_files)} total NPC entries in group 9") + + npc_defs: dict[int, NpcDef] = {} + all_model_ids: set[int] = set() + all_anim_ids: set[int] = set() + + for npc_id, label in sorted(INFERNO_NPC_IDS.items()): + if npc_id not in npc_files: + print(f" NPC {npc_id} ({label}): NOT FOUND in cache") + continue + + npc = parse_modern_npc_def(npc_id, npc_files[npc_id]) + npc_defs[npc_id] = npc + + print(f"\n NPC {npc_id} ({label}):") + print(f" name: {npc.name}") + print(f" models: {npc.model_ids}") + print(f" size: {npc.size}") + print(f" idle_anim: {npc.idle_anim}") + print(f" walk_anim: {npc.walk_anim}") + print(f" scale: {npc.width_scale}x{npc.height_scale}") + if npc.recolor_src: + print(f" recolors: {list(zip(npc.recolor_src, npc.recolor_dst))}") + if npc.retexture_src: + print(f" retextures: {list(zip(npc.retexture_src, npc.retexture_dst))}") + + all_model_ids.update(npc.model_ids) + for anim_id in [npc.idle_anim, npc.walk_anim, npc.turn_180_anim, npc.turn_cw_anim, npc.turn_ccw_anim]: + if anim_id >= 0: + all_anim_ids.add(anim_id) + # 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) + + # ================================================================ + # step 2: read SpotAnim/GFX definitions + # ================================================================ + print("\n\nreading SpotAnim/GFX definitions (index 2, group 13)...") + spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) + print(f" {len(spotanim_files)} total spotanim entries") + + spotanim_defs: dict[int, SpotAnimDef] = {} + for gfx_id, label in sorted(INFERNO_SPOTANIM_IDS.items()): + if gfx_id not in spotanim_files: + print(f" GFX {gfx_id} ({label}): NOT FOUND in cache") + continue + + sa = parse_modern_spotanim(gfx_id, spotanim_files[gfx_id]) + spotanim_defs[gfx_id] = sa + + print(f" GFX {gfx_id} ({label}): model={sa.model_id}, seq={sa.seq_id}, " + f"scale={sa.width_scale}x{sa.height_scale}") + + if sa.model_id >= 0: + all_model_ids.add(sa.model_id) + if sa.seq_id >= 0: + all_anim_ids.add(sa.seq_id) + + print(f"\ntotal unique model IDs to export: {len(all_model_ids)}") + print(f" {sorted(all_model_ids)}") + print(f"total unique animation IDs to export: {len(all_anim_ids)}") + print(f" {sorted(all_anim_ids)}") + + # ================================================================ + # step 3: export NPC models + # ================================================================ + print("\n\nexporting NPC + GFX models...") + all_models: list[ModelData] = [] + + # for each NPC, merge sub-models, apply recolors/scale + for npc_id, npc in sorted(npc_defs.items()): + sub_models: list[ModelData] = [] + for mid in npc.model_ids: + raw = load_model_modern(reader, mid) + if raw is None: + print(f" warning: model {mid} not found for NPC {npc_id}") + continue + md = decode_model(mid, raw) + if md is None: + print(f" warning: failed to decode model {mid} for NPC {npc_id}") + continue + sub_models.append(md) + + if not sub_models: + print(f" NPC {npc_id}: no models decoded") + continue + + if len(sub_models) == 1: + merged = sub_models[0] + else: + merged = _merge_models(sub_models) + + # apply recolors + if npc.recolor_src: + apply_recolors(merged, npc.recolor_src, npc.recolor_dst) + + # apply scale + apply_scale(merged, npc.width_scale, npc.height_scale) + + # use NPC ID as model ID for lookup (synthetic: 0xC0000 + npc_id) + merged.model_id = 0xC0000 + npc_id + all_models.append(merged) + print(f" NPC {npc_id} ({npc.name}): {merged.vertex_count} verts, {merged.face_count} faces") + + # export GFX projectile models, applying spotanim recolors where needed. + # recolored models get synthetic IDs (0xD0000 | gfx_id) so the cache binary + # can hold both the raw and recolored variants of the same base model. + exported_gfx_models: set[int] = set() + for gfx_id, sa in sorted(spotanim_defs.items()): + if sa.model_id < 0: + continue + raw = load_model_modern(reader, sa.model_id) + if raw is None: + print(f" warning: GFX {gfx_id} model {sa.model_id} not found") + continue + md = decode_model(sa.model_id, raw) + if md is None: + print(f" warning: failed to decode GFX {gfx_id} model {sa.model_id}") + continue + if sa.recolor_src: + apply_recolors(md, sa.recolor_src, sa.recolor_dst) + md.model_id = 0xD0000 | gfx_id + print(f" GFX {gfx_id} model {sa.model_id} -> 0x{md.model_id:X} (recolored): {md.vertex_count} verts") + else: + if sa.model_id in exported_gfx_models: + continue # already exported this raw model + print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") + exported_gfx_models.add(md.model_id) + all_models.append(md) + + # write models binary + models_path = output_dir / "inferno.models" + write_models_binary(models_path, all_models) + file_size = models_path.stat().st_size + print(f"\nwrote {len(all_models)} models ({file_size:,} bytes) to {models_path}") + + # ================================================================ + # step 4: export animations + # ================================================================ + print("\n\nexporting animations...") + seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) + + sequences: dict[int, SequenceDef] = {} + for seq_id in sorted(all_anim_ids): + if seq_id not in seq_files: + print(f" warning: sequence {seq_id} not found in cache") + continue + modern_seq = parse_modern_sequence(seq_id, seq_files[seq_id]) + seq = SequenceDef( + seq_id=modern_seq.seq_id, + frame_count=modern_seq.frame_count, + frame_delays=modern_seq.frame_delays, + primary_frame_ids=modern_seq.primary_frame_ids, + frame_step=modern_seq.frame_step, + interleave_order=modern_seq.interleave_order, + priority=modern_seq.forced_priority, + loop_count=modern_seq.max_loops, + walk_flag=modern_seq.priority, + run_flag=modern_seq.precedence_animating, + ) + sequences[seq_id] = seq + print(f" seq {seq_id}: {seq.frame_count} frames, delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}") + + # collect needed frame groups + needed_groups: set[int] = set() + for seq_id in all_anim_ids & set(sequences.keys()): + seq = sequences[seq_id] + for fid in seq.primary_frame_ids: + if fid != -1: + needed_groups.add(fid >> 16) + + print(f" loading {len(needed_groups)} frame archives...") + + # first pass: discover framebase IDs from frame data headers + needed_base_ids: set[int] = set() + raw_frame_data: dict[int, dict[int, bytes]] = {} + for group_id in sorted(needed_groups): + try: + files = reader.read_group(MODERN_FRAME_INDEX, group_id) + except (KeyError, FileNotFoundError): + print(f" warning: frame archive {group_id} not found") + continue + raw_frame_data[group_id] = files + for file_data in files.values(): + if len(file_data) >= 2: + fb_id = (file_data[0] << 8) | file_data[1] + needed_base_ids.add(fb_id) + + print(f" loading {len(needed_base_ids)} framebases...") + framebases = load_modern_framebases(reader, needed_base_ids) + print(f" loaded {len(framebases)} framebases") + + # second pass: parse frames + all_frames: dict[int, dict[int, FrameDef]] = {} + for group_id, files in raw_frame_data.items(): + frames: dict[int, FrameDef] = {} + for file_id, file_data in files.items(): + if len(file_data) < 3: + continue + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + if frames: + all_frames[group_id] = frames + + total_frames = sum(len(v) for v in all_frames.values()) + print(f" {len(all_frames)} frame archives, {total_frames} total frames") + + # write animations binary + anims_path = output_dir / "inferno.anims" + available_seqs = all_anim_ids & set(sequences.keys()) + write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) + + # ================================================================ + # step 5: update npc_models.h + # ================================================================ + print("\n\nupdating npc_models_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 tools/export_encounter_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") + 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}") + + # ================================================================ + # step 6: print encounter_inferno.h mapping table + # ================================================================ + print("\n\n========================================") + print("INF_NPC_DEF_IDS mapping table for encounter_inferno.h:") + print("========================================") + print("static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = {") + + inf_type_to_npc = { + "INF_NPC_NIBBLER": 7691, + "INF_NPC_BAT": 7692, + "INF_NPC_BLOB": 7693, + "INF_NPC_BLOB_MELEE": 7694, + "INF_NPC_BLOB_RANGE": 7695, + "INF_NPC_BLOB_MAGE": 7696, + "INF_NPC_MELEER": 7697, + "INF_NPC_RANGER": 7698, + "INF_NPC_MAGER": 7699, + "INF_NPC_JAD": 7700, + "INF_NPC_ZUK": 7706, + "INF_NPC_HEALER_JAD": 7701, + "INF_NPC_HEALER_ZUK": 7708, + "INF_NPC_ZUK_SHIELD": 7707, + } + for enum_name, npc_id in inf_type_to_npc.items(): + npc = npc_defs.get(npc_id) + name = npc.name if npc else "UNKNOWN" + print(f" [{enum_name}] = {npc_id}, /* {name} */") + print("};") + + print("\ndone.") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py index ff37f5f7da..e5dbc6c7f6 100644 --- a/ocean/osrs/scripts/export_models.py +++ b/ocean/osrs/scripts/export_models.py @@ -1772,6 +1772,7 @@ def _load_model(mid: int) -> ModelData | None: 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 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 index bb97de2091..a51d703d01 100644 --- a/ocean/osrs/tests/test_bolt_procs.c +++ b/ocean/osrs/tests/test_bolt_procs.c @@ -6,8 +6,8 @@ * and edge cases against .refs/osrs-dps-calc/src/lib/dists/bolts.ts. * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_bolt_procs \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_bolt_procs \ * ocean/osrs/tests/test_bolt_procs.c -lm * ./test_bolt_procs */ @@ -16,7 +16,7 @@ #include #include -#include "osrs_bolt_procs.h" +#include "ocean/osrs/osrs_bolt_procs.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_collision.c b/ocean/osrs/tests/test_collision.c index ae745286d8..68b2b34c7a 100644 --- a/ocean/osrs/tests/test_collision.c +++ b/ocean/osrs/tests/test_collision.c @@ -5,7 +5,7 @@ * Validates that collision flags block movement correctly, that the pathfinder * routes around obstacles, and that NULL collision map preserves flat arena behavior. * - * Compile: cd PufferLib && cc -O2 -Isrc/osrs -o test_collision ocean/osrs/tests/test_collision.c -lm + * Compile: cc -O2 -o test_collision test_collision.c -lm * Run: ./test_collision */ @@ -195,21 +195,21 @@ TEST(test_save_and_load) { * ========================================================================= */ TEST(test_pathfind_already_at_dest) { - PathResult r = pathfind_step(NULL, 0, 100, 100, 100, 100); + 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); + 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); + 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); @@ -225,7 +225,7 @@ TEST(test_pathfind_around_wall) { collision_mark_blocked(map, 0, 102, y); } - PathResult r = pathfind_step(map, 0, 100, 100, 105, 100); + 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. @@ -251,7 +251,7 @@ TEST(test_pathfind_completely_blocked) { /* also block the dest tile itself */ collision_mark_blocked(map, 0, 105, 100); - PathResult r = pathfind_step(map, 0, 100, 100, 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 */ @@ -268,7 +268,7 @@ TEST(test_pathfind_respects_wall_flags) { 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); + 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) */ diff --git a/ocean/osrs/tests/test_consumables.c b/ocean/osrs/tests/test_consumables.c index a6d549ed08..76a4c3f32b 100644 --- a/ocean/osrs/tests/test_consumables.c +++ b/ocean/osrs/tests/test_consumables.c @@ -2,12 +2,12 @@ * @file test_consumables.c * @brief Tests for shared food/potion/brew functions in osrs_consumables.h. * - * Build: cc -std=c11 -O0 -g -Isrc/osrs -o test_consumables ocean/osrs/tests/test_consumables.c -lm + * Build: cc -std=c11 -O0 -g -I. -o test_consumables ocean/osrs/tests/test_consumables.c -lm */ #include #include -#include "osrs_consumables.h" +#include "ocean/osrs/osrs_consumables.h" static int total_tests = 0; static int passed_tests = 0; diff --git a/ocean/osrs/tests/test_damage.c b/ocean/osrs/tests/test_damage.c index 0b8e371aa6..82761f9742 100644 --- a/ocean/osrs/tests/test_damage.c +++ b/ocean/osrs/tests/test_damage.c @@ -4,8 +4,8 @@ * (prayer reduction, vengeance, recoil, smite). * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_damage \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_damage \ * ocean/osrs/tests/test_damage.c -lm * ./test_damage * @@ -21,7 +21,7 @@ #include #include -#include "osrs_damage.h" +#include "ocean/osrs/osrs_damage.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_interaction.c b/ocean/osrs/tests/test_interaction.c index d393d80a90..e4060ccc2a 100644 --- a/ocean/osrs/tests/test_interaction.c +++ b/ocean/osrs/tests/test_interaction.c @@ -3,8 +3,8 @@ * @brief tests for osrs_interaction.h: entity interaction system + spec toggle * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_interaction \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_interaction \ * ocean/osrs/tests/test_interaction.c -lm * ./test_interaction */ @@ -12,7 +12,7 @@ #include #include -#include "osrs_interaction.h" +#include "ocean/osrs/osrs_interaction.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_inventory.c b/ocean/osrs/tests/test_inventory.c index c772e8a8db..281202ba95 100644 --- a/ocean/osrs/tests/test_inventory.c +++ b/ocean/osrs/tests/test_inventory.c @@ -3,8 +3,8 @@ * @brief tests for osrs_inventory.h: 28-slot inventory + equipment management * * BUILD: - * cd PufferLib - * cc -std=c11 -O0 -g -Isrc/osrs -o test_inventory \ + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_inventory \ * ocean/osrs/tests/test_inventory.c -lm * ./test_inventory */ @@ -13,7 +13,7 @@ #include #include -#include "osrs_inventory.h" +#include "ocean/osrs/osrs_inventory.h" /* ======================================================================== */ /* test harness */ diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c index fd88ebf6be..69bba9201f 100644 --- a/ocean/osrs/tests/test_npc_movement.c +++ b/ocean/osrs/tests/test_npc_movement.c @@ -117,52 +117,36 @@ static void test_far_npc_walks(void) { } /* ======================================================================== */ -/* npc_has_line_of_sight: regression for the "ranged NPC walks into melee" */ -/* bug (inferno-encounter). inf_npc_move must gate movement using LOS to */ -/* the NPC's CURRENT target (player OR shield/other NPC), not hardcoded to */ -/* the player. these tests lock in the generic target semantics of the */ -/* shared LOS helper so any caller can trust it for arbitrary targets. */ +/* 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. */ /* ======================================================================== */ -/* --- ranged NPC sees a non-player target (shield-like) in range, clear --- */ -static void test_los_to_npc_target_in_range_clear(void) { - printf("--- LOS to non-player target: in range, clear ray ---\n"); - /* mager at (20, 36) size 4, shield at (23, 44) size 5 (zuk wave layout). - no blockers. attack_range=15. closest NPC corner to target (23, 44): - cx = clamp(23, [20, 23]) = 23, cy = clamp(44, [36, 39]) = 39. - trace (23, 44) -> (23, 39): dy=-5, dx=0, within range, clear. */ - int has = npc_has_line_of_sight(NULL, 0, 20, 36, 4, 23, 44, 15); +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); } -/* --- ranged NPC sees a non-player target, pillar blocks ray --- */ -static void test_los_to_npc_target_blocked_by_pillar(void) { - printf("--- LOS to non-player target: in range, pillar blocks ---\n"); - /* mager at (10, 40) size 4 (footprint 10..13 × 40..43), target at - (23, 40). closest NPC corner to target: (13, 40). horizontal ray. - pillar at (15, 40) size 3 (covers 15..17 × 40..42) sits on the ray. */ +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 = npc_has_line_of_sight(&pillar, 1, 10, 40, 4, 23, 40, 15); + 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); } -/* --- ranged NPC too far from non-player target: out of range --- */ -static void test_los_to_npc_target_out_of_range(void) { - printf("--- LOS to non-player target: out of range ---\n"); - /* mager at (0, 0) size 4, target at (25, 25) size 1. Chebyshev from - closest NPC corner (3, 3) to (25, 25) is 22. attack_range=15. */ - int has = npc_has_line_of_sight(NULL, 0, 0, 0, 4, 25, 25, 15); +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); } -/* --- symmetric check: LOS to player coords same function, same contract --- */ -static void test_los_to_player_target_in_range_clear(void) { - printf("--- LOS to player target: in range, clear ray (control) ---\n"); - /* ranger at (14, 32) size 3, player at (22, 25) size 1. - closest NPC corner (16, 32). trace (22, 25) -> (16, 32): dx=-6, dy=7. - range=15. clear. */ - int has = npc_has_line_of_sight(NULL, 0, 14, 32, 3, 22, 25, 15); - ASSERT_EQ("has_los to player = 1", has, 1); +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) { @@ -172,10 +156,10 @@ int main(void) { test_melee_adjacent_natural_stop(); test_far_npc_walks(); - test_los_to_npc_target_in_range_clear(); - test_los_to_npc_target_blocked_by_pillar(); - test_los_to_npc_target_out_of_range(); - test_los_to_player_target_in_range_clear(); + 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_player_combat.c b/ocean/osrs/tests/test_player_combat.c index 5ff84c3496..8c0d0db13a 100644 --- a/ocean/osrs/tests/test_player_combat.c +++ b/ocean/osrs/tests/test_player_combat.c @@ -6,13 +6,13 @@ * double accuracy, and equipment bonus summation against osrs-dps-calc * reference values. * - * Build: cc -std=c11 -O0 -g -Isrc/osrs -o test_player_combat ocean/osrs/tests/test_player_combat.c -lm + * Build: cc -std=c11 -O0 -g -I. -o test_player_combat ocean/osrs/tests/test_player_combat.c -lm */ #include #include #include -#include "osrs_combat.h" +#include "ocean/osrs/osrs_combat.h" static int total_tests = 0; static int passed_tests = 0; diff --git a/ocean/osrs/tools/generate_monsters.py b/ocean/osrs/tools/generate_monsters.py index 1360ac8609..2e3eac4aa4 100644 --- a/ocean/osrs/tools/generate_monsters.py +++ b/ocean/osrs/tools/generate_monsters.py @@ -92,16 +92,14 @@ 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 - # strip HTML entities like   - cleaned = re.sub(r"&\w+;", "", str(raw)).strip() - # handle "X (Style)" format + cleaned = re.sub(r"&\w+;", " ", str(raw)) cleaned = re.split(r"\s*\(", cleaned)[0].strip() - # handle "X-Y" ranges (take max) - if "-" in cleaned: - parts = cleaned.split("-") - return max(int(p.strip()) for p in parts if p.strip().isdigit()) - if cleaned.isdigit(): - return int(cleaned) + range_matches = [ + int(value) + for value in re.findall(r"\d+", cleaned.replace("–", "-").replace("—", "-")) + ] + if range_matches: + return max(range_matches) return 0 diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index d5140f169a..57857fb56f 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -146,7 +146,7 @@ void c_step(Env* env) { 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; + 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]; @@ -223,19 +223,21 @@ void c_step(Env* env) { const char* rpath = getenv("RECORD_REPLAY"); if (rpath && rpath[0]) { FILE* fp = fopen(rpath, "wb"); - if (fp) { - fwrite(&env->episode_action_len, sizeof(int), 1, fp); - fwrite(&env->episode_rng_start, sizeof(uint32_t), 1, fp); - fwrite(env->episode_actions, sizeof(int), - env->episode_action_len * NUM_ATNS, fp); - fclose(fp); - if (st->start_wave >= 68) { - fprintf(stderr, "replay: new best zuk hp=%d (%d ticks, rng=%u) saved to %s\n", - g_best_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); - } + 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 zuk hp=%d (%d ticks, rng=%u) saved to %s\n", + g_best_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); } } } @@ -386,9 +388,19 @@ void my_init(Env* env, Dict* kwargs) { int sw = start_wave ? (int)start_wave->value : 0; env->config_start_wave = (sw > 0) ? sw - 1 : 0; - /* allocate action buffer for best-episode recording (all envs buffer) */ - if (getenv("RECORD_REPLAY") && getenv("RECORD_REPLAY")[0]) { + 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; @@ -405,34 +417,41 @@ void my_init(Env* env, Dict* kwargs) { env->ticks_per_second = 1.667f; env->last_step_time = 0.0; static int g_play_replay_loaded = 0; - const char* play_path = getenv("PLAY_REPLAY"); 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); - } else { - 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) { - int* buf = (int*)malloc(num_ticks * NUM_ATNS * sizeof(int)); - if (fread(buf, sizeof(int), num_ticks * NUM_ATNS, fp) == (size_t)(num_ticks * NUM_ATNS)) { - 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); - /* seed the encounter to match the recording */ - ENCOUNTER_INFERNO.reset(env->enc_state, rng_seed); - } else { - free(buf); - fprintf(stderr, "PLAY_REPLAY: short read from %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); } } @@ -563,7 +582,8 @@ void my_log(Log* log, Dict* out) { float wr = log->wins; float score; - if (log->start_wave >= 68) { + int start_wave = (int)(log->start_wave + 0.5f); + if (start_wave >= 68) { /* Zuk-only: score = fraction of Zuk HP removed (0..1), wins = 1.0 */ score = (1200.0f - log->zuk_hp_remaining) / 1200.0f; } else { diff --git a/pufferlib/config/ocean/osrs_inferno.ini b/pufferlib/config/ocean/osrs_inferno.ini new file mode 100644 index 0000000000..ffe6d1e187 --- /dev/null +++ b/pufferlib/config/ocean/osrs_inferno.ini @@ -0,0 +1,183 @@ +# 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 +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.0 +curriculum_frac_1 = 0.0 +curriculum_wave_2 = 40.0 +curriculum_frac_2 = 0.0 +curriculum_wave_3 = 60.0 +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 From c6d72b1ee6c41c3fd761e5f511ea43ad9b02f7e8 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 22 Apr 2026 13:05:25 +0300 Subject: [PATCH 52/60] fix jad preview and inferno visuals --- build.sh | 2 +- ocean/osrs/binding.c | 7 +- ocean/osrs/data/npc_models.h | 6 +- ocean/osrs/data/npc_models_inferno.h | 14 +- ocean/osrs/encounters/encounter_inferno.h | 31 +- ocean/osrs/scripts/export_inferno_npcs.py | 19 +- ocean/osrs/tests/test_inferno_attack_styles.c | 605 ++++++++++++++++++ 7 files changed, 664 insertions(+), 20 deletions(-) create mode 100644 ocean/osrs/tests/test_inferno_attack_styles.c diff --git a/build.sh b/build.sh index 72ab057649..311c040c5f 100755 --- a/build.sh +++ b/build.sh @@ -126,7 +126,7 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v6/osrs-assets-v6.tar.gz" + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v7/osrs-assets-v7.tar.gz" mkdir -p data curl -sL "$OSRS_ASSETS_URL" | tar xz -C data fi diff --git a/ocean/osrs/binding.c b/ocean/osrs/binding.c index 9d69adcffd..baceb24975 100644 --- a/ocean/osrs/binding.c +++ b/ocean/osrs/binding.c @@ -1,10 +1,11 @@ /** * @file binding.c - * @brief Metal static-native binding for OSRS PVP environment + * @brief Metal static-native vec binding for the shared OSRS PvP environment. * - * Bridges vecenv.h's contract (float actions, float terminals) with the PVP + * 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. + * 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" diff --git a/ocean/osrs/data/npc_models.h b/ocean/osrs/data/npc_models.h index d7eadc30dd..38ed8f741f 100644 --- a/ocean/osrs/data/npc_models.h +++ b/ocean/osrs/data/npc_models.h @@ -63,10 +63,12 @@ static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { /* 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_447_MODEL INF_GEN_GFX_447_MODEL -#define INF_GFX_447_ANIM INF_GEN_GFX_447_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 diff --git a/ocean/osrs/data/npc_models_inferno.h b/ocean/osrs/data/npc_models_inferno.h index 8ea7338099..d7b262f6c7 100644 --- a/ocean/osrs/data/npc_models_inferno.h +++ b/ocean/osrs/data/npc_models_inferno.h @@ -1,4 +1,4 @@ -/* generated by tools/export_encounter_npcs.py -- do not edit */ +/* generated by scripts/export_inferno_npcs.py -- do not edit */ #ifndef NPC_MODELS_INFERNO_H #define NPC_MODELS_INFERNO_H @@ -41,6 +41,8 @@ static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { #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_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 @@ -62,11 +64,13 @@ static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { /* 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_447_MODEL 9334 /* Jad ranged projectile (fireball) */ -#define INF_GEN_GFX_447_ANIM 2658 -#define INF_GEN_GFX_448_MODEL 9337 /* Jad magic projectile */ +#define INF_GEN_GFX_448_MODEL 9337 /* Jad magic projectile (front) */ #define INF_GEN_GFX_448_ANIM 2659 -#define INF_GEN_GFX_451_MODEL 9342 /* Jad ranged hit */ +#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 diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 747e821601..6b06a7b5d6 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -791,6 +791,17 @@ static inline int inf_attack_style_obs_preview(int style_mask) { 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; @@ -3129,7 +3140,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { style = npc->jad_attack_style; if (style == ATTACK_STYLE_NONE) continue; } - int style_mask = inf_attack_style_options_mask( + int style_mask = inf_attack_style_telegraph_mask( s, npc, st, style, dist); int preview_style = inf_attack_style_obs_preview(style_mask); @@ -3546,6 +3557,12 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i 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 && nm && nm->attack_anim != 65535) { re->npc_anim_id = (int)nm->attack_anim; } else { @@ -3721,7 +3738,9 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { 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_447_MODEL; + 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; @@ -3753,9 +3772,11 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { if (actual_style == ATTACK_STYLE_MAGIC) { arc = 1.0f; /* arcing magic projectile */ } - /* InfernoTrainer JAD_PROJECTILE_DELAY=3: projectile invisible - for first 3 ticks, shorter visible flight. */ - duration -= 3 * 30; + /* InfernoTrainer Jad projectiles use visualDelayTicks=3 and + visualHitEarlyTicks=-1, so the visible segment lasts + hit_delay - 3 + 1 ticks. With Jad's fixed 4-tick land delay + that means a 2-tick visible flight, not 1. */ + duration = (hit_delay - 2) * 30; if (duration < 30) duration = 30; break; case INF_NPC_HEALER_ZUK: diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py index f4d0670bc0..339d578bd6 100644 --- a/ocean/osrs/scripts/export_inferno_npcs.py +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -96,15 +96,23 @@ 7708: 65535, # zuk healer (no attack anim) } +INFERNO_EXTRA_ANIMS: dict[int, dict[str, int]] = { + 7697: { + "DIG_DOWN": 7600, + "DIG_UP": 7601, + }, +} + # known inferno projectile/effect GFX IDs to check # from OSRS wiki inferno page and runelite inferno plugin INFERNO_SPOTANIM_IDS = { # jad attacks - 447: "Jad ranged projectile (fireball)", - 448: "Jad magic projectile", + 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", - 451: "Jad ranged hit", 157: "Jad magic hit", # mager 1379: "Mager magic projectile", @@ -726,7 +734,7 @@ def main() -> None: # write C header with open(header_path, "w") as f: - f.write("/* generated by tools/export_encounter_npcs.py -- do not edit */\n") + 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") @@ -748,6 +756,9 @@ def main() -> None: 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") 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..4065f97578 --- /dev/null +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -0,0 +1,605 @@ +/** + * @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 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 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_occupancy(void) { + printf("--- overlap shuffle respects npc occupancy ---\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_occupancy(&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_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_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_preview_and_obs_timing(void) { + printf("--- jad preview and obs timing ---\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 preview decrements to one", state.npcs[0].attack_timer, 1); + ASSERT_INT_EQ( + "jad preview style committed", + state.npcs[0].jad_attack_style == ATTACK_STYLE_RANGED || + state.npcs[0].jad_attack_style == ATTACK_STYLE_MAGIC, + 1); + + float obs[INF_NUM_OBS]; + inf_write_obs((EncounterState*)&state, obs); + ASSERT_FLOAT_NEAR("prayer-critical timer exposes next-tick jad telegraph", obs[37], 0.1f, 1e-6f); + ASSERT_INT_EQ( + "prayer-critical style is one-hot for jad preview", + (int)(obs[38] + obs[39] + obs[40]), + 1); + + state.npcs[0].attack_timer = 1; + state.npcs[0].jad_attack_style = ATTACK_STYLE_MAGIC; + state.player_pending_hit_count = 0; + state.npcs[0].attacked_this_tick = 0; + + inf_npc_attack(&state, 0); + + ASSERT_INT_EQ("jad attack queued one pending hit", state.player_pending_hit_count, 1); + ASSERT_INT_EQ("jad preview resets after firing", state.npcs[0].jad_attack_style, ATTACK_STYLE_NONE); + ASSERT_INT_EQ("jad pending hit keeps prayer delay", state.player_pending_hits[0].prayer_check_delay, 3); + ASSERT_INT_EQ("jad pending hit keeps land delay", state.player_pending_hits[0].ticks_remaining, 4); + + memset(obs, 0, sizeof(obs)); + inf_write_obs((EncounterState*)&state, obs); + int pending_start = INF_NUM_OBS - INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS; + ASSERT_FLOAT_NEAR("pending hit obs timer uses prayer window not impact delay", obs[pending_start + 3], 0.3f, 1e-6f); +} + +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); +} + +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_occupancy(); + test_meleer_dig_landing_order(); + test_dead_mob_store_eligibility(); + test_pending_hit_obs_timer_prefers_prayer_window(); + test_jad_preview_and_obs_timing(); + test_jad_melee_stays_instant_and_untelegraphed(); + test_zuk_obs_tracks_shield_and_mager_aggro(); + + 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; +} From ab66c82f5ada467216ea62e0909e2c8ffaca085c Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 22 Apr 2026 13:21:33 +0300 Subject: [PATCH 53/60] fix inferno human mode --- ocean/osrs/encounters/encounter_inferno.h | 42 ++++--- ocean/osrs/osrs_encounter.h | 1 + ocean/osrs/osrs_gui.h | 8 +- ocean/osrs/osrs_human_input.h | 18 ++- ocean/osrs/osrs_human_input_types.h | 2 +- ocean/osrs/osrs_render.h | 54 +++++++-- ocean/osrs/osrs_types.h | 12 +- ocean/osrs/tests/test_inferno_attack_styles.c | 111 ++++++++++++++++++ 8 files changed, 218 insertions(+), 30 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 6b06a7b5d6..1c9a3f5ea0 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -3381,6 +3381,28 @@ static void inf_write_obs(EncounterState* state, float* obs) { } } +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; @@ -3409,12 +3431,7 @@ static void inf_write_mask(EncounterState* state, float* mask) { 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++) { - int idx = s->current_obs_slots[n]; - if (idx >= 0 && s->npcs[idx].active && s->npcs[idx].death_ticks == 0 && s->npcs[idx].type != INF_NPC_ZUK_SHIELD) { - mask[offset++] = 1.0f; - } else { - mask[offset++] = 0.0f; - } + mask[offset++] = inf_obs_slot_is_targetable(s, n) ? 1.0f : 0.0f; } /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ @@ -3893,14 +3910,8 @@ static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterSta 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 = -1; - for (int j = 0; j < INF_OBS_NPCS; j++) { - if (s->current_obs_slots[j] == hi->pending_target_idx) { - found_slot = j; - break; - } - } - if (found_slot >= 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; @@ -3918,6 +3929,8 @@ static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterSta /* 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; @@ -3967,6 +3980,7 @@ static const EncounterDef ENCOUNTER_INFERNO = { .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, diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 6620d9a160..8b779186e6 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -1581,6 +1581,7 @@ typedef struct { 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. */ diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index ed8fee63e0..4011050c75 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -1352,7 +1352,13 @@ static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, if (human_active) { hi->pending_potion = POTION_ANTIVENOM; gs->human_clicked_inv_slot = slot; } return INV_ACTION_DRINK; case INV_SLOT_PRAYER_POT: - if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + if (human_active) { hi->pending_potion = POTION_PRAYER_POT; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_BASTION_POT: + if (human_active) { hi->pending_potion = POTION_BASTION; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_STAMINA_POT: + if (human_active) { hi->pending_potion = POTION_STAMINA; gs->human_clicked_inv_slot = slot; } return INV_ACTION_DRINK; default: return INV_ACTION_NONE; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index f7840dc566..a9c7d5d64a 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -94,6 +94,9 @@ static int human_tile_hits_entity(RenderEntity* ent, int wx, int wy) { 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; @@ -109,12 +112,18 @@ 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) { + 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) continue; /* can't attack self */ + 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 @@ -149,6 +158,8 @@ static void human_handle_ground_click(HumanInput* hi, 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; @@ -160,7 +171,8 @@ static void human_handle_ground_click(HumanInput* hi, 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); + entities, entity_count, gui_entity_idx, + can_attack_entity, can_attack_ctx); } /** Handle prayer icon click. Hit-tests the 5-col prayer grid. diff --git a/ocean/osrs/osrs_human_input_types.h b/ocean/osrs/osrs_human_input_types.h index 9d58aff879..627093b703 100644 --- a/ocean/osrs/osrs_human_input_types.h +++ b/ocean/osrs/osrs_human_input_types.h @@ -23,7 +23,7 @@ typedef struct HumanInput { int pending_offensive_prayer; /* 0=none, 1=piety, 2=rigour, 3=augury, -1=no change */ int pending_food; /* 1 = eat food */ int pending_karambwan; /* 1 = eat karambwan */ - int pending_potion; /* PotionAction value, 0 = none */ + int pending_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 */ diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 92091ef2f3..13bfa19c56 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -538,6 +538,34 @@ static const char* render_entity_display_name(RenderEntity* ent) { 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; @@ -558,8 +586,9 @@ static void context_menu_add(ContextMenu* cm, ContextMenuAction action, /** 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, int mx, int my) { +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; @@ -572,9 +601,11 @@ static void context_menu_build(RenderClient* rc, int mx, int my) { if (rc->mode_3d) { /* 3D: test against convex hulls */ for (int ei = 0; ei < rc->entity_count; ei++) { - if (ei == rc->gui.gui_entity_idx) continue; /* skip self */ RenderEntity* ent = &rc->entities[ei]; - if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + 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; @@ -619,9 +650,11 @@ static void context_menu_build(RenderClient* rc, int mx, int my) { cm->walk_tile_y = wy; for (int ei = 0; ei < rc->entity_count; ei++) { - if (ei == rc->gui.gui_entity_idx) continue; RenderEntity* ent = &rc->entities[ei]; - if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + if (!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; @@ -1209,6 +1242,7 @@ static void render_destroy_client(RenderClient* 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) { @@ -1410,9 +1444,11 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { check entities FIRST before ground tiles. */ int entity_hit = 0; for (int ei = 0; ei < rc->entity_count; ei++) { - if (ei == rc->gui.gui_entity_idx) continue; RenderEntity* ent = &rc->entities[ei]; - if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + if (!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; @@ -1487,6 +1523,8 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { 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); } } @@ -1502,7 +1540,7 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { /* 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, rmx, rmy); + context_menu_build(rc, env, rmx, rmy); } else { context_menu_dismiss(&rc->context_menu); } diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 848a9f8374..f92fcdc166 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -192,7 +192,7 @@ #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 // NONE, BREW, RESTORE, COMBAT, RANGED +#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} @@ -435,14 +435,20 @@ typedef enum { FOOD_EAT, } FoodAction; -/** Potion action head options. */ +/** 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_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. */ diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index 4065f97578..c3a0602535 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -76,6 +76,17 @@ static InfNPCStats make_test_stats(int default_style) { 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 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); @@ -579,6 +590,105 @@ static void test_zuk_obs_tracks_shield_and_mager_aggro(void) { 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); + } +} + int main(void) { inf_build_npc_stats(); @@ -594,6 +704,7 @@ int main(void) { test_jad_preview_and_obs_timing(); test_jad_melee_stays_instant_and_untelegraphed(); test_zuk_obs_tracks_shield_and_mager_aggro(); + test_human_target_and_potion_translation(); printf("\n%d/%d tests passed", tests_passed, tests_run); if (tests_failed > 0) { From 821e26c917b50d1a40fca203f0cdb40c983b3b6c Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 22 Apr 2026 19:10:34 +0300 Subject: [PATCH 54/60] fix inferno mage rez and potion reset ui --- ocean/osrs/encounters/encounter_inferno.h | 3 + ocean/osrs/osrs_gui.h | 72 +++++--- ocean/osrs/osrs_render.h | 6 +- ocean/osrs/osrs_visual.c | 2 +- ocean/osrs/tests/test_gui_inventory.c | 163 ++++++++++++++++++ ocean/osrs/tests/test_inferno_attack_styles.c | 102 +++++++++++ ocean/osrs_inferno/binding.c | 2 +- 7 files changed, 317 insertions(+), 33 deletions(-) create mode 100644 ocean/osrs/tests/test_gui_inventory.c diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 1c9a3f5ea0..5db7c1ef4d 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -411,6 +411,7 @@ typedef struct { /* 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 */ @@ -845,6 +846,7 @@ static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { /* 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; @@ -1942,6 +1944,7 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { 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; /* agent already got paid for driving this mob to 0 the first time — lock min_hp_reached at 0 so re-killing the resurrected copy yields no new min-HP-progress reward. encourages killing mager before it can rez. */ diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 4011050c75..21b0fc5df7 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -964,6 +964,49 @@ static int gui_inv_place_equipment(GuiState* gs, uint8_t item_db_idx) { 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. */ @@ -1035,15 +1078,7 @@ static void gui_populate_inventory(GuiState* gs, Player* p) { #undef ADD_POTION_VIALS /* snapshot player state for incremental change detection */ - memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); - gs->inv_prev_food_count = p->food_count; - gs->inv_prev_karambwan_count = p->karambwan_count; - gs->inv_prev_brew_doses = p->brew_doses; - gs->inv_prev_restore_doses = p->restore_doses; - gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; - gs->inv_prev_combat_doses = p->combat_potion_doses; - gs->inv_prev_ranged_doses = p->ranged_potion_doses; - gs->inv_prev_antivenom_doses = p->antivenom_doses; + gui_snapshot_inventory_state(gs, p); } /** Update potion vial doses in-place when doses change. @@ -1254,27 +1289,12 @@ static void gui_update_inventory(GuiState* gs, Player* p) { /* only clear human click when a consumable was actually used this frame. if no diff happened yet, keep it for the next tick when the sim processes the action. */ - int any_consumable_changed = clicked_used - || (p->brew_doses != gs->inv_prev_brew_doses) - || (p->restore_doses != gs->inv_prev_restore_doses) - || (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) - || (p->combat_potion_doses != gs->inv_prev_combat_doses) - || (p->ranged_potion_doses != gs->inv_prev_ranged_doses) - || (p->antivenom_doses != gs->inv_prev_antivenom_doses); - if (any_consumable_changed) { + if (clicked_used || gui_inventory_consumables_changed(gs, p)) { gs->human_clicked_inv_slot = -1; } /* update snapshot */ - memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); - gs->inv_prev_food_count = p->food_count; - gs->inv_prev_karambwan_count = p->karambwan_count; - gs->inv_prev_brew_doses = p->brew_doses; - gs->inv_prev_restore_doses = p->restore_doses; - gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; - gs->inv_prev_combat_doses = p->combat_potion_doses; - gs->inv_prev_ranged_doses = p->ranged_potion_doses; - gs->inv_prev_antivenom_doses = p->antivenom_doses; + gui_snapshot_inventory_state(gs, p); } /** Get the inventory grid screen position for a slot index. */ diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index 13bfa19c56..c91f0b3a3b 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -890,11 +890,7 @@ static RenderClient* render_make_client(void) { rc->gui.gui_entity_count = 0; /* inventory interaction state */ - rc->gui.inv_dim_slot = -1; - rc->gui.inv_drag_src_slot = -1; - rc->gui.inv_drag_active = 0; - rc->gui.inv_grid_dirty = 1; - rc->gui.human_clicked_inv_slot = -1; + gui_reset_inventory_ui_state(&rc->gui); /* human input control */ human_input_init(&rc->human_input); diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index 9ab5b20a03..8f38e1fb3b 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -268,7 +268,7 @@ static void visual_frame(void* arg) { vs->episode_ended = 0; render_clear_history(rc); effect_clear_all(rc->effects); - rc->gui.inv_grid_dirty = 1; + gui_reset_inventory_ui_state(&rc->gui); if (env->encounter_def) { ((const EncounterDef*)env->encounter_def)->reset( env->encounter_state, (uint32_t)rand()); diff --git a/ocean/osrs/tests/test_gui_inventory.c b/ocean/osrs/tests/test_gui_inventory.c new file mode 100644 index 0000000000..da5e014595 --- /dev/null +++ b/ocean/osrs/tests/test_gui_inventory.c @@ -0,0 +1,163 @@ +/** + * @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. -I./ocean/osrs/raylib-5.5_macos/include -o /tmp/test_gui_inventory \ + * ocean/osrs/tests/test_gui_inventory.c ./ocean/osrs/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/osrs_gui.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 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 = 4; + p.stamina_doses = 4; + + gui_populate_inventory(&gs, &p); + + ASSERT_INT_EQ("snapshot keeps bastion doses", gs.inv_prev_bastion_doses, 4); + ASSERT_INT_EQ("snapshot keeps stamina doses", gs.inv_prev_stamina_doses, 4); + ASSERT_INT_EQ("bastion vial present", find_slot_of_type(&gs, INV_SLOT_BASTION_POT) >= 0, 1); + 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 = 4; + 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 = 3; + 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, 3); + 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 = 4; + 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 = 4; + 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", find_slot_of_type(&gs, INV_SLOT_BASTION_POT) >= 0, 1); + 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, 4); + ASSERT_INT_EQ("stamina snapshot restored", gs.inv_prev_stamina_doses, 4); +} + +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(); + + 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 index c3a0602535..82593eb37c 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -87,6 +87,18 @@ static HumanInput make_human_input(void) { return input; } +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); @@ -346,6 +358,94 @@ static void test_dead_mob_store_eligibility(void) { 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"); @@ -700,6 +800,8 @@ int main(void) { test_overlap_shuffle_respects_npc_occupancy(); test_meleer_dig_landing_order(); 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_preview_and_obs_timing(); test_jad_melee_stays_instant_and_untelegraphed(); diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 57857fb56f..eca0035640 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -335,7 +335,7 @@ void c_render(Env* env) { if (env->pending_render_reset) { render_clear_history(rc); effect_clear_all(rc->effects); - rc->gui.inv_grid_dirty = 1; + 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; From 80cd4a3a567cf6cc88cceb31cad7a650a74baeee Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Wed, 22 Apr 2026 20:50:53 +0300 Subject: [PATCH 55/60] switch inferno to healer-tag rewards --- ocean/osrs/encounters/encounter_inferno.h | 134 ++++++------------ ocean/osrs/tests/test_gui_inventory.c | 29 ++-- ocean/osrs/tests/test_inferno_attack_styles.c | 49 +++++++ ocean/osrs_inferno/binding.c | 9 ++ pufferlib/config/ocean/osrs_inferno.ini | 3 + 5 files changed, 126 insertions(+), 98 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 5db7c1ef4d..007bd21d80 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -25,6 +25,7 @@ #include "../osrs_encounter.h" #include "../osrs_interaction.h" #include "../data/npc_models.h" +#include #include #include @@ -383,10 +384,6 @@ typedef struct { InfNPCType type; int x, y; int hp, max_hp; - int min_hp_reached; /* lowest hp this npc has ever been at; reward accrues only - when current hp drops below this. prevents farming damage - that gets healed back — healing raises current hp but min - stays, so re-damaging up to min gives 0 reward. */ int size; int attack_timer; /* ticks until next attack */ int attack_style; /* current attack style (may differ from default for blobs) */ @@ -570,15 +567,10 @@ typedef struct { float episode_return; /* accumulated reward over entire episode */ 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; - /* HP restored to the enemy side this tick — kept as diagnostic only. - the reward signal now uses min-HP progress, which inherently ignores - heals (they raise current hp without touching the min-reached floor). */ float hp_restored_this_tick; - /* irreversible min-HP progress this tick: sum across all NPCs of how much - their HP dropped below their previous min_hp_reached. healing and - resurrection can't contribute here — only new damage below the floor. */ - float min_hp_progress_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 */ @@ -671,6 +663,9 @@ typedef struct { /* 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; Log log; } InfernoState; @@ -892,6 +887,9 @@ static void inf_reset(EncounterState* state, uint32_t seed) { 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; memset(s, 0, sizeof(InfernoState)); s->log = saved_log; s->start_wave = saved_start; @@ -899,6 +897,9 @@ static void inf_reset(EncounterState* state, uint32_t seed) { 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; /* human click-to-move: no destination after reset */ s->player_dest_x = -1; @@ -939,9 +940,9 @@ static void inf_reset(EncounterState* state, uint32_t seed) { } s->player.num_items_in_slot[GEAR_SLOT_AMMO] = 0; } - s->player.brew_doses = 0; /* 8 pots x 4 doses */ + s->player.brew_doses = 24; /* 6 pots x 4 doses */ s->player.restore_doses = 40; /* 10 pots x 4 doses */ - s->player.bastion_doses = 4; /* 1 pot x 4 doses */ + s->player.bastion_doses = 8; /* 2 pots x 4 doses */ s->player.stamina_doses = 4; /* 1 pot x 4 doses */ s->stamina_active_ticks = 0; s->player.prayer = PRAYER_NONE; @@ -1015,7 +1016,6 @@ static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y npc->type = type; npc->hp = stats->hp; npc->max_hp = stats->hp; - npc->min_hp_reached = stats->hp; npc->size = stats->size; npc->attack_timer = stats->attack_speed; npc->attack_style = stats->default_style; @@ -1543,8 +1543,12 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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) { target->active = 0; @@ -1945,10 +1949,6 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { s->npcs[slot].hp = dm->hp; /* 50% of max HP */ s->npcs[slot].max_hp = dm->max_hp; s->npcs[slot].resurrection_count = 1; - /* agent already got paid for driving this mob to 0 the first time — lock - min_hp_reached at 0 so re-killing the resurrected copy yields no new - min-HP-progress reward. encourages killing mager before it can rez. */ - s->npcs[slot].min_hp_reached = 0; /* remove from dead store (swap with last) */ s->dead_mobs[di] = s->dead_mobs[s->dead_mob_count - 1]; @@ -2692,33 +2692,14 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* reward */ /* ======================================================================== */ -/* walk every active NPC, bank any new HP-low-watermark progress, and update - the min_hp_reached floor. call once per tick, after all damage/heal has - resolved. - - jad healers (Yt-HurKot) are excluded: in practice you tag them once to - switch their aggro from jad to you, then ignore them while you burn jad. - killing them outright is wasted DPS. if damage on healers paid reward the - agent would learn to finish them off instead of just tagging. the - indirect signal — tagged healers stop healing jad, so jad progress stops - getting undone — already makes tagging optimal under min-hp progress. */ -static void inf_accrue_min_hp_progress(InfernoState* s) { - float progress = 0.0f; - for (int i = 0; i < INF_MAX_NPCS; i++) { - InfNPC* npc = &s->npcs[i]; - if (!npc->active) continue; - if (npc->type == INF_NPC_HEALER_JAD) continue; - if (npc->hp < npc->min_hp_reached) { - progress += (float)(npc->min_hp_reached - npc->hp); - npc->min_hp_reached = npc->hp; - } - } - s->min_hp_progress_this_tick = progress; +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) { - /* accumulate diagnostic stats BEFORE terminal check so the killing - blow's damage is counted in total_damage_received */ 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; @@ -2727,51 +2708,19 @@ static float inf_compute_reward(InfernoState* s) { if (s->episode_over) return (s->winner == 0) ? 1.0f : 0.0f; - float r = 0.0f; - - /* survival: per-tick bonus for staying alive */ - //if (s->wave >= 68) - // r += 0.001f; - - /* shield positioning: strong signal for the core Zuk mechanic. - this is THE thing we need the agent to learn first. */ - //if (s->behind_shield_this_tick) - // r += 0.005f; - - /* irreversible-progress reward: only new HP below each NPC's low-water - mark counts. damage that gets healed back is worth 0 on re-application; - farming is impossible because hitting the same HP twice only pays the - first time. killing healers gives their full HP as progress since they - can't be healed back up. naturally incentivizes: kill healers first, - then finish the boss. - - zuk-phase override kept: while zuk healers are alive, zuk progress is - zeroed so the agent doesn't learn to race zuk while healers tick him - back up. once the healers are cleared, all progress (including zuk's) - flows normally. */ - int zuk_healers_alive = 0; + int healer_is_actively_healing = 0; for (int i = 0; i < INF_MAX_NPCS; i++) { - if (s->npcs[i].active && s->npcs[i].type == INF_NPC_HEALER_ZUK && s->npcs[i].death_ticks == 0) { - zuk_healers_alive = 1; + if (inf_healer_is_actively_healing(s, &s->npcs[i])) { + healer_is_actively_healing = 1; break; } } - float progress = s->min_hp_progress_this_tick; - if (zuk_healers_alive) { - /* recompute progress ignoring zuk itself so only healer damage counts. */ - progress = 0.0f; - /* re-walk the delta we just banked: we no longer have per-npc deltas, - so use the already-banked damage_zuk_healers_this_tick which tracks - landed damage on zuk-healers specifically. this is a rough match — - it overcounts by healers-healing-zuk-healers but that path doesn't - exist, so it's effectively equal to the min-hp progress on healers. */ - progress = s->damage_zuk_healers_this_tick; - } - if (progress > 0.0f) - r += 0.001f * progress; - - return r; + float reward = healer_is_actively_healing + ? s->tag_reward_coeff * (float)s->healer_tags_this_tick + : 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; } /* ======================================================================== */ @@ -2786,9 +2735,10 @@ static void inf_step(EncounterState* state, const int* actions) { 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->min_hp_progress_this_tick = 0.0f; s->prayer_correct_this_tick = 0; s->off_prayer_hits_this_tick = 0; s->tick_styles_fired = 0; @@ -2845,8 +2795,9 @@ static void inf_step(EncounterState* state, const int* actions) { &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 += (s->damage_dealt_this_tick - dmg_before); + s->damage_zuk_healers_this_tick += landed_damage; } s->npcs[i].hit_spell_type = spell; /* tagging: aggro switches on projectile LAND, not FIRE. matches @@ -2854,6 +2805,10 @@ static void inf_step(EncounterState* state, const int* actions) { 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 */ } @@ -2958,7 +2913,6 @@ static void inf_step(EncounterState* state, const int* actions) { /* bank the tick's irreversible HP progress before computing reward. all damage landings and healer applies have resolved by this point. */ - inf_accrue_min_hp_progress(s); s->reward = inf_compute_reward(s); s->episode_return += s->reward; @@ -3057,7 +3011,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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 / 32.0f; + obs[i++] = (float)s->player.brew_doses / 24.0f; obs[i++] = (float)s->player.restore_doses / 40.0f; obs[i++] = (float)s->player.current_prayer / 99.0f; obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; @@ -3069,7 +3023,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; obs[i++] = s->armor_tank ? 1.0f : 0.0f; - obs[i++] = (float)s->player.bastion_doses / 4.0f; + obs[i++] = (float)s->player.bastion_doses / 8.0f; obs[i++] = (float)s->player.stamina_doses / 4.0f; obs[i++] = (s->stamina_active_ticks > 0) ? 1.0f : 0.0f; obs[i++] = (float)s->player.potion_timer / 3.0f; @@ -3643,7 +3597,11 @@ static void inf_put_int(EncounterState* state, const char* key, int value) { } static void inf_put_float(EncounterState* state, const char* key, float value) { - (void)state; (void)key; (void)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 assert(0 && "unknown inferno float config"); } static void inf_put_ptr(EncounterState* state, const char* key, void* value) { diff --git a/ocean/osrs/tests/test_gui_inventory.c b/ocean/osrs/tests/test_gui_inventory.c index da5e014595..38d39ad6a9 100644 --- a/ocean/osrs/tests/test_gui_inventory.c +++ b/ocean/osrs/tests/test_gui_inventory.c @@ -36,6 +36,14 @@ static int find_slot_of_type(const GuiState* gs, InvSlotType type) { 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 void test_gui_populate_tracks_bastion_and_stamina(void) { printf("--- gui populate tracks inferno potion snapshots ---\n"); @@ -44,14 +52,14 @@ static void test_gui_populate_tracks_bastion_and_stamina(void) { memset(&gs, 0, sizeof(gs)); memset(&p, 0, sizeof(p)); - p.bastion_doses = 4; + 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, 4); + 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 vial present", find_slot_of_type(&gs, INV_SLOT_BASTION_POT) >= 0, 1); + 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); } @@ -63,7 +71,7 @@ static void test_gui_update_tracks_bastion_and_stamina(void) { memset(&gs, 0, sizeof(gs)); memset(&p, 0, sizeof(p)); - p.bastion_doses = 4; + p.bastion_doses = 8; p.stamina_doses = 4; gui_populate_inventory(&gs, &p); @@ -71,11 +79,12 @@ static void test_gui_update_tracks_bastion_and_stamina(void) { int stamina_slot = find_slot_of_type(&gs, INV_SLOT_STAMINA_POT); gs.human_clicked_inv_slot = bastion_slot; - p.bastion_doses = 3; + 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, 3); + 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)); @@ -123,7 +132,7 @@ static void test_gui_reset_rebuild_restores_potions(void) { memset(&gs, 0, sizeof(gs)); memset(&p, 0, sizeof(p)); - p.bastion_doses = 4; + p.bastion_doses = 8; p.stamina_doses = 4; gui_populate_inventory(&gs, &p); @@ -133,7 +142,7 @@ static void test_gui_reset_rebuild_restores_potions(void) { 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 = 4; + p.bastion_doses = 8; p.stamina_doses = 4; gui_reset_inventory_ui_state(&gs); if (gs.inv_grid_dirty) { @@ -141,9 +150,9 @@ static void test_gui_reset_rebuild_restores_potions(void) { gs.inv_grid_dirty = 0; } - ASSERT_INT_EQ("bastion restored after rebuild", find_slot_of_type(&gs, INV_SLOT_BASTION_POT) >= 0, 1); + 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, 4); + ASSERT_INT_EQ("bastion snapshot restored", gs.inv_prev_bastion_doses, 8); ASSERT_INT_EQ("stamina snapshot restored", gs.inv_prev_stamina_doses, 4); } diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index 82593eb37c..48d18ab175 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -104,6 +104,53 @@ static int distance_to_player(const InfernoState* state, const InfNPC* npc) { state->player.x, state->player.y, npc->x, npc->y, npc->size); } +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.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; + + damage_state = healing_state; + damage_state.npcs[0].aggro_target = -1; + + ASSERT_FLOAT_NEAR("active healer reward uses tag path", + inf_compute_reward(&healing_state), 0.43f, 0.0001f); + ASSERT_FLOAT_NEAR("no active healer reward uses damage path", + inf_compute_reward(&damage_state), 0.33f, 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; + + 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_reset(raw_state, 123); + + ASSERT_INT_EQ("inferno reset gives 24 brew doses", state->player.brew_doses, 24); + ASSERT_INT_EQ("inferno reset gives 40 restore doses", state->player.restore_doses, 40); + ASSERT_INT_EQ("inferno reset gives 8 bastion doses", state->player.bastion_doses, 8); + ASSERT_INT_EQ("inferno reset gives 4 stamina doses", state->player.stamina_doses, 4); + + inf_destroy(raw_state); +} + static void test_tagged_jad_healer_melee_geometry(void) { printf("--- tagged jad healer melee geometry ---\n"); @@ -799,6 +846,8 @@ int main(void) { test_overlap_shuffle_hold_after_recent_target_click(); test_overlap_shuffle_respects_npc_occupancy(); test_meleer_dig_landing_order(); + test_reward_switches_between_healer_tags_and_damage(); + test_inferno_reset_supplies_match_current_inventory(); test_dead_mob_store_eligibility(); test_resurrected_mob_does_not_reenter_dead_store(); test_double_mager_wave_resurrection_limit(); diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index eca0035640..79c6709827 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -384,6 +384,15 @@ void my_init(Env* env, Dict* kwargs) { DictItem* start_wave = dict_get_unsafe(kwargs, "start_wave"); if (start_wave) ENCOUNTER_INFERNO.put_int(env->enc_state, "start_wave", (int)start_wave->value); + 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); /* match the 1-indexed → 0-indexed conversion done by encounter's put_int */ int sw = start_wave ? (int)start_wave->value : 0; env->config_start_wave = (sw > 0) ? sw - 1 : 0; diff --git a/pufferlib/config/ocean/osrs_inferno.ini b/pufferlib/config/ocean/osrs_inferno.ini index ffe6d1e187..72626d0c91 100644 --- a/pufferlib/config/ocean/osrs_inferno.ini +++ b/pufferlib/config/ocean/osrs_inferno.ini @@ -11,6 +11,9 @@ score_metric = episode_return [env] start_wave = 69 +damage_reward_coeff = 0.01 +shield_penalty_coeff = 0.01 +tag_reward_coeff = 0.25 mask_in_obs = 1.0 record_best_replay_path = "" play_replay_path = "" From a422c7334fb18adb336e007423f571c519e14007 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Sun, 26 Apr 2026 11:32:10 +0300 Subject: [PATCH 56/60] sync osrs human mode and inferno pathing --- build.sh | 4 +- ocean/osrs/data/npc_models_inferno.h | 26 + ocean/osrs/encounters/encounter_inferno.h | 858 +++++++++++++----- ocean/osrs/encounters/encounter_zulrah.h | 212 ++++- ocean/osrs/osrs_encounter.h | 192 ++-- ocean/osrs/osrs_gui.h | 68 +- ocean/osrs/osrs_human_input.h | 59 +- ocean/osrs/osrs_human_input_types.h | 178 ++++ ocean/osrs/osrs_render.h | 204 ++++- ocean/osrs/osrs_types.h | 1 + ocean/osrs/osrs_visual.c | 10 +- ocean/osrs/scripts/export_inferno_npcs.py | 44 + ocean/osrs/tests/test_gui_inventory.c | 36 +- ocean/osrs/tests/test_human_commands.c | 101 +++ ocean/osrs/tests/test_inferno_attack_styles.c | 612 ++++++++++++- ocean/osrs/tests/test_npc_movement.c | 59 +- .../tests/test_osrs_visual_asset_exports.c | 61 ++ ocean/osrs/tests/test_zulrah_human_commands.c | 102 +++ ocean/osrs_inferno/binding.c | 79 +- ocean/osrs_zulrah/binding.c | 33 +- tests/ocean_osrs/test_generate_monsters.py | 29 + 21 files changed, 2515 insertions(+), 453 deletions(-) create mode 100644 ocean/osrs/tests/test_human_commands.c create mode 100644 ocean/osrs/tests/test_osrs_visual_asset_exports.c create mode 100644 ocean/osrs/tests/test_zulrah_human_commands.c create mode 100644 tests/ocean_osrs/test_generate_monsters.py diff --git a/build.sh b/build.sh index 311c040c5f..e7ffa82272 100755 --- a/build.sh +++ b/build.sh @@ -126,9 +126,9 @@ elif [[ "$ENV" == osrs_* ]]; then # 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-v7/osrs-assets-v7.tar.gz" + 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 -C data + curl -sL "$OSRS_ASSETS_URL" | tar xz --strip-components=1 -C data fi elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" diff --git a/ocean/osrs/data/npc_models_inferno.h b/ocean/osrs/data/npc_models_inferno.h index d7b262f6c7..2be2eeb644 100644 --- a/ocean/osrs/data/npc_models_inferno.h +++ b/ocean/osrs/data/npc_models_inferno.h @@ -26,12 +26,20 @@ static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { #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 @@ -41,23 +49,41 @@ static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { #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 diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 007bd21d80..b0199637ea 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -22,12 +22,14 @@ #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 /* ======================================================================== */ /* arena constants */ @@ -68,6 +70,7 @@ static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { #define INF_MAX_TICKS 18000 /* 3 hours at 0.6s/tick */ #define INF_NUM_WAVES 69 +#define INF_NUM_ACTION_HEADS 9 /* ======================================================================== */ /* NPC types */ @@ -565,6 +568,7 @@ typedef struct { /* 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; @@ -608,7 +612,7 @@ typedef struct { /* action distribution: count of action-0 (noop) per head. high noop_rate = policy collapsed to doing nothing on that head. */ - int action_noop_count[8]; /* 8 = INF_NUM_ACTION_HEADS (defined later in file) */ + 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) */ @@ -623,6 +627,10 @@ typedef struct { /* 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 */ @@ -656,9 +664,9 @@ typedef struct { and on pillar collapse. */ int8_t npc_los_cache[INF_MAX_NPCS]; - /* NPC occupancy grid: tile -> NPC index+1 (0 = empty). - covers the 29x30 arena. nibblers excluded (transparent to movement). */ - uint8_t npc_occupancy[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; + /* 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 */ @@ -721,6 +729,17 @@ 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, @@ -804,12 +823,49 @@ static inline int inf_pending_hit_obs_timer(const EncounterPendingHit* ph) { 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 ) { @@ -863,6 +919,7 @@ 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); /* ======================================================================== */ /* lifecycle */ @@ -966,9 +1023,10 @@ static void inf_reset(EncounterState* state, uint32_t seed) { 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 >= 68); + 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: all destroyed at end of wave 66 (index 65), so waves 66+ have none */ for (int i = 0; i < INF_NUM_PILLARS; i++) { @@ -1007,6 +1065,84 @@ static int inf_find_free_npc(InfernoState* s) { 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]; @@ -1034,17 +1170,8 @@ static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y npc->had_los_last_tick = 0; npc->stun_timer = stats->stun_on_spawn; - /* stamp occupancy grid (nibblers excluded — transparent to movement) */ - if (type != INF_NPC_NIBBLER) { - for (int dx = 0; dx < stats->size; dx++) { - for (int dy = 0; dy < stats->size; dy++) { - int gx = x + dx - INF_ARENA_MIN_X; - int gy = y + dy - INF_ARENA_MIN_Y; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) - s->npc_occupancy[gx][gy] = (uint8_t)(idx + 1); - } - } - } + if (inf_npc_sets_collision_flag(type)) + inf_stamp_npc_collision_footprint(s, x, y, stats->size); } static void inf_spawn_wave(InfernoState* s) { @@ -1055,6 +1182,8 @@ static void inf_spawn_wave(InfernoState* s) { /* 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; @@ -1094,6 +1223,7 @@ static void inf_spawn_wave(InfernoState* s) { 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 */ @@ -1110,6 +1240,7 @@ static void inf_spawn_wave(InfernoState* s) { 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--) { @@ -1137,6 +1268,7 @@ static void inf_spawn_wave(InfernoState* s) { 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; @@ -1165,6 +1297,7 @@ static void inf_spawn_wave(InfernoState* s) { /* 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; } @@ -1219,79 +1352,45 @@ static int inf_pathfind_blocked(void* ctx, int abs_x, int abs_y) { return inf_blocked_by_pillar(s, lx, ly, 1); } -/* rebuild NPC occupancy grid from scratch. - marks each non-nibbler active NPC's footprint on the 29x30 arena grid. - value = NPC index + 1 (0 = empty). call at start of NPC tick phase. */ -static void inf_rebuild_occupancy(InfernoState* s) { - memset(s->npc_occupancy, 0, sizeof(s->npc_occupancy)); - for (int i = 0; i < INF_MAX_NPCS; i++) { - InfNPC* npc = &s->npcs[i]; - if (!npc->active) continue; - if (npc->type == INF_NPC_NIBBLER) continue; - int sz = INF_NPC_STATS[npc->type].size; - for (int dx = 0; dx < sz; dx++) { - for (int dy = 0; dy < sz; dy++) { - int gx = npc->x + dx - INF_ARENA_MIN_X; - int gy = npc->y + dy - INF_ARENA_MIN_Y; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) - s->npc_occupancy[gx][gy] = (uint8_t)(i + 1); - } - } - } -} - -/* update occupancy grid after a single NPC moves from (ox,oy) to (nx,ny). */ -static void inf_update_occupancy(InfernoState* s, int idx, int ox, int oy, int nx, int ny, int sz) { - /* clear old footprint */ - for (int dx = 0; dx < sz; dx++) { - for (int dy = 0; dy < sz; dy++) { - int gx = ox + dx - INF_ARENA_MIN_X; - int gy = oy + dy - INF_ARENA_MIN_Y; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) - s->npc_occupancy[gx][gy] = 0; - } - } - /* stamp new footprint */ - for (int dx = 0; dx < sz; dx++) { - for (int dy = 0; dy < sz; dy++) { - int gx = nx + dx - INF_ARENA_MIN_X; - int gy = ny + dy - INF_ARENA_MIN_Y; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) - s->npc_occupancy[gx][gy] = (uint8_t)(idx + 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; } -/* check if an NPC footprint at (x,y) with given size overlaps another NPC via occupancy grid */ -static int inf_occupancy_blocked(InfernoState* s, int self_idx, int x, int y, int size) { +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 = x + dx - INF_ARENA_MIN_X; - int gy = y + dy - INF_ARENA_MIN_Y; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) { - uint8_t occ = s->npc_occupancy[gx][gy]; - if (occ != 0 && (int)(occ - 1) != self_idx) - return 1; - } + 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. - checks arena bounds, pillars, collision map, and NPC-vs-NPC collision via occupancy grid. */ +/* 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; - return inf_occupancy_blocked(s, mc->self_idx, x, y, size); + 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) { @@ -1330,23 +1429,29 @@ static void inf_npc_move(InfernoState* s, int idx) { 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 ox = npc->x, oy = npc->y; 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; - inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); + 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 (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 */ @@ -1385,34 +1490,31 @@ static void inf_npc_move(InfernoState* s, int idx) { npc->target_x = tx; npc->target_y = ty; - /* ranged/magic NPCs stop moving once they can see their CURRENT target - within attack range — not just the player. the previous gate only - triggered when aggro_target was the player, so shield-aggroed - mager/ranger in the zuk wave walked right into melee. + /* 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 (stats->attack_range > 1 && npc->type != INF_NPC_NIBBLER) { + 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)) return; + stats->attack_range)) { + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + return; + } } - /* greedy step toward target using shared helper. the helper no longer - gates on range/LOS (per the commit removing early-return); that's - handled above for ranged NPCs and naturally by player-tile blocking - for melee NPCs. */ 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, + target_size, stats->attack_range == 1, inf_npc_blocked, &mc); if (npc->x != ox || npc->y != oy) { npc->moved_this_tick = 1; - if (npc->type != INF_NPC_NIBBLER) - inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); } + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); } /* ======================================================================== */ @@ -1430,6 +1532,8 @@ static void inf_meleer_dig_check(InfernoState* s, int idx) { 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 }, @@ -1448,7 +1552,8 @@ static void inf_meleer_dig_check(InfernoState* s, int idx) { } npc->x = landing_x; npc->y = landing_y; - inf_update_occupancy(s, idx, ox, oy, npc->x, npc->y, npc->size); + 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; @@ -1480,6 +1585,37 @@ static void inf_meleer_dig_check(InfernoState* s, int idx) { } } +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; +} + /* ======================================================================== */ /* NPC AI: attacks */ /* ======================================================================== */ @@ -1517,14 +1653,6 @@ static void inf_npc_attack(InfernoState* s, int idx) { /* 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->type == INF_NPC_JAD && - npc->attack_timer == 1 && - npc->jad_attack_style == ATTACK_STYLE_NONE) { - /* Jad telegraphs on the fire tick in the reference client. Our control - loop applies actions at tick start, so commit that telegraph one tick - earlier in the observation stream. */ - npc->jad_attack_style = inf_jad_roll_primary_style(&s->rng_state); - } if (npc->attack_timer > 0) return; /* shield doesn't attack */ @@ -1551,7 +1679,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { } /* shield death: redirect all NPCs targeting it to the player */ if (target->hp <= 0 && target->type == INF_NPC_ZUK_SHIELD) { - target->active = 0; + 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) @@ -1664,7 +1792,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); /* accuracy roll */ int att_roll = osrs_npc_attack_roll(stats->att_level, stats->melee_att_bonus); - const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + 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; @@ -1755,6 +1883,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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; @@ -1789,10 +1918,12 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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 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; @@ -1802,7 +1933,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { att_lvl = stats->magic_level; att_bonus = stats->magic_att_bonus; } int att_roll = osrs_npc_attack_roll(att_lvl, att_bonus); - const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + 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); @@ -1842,7 +1973,7 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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 */ + /* 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) { @@ -1850,8 +1981,10 @@ static void inf_npc_attack(InfernoState* s, int idx) { 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; + 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 && @@ -1865,20 +1998,13 @@ static void inf_npc_attack(InfernoState* s, int idx) { EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; ph->active = 1; ph->damage = dmg; - /* jad: fixed 4-tick land delay regardless of distance. ref - JalTokJad registers the projectile inside DelayedAction(T+3) with - reduceDelay=3, and Projectile clamps remainingDelay>=1, so the - effective land time is T + max(4, formula(dist)). for every - realistic fight distance formula(dist) ≤ 4, so land is always - exactly T+4. model as a flat constant — matches in-game behavior - where jads hit on a predictable tick regardless of position. */ - ph->ticks_remaining = is_jad ? 4 : hit_delay; + 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; - /* jad prayer check is deferred 3 ticks (the DelayedAction window). - other NPCs had their prayer pre-checked above (damage already zeroed - if prayer matched), so delay=0 and the deferred path no-ops. */ - ph->prayer_check_delay = is_jad ? 3 : 0; + ph->prayer_check_delay = is_jad ? INF_JAD_PROJECTILE_DELAY + 1 : 0; + ph->source_npc_type = npc->type; } } @@ -1963,6 +2089,50 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { /* NPC AI: jad healer spawning */ /* ======================================================================== */ +#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; @@ -1980,8 +2150,9 @@ static void inf_jad_check_healers(InfernoState* s, int idx) { for (int h = 0; h < num_healers; h++) { int slot = inf_find_free_npc(s); if (slot < 0) break; - int hx = npc->x + encounter_rand_int(&s->rng_state, 5) - 2; - int hy = npc->y + encounter_rand_int(&s->rng_state, 5) - 2; + 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; @@ -1993,16 +2164,10 @@ static void inf_jad_check_healers(InfernoState* s, int idx) { /* ======================================================================== */ static void inf_zuk_tick(InfernoState* s) { - if (s->wave != 68) return; + if (!inf_is_final_wave(s)) return; /* find zuk NPC */ - int zuk_idx = -1; - for (int i = 0; i < INF_MAX_NPCS; i++) { - if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { - zuk_idx = i; - break; - } - } + int zuk_idx = inf_find_live_zuk_idx(s); if (zuk_idx < 0) return; InfNPC* zuk = &s->npcs[zuk_idx]; @@ -2013,6 +2178,9 @@ static void inf_zuk_tick(InfernoState* s) { 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) { @@ -2024,6 +2192,7 @@ static void inf_zuk_tick(InfernoState* s) { s->zuk.shield_freeze = 5; s->zuk.shield_dir = -1; } + inf_stamp_npc_collision_footprint(s, shield->x, shield->y, shield->size); } } @@ -2088,7 +2257,7 @@ static void inf_zuk_tick(InfernoState* s) { /* on zuk death: all other mobs die */ if (zuk->hp <= 0) { for (int i = 0; i < INF_MAX_NPCS; i++) { - s->npcs[i].active = 0; + inf_deactivate_npc(s, i); } } } @@ -2178,7 +2347,6 @@ static void inf_tick_npcs(InfernoState* s) { /* zuk-specific phases first */ inf_zuk_tick(s); - inf_rebuild_occupancy(s); for (int i = 0; i < INF_MAX_NPCS; i++) { if (!s->npcs[i].active) continue; @@ -2193,7 +2361,7 @@ static void inf_tick_npcs(InfernoState* s) { /* death linger: decrement and deactivate when done */ if (s->npcs[i].death_ticks > 0) { s->npcs[i].death_ticks--; - if (s->npcs[i].death_ticks == 0) s->npcs[i].active = 0; + if (s->npcs[i].death_ticks == 0) inf_deactivate_npc(s, i); continue; /* dying NPCs don't move or attack */ } @@ -2229,8 +2397,6 @@ static void inf_tick_npcs(InfernoState* s) { #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) */ -#define INF_NUM_ACTION_HEADS 9 - 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 }; @@ -2244,9 +2410,10 @@ static int inf_tile_walkable(void* ctx, int x, int y) { if (!inf_in_arena(x, y)) return 0; if (inf_blocked_by_pillar(s, x, y, 1)) return 0; if (s->collision_map) - return collision_tile_walkable(s->collision_map, 0, - x + s->world_offset_x, y + s->world_offset_y); - return 1; + 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 */ @@ -2282,7 +2449,7 @@ static void inf_apply_npc_death(InfernoState* s, int npc_idx) { if (s->npcs[j].active && s->npcs[j].type == INF_NPC_HEALER_JAD && s->npcs[j].jad_owner_idx == npc_idx) { - s->npcs[j].active = 0; + inf_deactivate_npc(s, j); } } } @@ -2306,40 +2473,85 @@ static void inf_player_pretick(InfernoState* s, const int* actions) { 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; - /* gear switching */ - int gear_act = actions[INF_HEAD_GEAR]; - if (gear_act >= 1) s->total_gear_switches++; - if (gear_act >= 1 && gear_act <= 3) { - /* 1=mage, 2=tbow, 3=bp */ - InfWeaponSet new_set = (InfWeaponSet)(gear_act - 1); - s->weapon_set = new_set; - s->armor_tank = 0; - GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; - encounter_apply_loadout(&s->player, INF_LOADOUTS[new_set], gs); - } else if (gear_act == 4) { - /* reserved tank slot kept in the action space for compatibility */ - s->armor_tank = 0; - } - - /* auto-detect gear switch from direct inventory equip (human mode). - gui_inv_click mutates p->equipped directly, bypassing the action head. - detect weapon mismatch and sync weapon_set + full loadout. */ - { - uint8_t current_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; - if (current_weapon != INF_LOADOUTS[s->weapon_set][GEAR_SLOT_WEAPON]) { - for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { - if (INF_LOADOUTS[g][GEAR_SLOT_WEAPON] == current_weapon) { - s->weapon_set = (InfWeaponSet)g; - GearSet gs = (g == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; - encounter_apply_loadout(&s->player, INF_LOADOUTS[g], gs); - break; + 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; + } } } } @@ -2371,6 +2583,8 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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 */ @@ -2390,6 +2604,8 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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 */ @@ -2403,11 +2619,15 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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--; @@ -2470,7 +2690,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { } 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 = &s->loadout_stats[s->weapon_set]; + 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, @@ -2479,13 +2699,14 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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 = &s->loadout_stats[s->weapon_set]; + 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, @@ -2495,7 +2716,10 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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 mage_blocked = (s->weapon_set == INF_GEAR_MAGE) && + 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)); @@ -2506,14 +2730,14 @@ static void inf_tick_player(InfernoState* s, const int* actions) { int hit_delay; if (ls->style == ATTACK_STYLE_MAGIC) hit_delay = encounter_magic_hit_delay(target_dist, 1); - else if (s->weapon_set == INF_GEAR_BP) + 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 (s->weapon_set == INF_GEAR_MAGE) { + 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). */ @@ -2602,7 +2826,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { ph->spell_type = s->spell_choice; } - } else if (s->weapon_set == INF_GEAR_TBOW) { + } 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, @@ -2631,7 +2855,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { ph->check_prayer = 0; ph->spell_type = 0; - } else if (s->player.spec_armed && + } 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); @@ -2679,7 +2903,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { /* player attack animation + spell type for renderer effect system */ s->player.attack_style_this_tick = ls->style; - if (s->weapon_set == INF_GEAR_MAGE) { + 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; } @@ -2688,6 +2912,108 @@ static void inf_tick_player(InfernoState* s, const int* actions) { } } +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); + } +} + /* ======================================================================== */ /* reward */ /* ======================================================================== */ @@ -2716,9 +3042,22 @@ static float inf_compute_reward(InfernoState* s) { } } - float reward = healer_is_actively_healing - ? s->tag_reward_coeff * (float)s->healer_tags_this_tick - : s->damage_reward_coeff * fmaxf(0.0f, s->damage_dealt_this_tick - s->hp_restored_this_tick); + 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; } @@ -2779,7 +3118,7 @@ static void inf_step(EncounterState* state, const int* actions) { then the player's movement/attack phase. */ /* ------------------------------------------------------------------ */ if (!in_wave_gap) { - inf_rebuild_occupancy(s); + inf_rebuild_player_collision_flags(s); inf_invalidate_los_cache(s); inf_tick_npcs(s); } @@ -2824,10 +3163,7 @@ static void inf_step(EncounterState* state, const int* actions) { } } - encounter_resolve_player_pending_hits( - s->player_pending_hits, &s->player_pending_hit_count, - &s->player, s->player.prayer, - &s->damage_received_this_tick, &s->prayer_correct_this_tick, &s->off_prayer_hits_this_tick); + 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 @@ -2848,6 +3184,7 @@ static void inf_step(EncounterState* state, const int* actions) { /* 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 */ { @@ -2931,7 +3268,6 @@ static void inf_step(EncounterState* state, const int* actions) { if (spawn_wave_now) { s->wave = s->wave_spawn_target; inf_spawn_wave(s); - inf_rebuild_occupancy(s); inf_invalidate_los_cache(s); return; } @@ -2997,6 +3333,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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); /* player state (26 features) */ obs[i++] = (float)s->player.current_hitpoints / 99.0f; @@ -3033,14 +3370,14 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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)s->loadout_stats[s->weapon_set].attack_range / 15.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)s->loadout_stats[s->weapon_set].max_hit / 80.0f; - obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_speed / 6.0f; - obs[i++] = (float)s->loadout_stats[s->weapon_set].def_stab / 300.0f; - obs[i++] = (float)s->loadout_stats[s->weapon_set].def_magic / 300.0f; - obs[i++] = (float)s->loadout_stats[s->weapon_set].def_ranged / 300.0f; + obs[i++] = (float)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 */ @@ -3073,7 +3410,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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 || + if (npc->type == INF_NPC_ZUK || npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_NIBBLER || npc->type == INF_NPC_HEALER_ZUK) continue; @@ -3275,25 +3612,18 @@ static void inf_write_obs(EncounterState* state, float* obs) { } } - /* barrage AoE count: unique NPCs in 3x3 area via occupancy grid */ + /* barrage AoE count: unique blocking NPCs in the 3x3 area */ { int aoe_count = 0; - uint32_t seen = 0; - int cx = npc->x - INF_ARENA_MIN_X; - int cy = npc->y - INF_ARENA_MIN_Y; - for (int dx = -1; dx <= 1; dx++) { - for (int dy = -1; dy <= 1; dy++) { - int gx = cx + dx, gy = cy + dy; - if (gx >= 0 && gx < INF_ARENA_WIDTH && gy >= 0 && gy < INF_ARENA_HEIGHT) { - uint8_t occ = s->npc_occupancy[gx][gy]; - if (occ != 0) { - int oidx = (int)(occ - 1); - if (oidx != n && !(seen & (1u << oidx))) { - seen |= (1u << oidx); - aoe_count++; - } - } - } + 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; @@ -3490,7 +3820,7 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i int n = 0; { - const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + 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; @@ -3537,8 +3867,8 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i } 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 && nm && nm->attack_anim != 65535) { - re->npc_anim_id = (int)nm->attack_anim; + } 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) @@ -3594,6 +3924,7 @@ static void inf_put_int(EncounterState* state, const char* key, int 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) { @@ -3637,6 +3968,9 @@ static void* inf_get_log(EncounterState* state) { 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; } @@ -3749,13 +4083,10 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { case INF_NPC_JAD: if (actual_style == ATTACK_STYLE_MAGIC) { arc = 1.0f; /* arcing magic projectile */ + } else { + start_h = end_h; } - /* InfernoTrainer Jad projectiles use visualDelayTicks=3 and - visualHitEarlyTicks=-1, so the visible segment lasts - hit_delay - 3 + 1 ticks. With Jad's fixed 4-tick land delay - that means a 2-tick visible flight, not 1. */ - duration = (hit_delay - 2) * 30; - if (duration < 30) duration = 30; + duration = inf_jad_visible_duration_ticks(hit_delay); break; case INF_NPC_HEALER_ZUK: arc = 3.0f; /* high arcing spark */ @@ -3769,6 +4100,30 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { 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, @@ -3782,7 +4137,16 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { /* Jad: 3-tick visual delay (InfernoTrainer JAD_PROJECTILE_DELAY=3) */ if (pi >= 0 && npc->type == INF_NPC_JAD) - ov->projectiles[pi].start_delay = 3 * 30; + 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) @@ -3824,13 +4188,14 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { 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->weapon_set == INF_GEAR_MAGE) { + 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 (s->weapon_set == INF_GEAR_TBOW) { + } 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; @@ -3901,6 +4266,76 @@ static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterSta 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); +} + /* ======================================================================== */ /* encounter definition */ /* ======================================================================== */ @@ -3916,6 +4351,7 @@ static const EncounterDef ENCOUNTER_INFERNO = { .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, diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index ce7ba33148..cfc804bfa7 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -38,6 +38,7 @@ #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" @@ -522,6 +523,10 @@ typedef struct { /* 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). */ @@ -580,6 +585,7 @@ typedef struct { } 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); /* ======================================================================== */ /* helpers */ @@ -687,8 +693,8 @@ static void zul_try_envenom(ZulrahState* s) { 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 = (s->player_gear == ZUL_GEAR_MAGE) - ? &s->mage_stats : &s->range_stats; + 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, @@ -839,6 +845,33 @@ static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* de *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 ) { @@ -870,9 +903,12 @@ static void zul_player_attack(ZulrahState* s, int is_mage) { if (s->player.attack_timer > 0) return; if (s->player_stunned_ticks > 0) return; - int gear_ok = (is_mage && s->player_gear == ZUL_GEAR_MAGE) || - (!is_mage && s->player_gear == ZUL_GEAR_RANGE); - const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; + 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( @@ -939,13 +975,13 @@ static void zul_player_spec(ZulrahState* s) { if (s->player.attack_timer > 0) return; if (s->player_stunned_ticks > 0) return; - /* determine weapon and stats from current gear */ int is_mage = (s->player_gear == ZUL_GEAR_MAGE); - const EncounterLoadoutStats* ls = is_mage ? &s->mage_stats : &s->range_stats; - const uint8_t* loadout = is_mage - ? ZUL_MAGE_LOADOUT[s->gear_tier] - : ZUL_RANGE_LOADOUT[s->gear_tier]; - int weapon = loadout[GEAR_SLOT_WEAPON]; + 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) */ @@ -1521,6 +1557,8 @@ static void zul_process_prayer(ZulrahState* s, int overhead_action, int offensiv 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); } } @@ -1580,6 +1618,47 @@ static void zul_process_gear(ZulrahState* s, int atk) { } } +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); +} + /* ======================================================================== */ /* observations */ @@ -1901,6 +1980,9 @@ static void zul_step(EncounterState* state, const int* actions) { /* 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); @@ -1916,11 +1998,13 @@ static void zul_step(EncounterState* state, const int* actions) { /* gear switch from attack action — interrupts if actually switching */ int atk_action = actions[ZUL_HEAD_ATTACK]; - 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); + 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); } - 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) { @@ -2008,6 +2092,8 @@ static void zul_step(EncounterState* state, const int* actions) { 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) { @@ -2189,8 +2275,15 @@ static void zul_put_int(EncounterState* state, const char* key, int value) { else if (strcmp(key, "gear_tier") == 0) { if (value >= 0 && value < ZUL_NUM_GEAR_TIERS) s->gear_tier = value; } - else if (strcmp(key, "player_dest_x") == 0) { s->player_dest_x = value; s->player_dest_explicit = 1; } - else if (strcmp(key, "player_dest_y") == 0) { s->player_dest_y = value; s->player_dest_explicit = 1; } + 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) { @@ -2262,7 +2355,7 @@ static void zul_render_post_tick(EncounterState* state, EncounterOverlay* 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); + 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) @@ -2271,7 +2364,7 @@ static void zul_render_post_tick(EncounterState* state, EncounterOverlay* 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); + 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++) { @@ -2282,7 +2375,7 @@ static void zul_render_post_tick(EncounterState* state, EncounterOverlay* ov) { /* 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); + GFX_CLOUD_PROJ_MODEL, 0); } } static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } @@ -2321,6 +2414,84 @@ static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterSta (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); +} + /* ======================================================================== */ /* encounter definition */ /* ======================================================================== */ @@ -2335,6 +2506,7 @@ static const EncounterDef ENCOUNTER_ZULRAH = { .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, diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index 8b779186e6..eea74ec5b4 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -28,7 +28,7 @@ * * NPC pathfinding: * encounter_npc_step_out_from_under() shuffle NPC off player tile (OSRS overlap rule) - * encounter_npc_step_toward() greedy size-aware chase step (diagonal > x > y) + * encounter_npc_step_toward() OSRS size-aware chase step * * damage: * encounter_damage_player() apply damage to player (HP, clamp, splat, tracker) @@ -91,6 +91,7 @@ typedef struct { 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. @@ -102,6 +103,11 @@ typedef struct { 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]; @@ -133,9 +139,12 @@ typedef struct { 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; @@ -181,14 +190,43 @@ static inline int encounter_emit_projectile( 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; +} + /* ======================================================================== */ /* render entity: shared abstraction for renderer (value type, not pointer) */ /* ======================================================================== */ @@ -788,83 +826,87 @@ static inline int encounter_npc_y_edge_clear( return 1; } -/** greedy NPC step toward target. tries diagonal first, then x-only, then y-only. - this is the current generic NPC chase policy used by the ocean envs. +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. - corner safespot: if diagonal would land NPC on player, cancel Y component. - ref: InfernoTrainer Mob.ts:143-146. - - this function does NOT gate on attack range or LOS — the reference's - canMove() (Unit.ts:383) is `!hasLOS && !frozen && !stunned && !dying`, - with NO range check. caller is responsible for skipping the call when - the NPC shouldn't move (hasLOS, frozen, etc). for melee mobs adjacent - to the player, the step naturally fails because the player tile is - occupied — no explicit range gate needed. - - attack_range param is retained for signature compatibility but unused. + 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 attack_range, + int target_size, int stop_at_melee_distance, encounter_npc_blocked_fn is_blocked, void* ctx ) { - (void)attack_range; int size = npc_size; - int dx = 0, dy = 0; - if (tx > *x) dx = 1; - else if (tx < *x) dx = -1; - if (ty > *y) dy = 1; - else if (ty < *y) dy = -1; - if (dx == 0 && dy == 0) return 0; + 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); - /* corner safespot cancellation: if a diagonal step would overlap the target, - cancel the Y component and take X-only. */ - if (dx != 0 && dy != 0) { - int nx = *x + dx, ny = *y + dy; - if (encounter_entity_footprints_overlap(nx, ny, size, tx, ty, target_size)) { - dy = 0; - } - } + 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; - /* size-1 NPCs: simple destination check (edge tiles = destination tile) */ - if (size <= 1) { - if (dx != 0 && dy != 0 && !is_blocked(ctx, *x + dx, *y + dy, 1)) { - *x += dx; *y += dy; return 1; - } - if (dx != 0 && !is_blocked(ctx, *x + dx, *y, 1)) { - *x += dx; return 1; - } - if (dy != 0 && !is_blocked(ctx, *x, *y + dy, 1)) { - *y += dy; return 1; - } - return 0; + if (stop_at_melee_distance && x_gap == 1 && y_gap == 1) { + return encounter_npc_try_step(x, y, size, dx, 0, is_blocked, ctx); } - /* size>1 NPCs: edge-tile validation per InfernoTrainer. - diagonal: both x-edge AND y-edge must be clear (each extended by 1 for corner). - cardinal: just the leading edge (size tiles). */ - if (dx != 0 && dy != 0) { - int x_clear = encounter_npc_x_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); - int y_clear = encounter_npc_y_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); - if (x_clear && y_clear) { - *x += dx; *y += dy; return 1; - } - /* diagonal failed — fall through to try cardinal with dy=0 edge strips */ - } - /* x-only: check leading x-edge (size tiles, no diagonal extension) */ - if (dx != 0 && encounter_npc_x_edge_clear(*x, *y, size, dx, 0, is_blocked, ctx)) { - *x += dx; return 1; - } - /* y-only: check leading y-edge (size tiles, no diagonal extension) */ - if (dy != 0 && encounter_npc_y_edge_clear(*x, *y, size, 0, dy, is_blocked, ctx)) { - *y += dy; return 1; - } + 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; } @@ -1320,6 +1362,33 @@ static inline void encounter_update_loadout_level( } } +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) */ /* */ @@ -1552,6 +1621,7 @@ typedef struct { /* 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); diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 21b0fc5df7..003ce79531 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -1341,44 +1341,88 @@ static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, switch (inv->type) { case INV_SLOT_EQUIPMENT: { - /* equipment clicks always directly equip (more faithful than RL loadout presets) */ int gear_slot = item_to_gear_slot(inv->item_db_idx); if (gear_slot >= 0) { - slot_equip_item(p, gear_slot, inv->item_db_idx); + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; gs->human_clicked_inv_slot = slot; } + 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; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index a9c7d5d64a..9ec3518e1d 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -20,47 +20,6 @@ /* forward declare — full struct lives in osrs_pvp_render.h */ struct RenderClient; -/* ======================================================================== */ -/* init / reset */ -/* ======================================================================== */ - -static void human_input_init(HumanInput* hi) { - memset(hi, 0, sizeof(*hi)); - hi->pending_move_x = -1; - hi->pending_move_y = -1; - hi->pending_prayer = -1; - hi->pending_offensive_prayer = -1; - hi->pending_target_idx = -1; - hi->click_cross_active = 0; -} - -/** Clear pending actions after they've been consumed at tick boundary. - Movement is NOT cleared here — it persists until the player reaches the - destination or a new click overrides it. Use human_input_clear_move() - for that. */ -static void human_input_clear_pending(HumanInput* hi) { - /* pending_move_x/y intentionally NOT cleared — movement is persistent */ - hi->pending_attack = 0; - hi->pending_prayer = -1; - hi->pending_offensive_prayer = -1; - hi->pending_food = 0; - hi->pending_karambwan = 0; - hi->pending_potion = 0; - hi->pending_veng = 0; - hi->pending_spec = 0; - hi->pending_spell = 0; - hi->pending_target_idx = -1; - hi->pending_gear = 0; - /* don't clear cursor_mode or selected_spell — those persist until cancelled */ - /* don't clear click_tile — visual feedback fades on its own */ -} - -/** Clear persistent movement destination. Call when player reaches target tile. */ -static void human_input_clear_move(HumanInput* hi) { - hi->pending_move_x = -1; - hi->pending_move_y = -1; -} - /* ======================================================================== */ /* screen-to-world conversion */ /* ======================================================================== */ @@ -132,7 +91,10 @@ static void human_process_tile_click(HumanInput* hi, 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; @@ -147,6 +109,7 @@ static void human_process_tile_click(HumanInput* hi, 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); } @@ -212,27 +175,35 @@ static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, 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 */ @@ -307,7 +278,10 @@ static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, int by = oy + row * (btn_h + btn_gap); if (mouse_x >= bx && mouse_x < bx + btn_w && mouse_y >= by && mouse_y < by + btn_h) { - p->fight_style = (FightStyle)i; + if (hi->enabled) + human_input_queue_fight_style(hi, i); + else + p->fight_style = (FightStyle)i; return; } } @@ -322,6 +296,7 @@ static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, 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); } } diff --git a/ocean/osrs/osrs_human_input_types.h b/ocean/osrs/osrs_human_input_types.h index 627093b703..d1a8845741 100644 --- a/ocean/osrs/osrs_human_input_types.h +++ b/ocean/osrs/osrs_human_input_types.h @@ -8,14 +8,55 @@ #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 */ @@ -41,4 +82,141 @@ typedef struct HumanInput { 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_render.h b/ocean/osrs/osrs_render.h index c91f0b3a3b..e4057ced60 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -112,7 +112,13 @@ typedef struct { 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; @@ -712,6 +718,7 @@ static void context_menu_execute(RenderClient* rc, int item_idx) { 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; @@ -724,7 +731,15 @@ static void context_menu_execute(RenderClient* rc, int item_idx) { 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); } @@ -957,6 +972,32 @@ static Model* render_get_proj_model(RenderClient* rc, uint32_t model_id) { 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. @@ -1005,13 +1046,39 @@ static void render_init_overlay_models(RenderClient* rc) { * - 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 impact_gfx_id, - int start_delay) { + 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; } @@ -1037,8 +1104,16 @@ static void flight_spawn(RenderClient* rc, 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 */ @@ -1058,6 +1133,23 @@ static void flight_spawn(RenderClient* rc, 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 @@ -1070,6 +1162,21 @@ static inline Matrix render_projectile_transform( 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; @@ -1111,6 +1218,18 @@ static void flight_client_tick(RenderClient* rc) { 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; @@ -1130,16 +1249,10 @@ static void flight_client_tick(RenderClient* rc) { fp->pitch = atan2f(h_vel, horiz_speed); } + flight_advance_animation(rc, fp); fp->progress += fp->speed; if (fp->progress >= 1.0f) { - 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); - } - fp->active = 0; + flight_finish(rc, fp); } } } @@ -1148,6 +1261,11 @@ static void flight_client_tick(RenderClient* rc) { * 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; @@ -1167,6 +1285,7 @@ static Vector3 flight_get_position(const FlightProjectile* fp, float src_ground, } static void render_destroy_client(RenderClient* rc) { + flight_clear_all(rc); /* free GUI panel sprites */ gui_unload_sprites(&rc->gui); /* free prayer icon textures */ @@ -1228,6 +1347,7 @@ static void render_destroy_client(RenderClient* rc) { objects_free(rc->npcs); rc->npcs = NULL; } + human_input_destroy(&rc->human_input); CloseWindow(); free(rc->history); free(rc); @@ -1361,6 +1481,8 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { 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); } @@ -1453,7 +1575,15 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { 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; @@ -1510,6 +1640,7 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { } 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) */ @@ -2007,8 +2138,14 @@ static void render_post_tick(RenderClient* rc, OsrsEnv* env) { 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].impact_gfx_id, - ov->projectiles[i].start_delay); + 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 */ @@ -2422,10 +2559,12 @@ static void render_draw_grid(RenderClient* rc, OsrsEnv* env) { /* in-flight projectiles (interpolated at 50 Hz) */ for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { FlightProjectile* fp = &rc->flights[i]; - if (!fp->active) continue; + if (!fp->active || fp->start_delay > 0) continue; float t = fp->progress; - float cur_x = fp->src_x + (fp->dst_x - fp->src_x) * t; - float cur_y = fp->src_y + (fp->dst_y - fp->src_y) * t; + 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; @@ -2437,7 +2576,8 @@ static void render_draw_grid(RenderClient* rc, OsrsEnv* env) { case 2: pc = CLITERAL(Color){ 255, 80, 80, 255 }; break; default: pc = WHITE; break; } - DrawLine(psx, psy, pcx, pcy, pc); + if (fp->motion_mode != ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED) + DrawLine(psx, psy, pcx, pcy, pc); DrawCircle(pcx, pcy, 4.0f, pc); } } @@ -3719,7 +3859,29 @@ static void render_draw_3d_world(RenderClient* rc) { Vector3 pos = flight_get_position(fp, src_ground, dst_ground); Model* proj_model = NULL; - if (fp->model_id > 0) { + 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) { @@ -3737,14 +3899,16 @@ static void render_draw_3d_world(RenderClient* rc) { if (proj_model) { rlDisableBackfaceCulling(); float pms = 1.0f / 128.0f; - proj_model->transform = render_projectile_transform( - pms, pms, pms, fp->yaw, fp->pitch, pos); + 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) { + 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; diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index f92fcdc166..8790642c6d 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -862,6 +862,7 @@ typedef struct { /* 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 */ /* action noop rates per head (0=move,1=prayer,2=target,3=gear,4=eat,5=pot,6=spell,7=spec) */ diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index 8f38e1fb3b..f73e287b2f 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -268,6 +268,7 @@ static void visual_frame(void* arg) { 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( @@ -311,8 +312,12 @@ static void visual_frame(void* arg) { /* 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) { + 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, @@ -339,7 +344,8 @@ static void visual_frame(void* arg) { enc_actions[h] = rand() % edef->action_head_dims[h]; } } - edef->step(env->encounter_state, enc_actions); + 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); diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py index 339d578bd6..609960c413 100644 --- a/ocean/osrs/scripts/export_inferno_npcs.py +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -97,10 +97,52 @@ } 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 @@ -523,6 +565,8 @@ def main() -> None: 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) # ================================================================ # step 2: read SpotAnim/GFX definitions diff --git a/ocean/osrs/tests/test_gui_inventory.c b/ocean/osrs/tests/test_gui_inventory.c index 38d39ad6a9..9412103718 100644 --- a/ocean/osrs/tests/test_gui_inventory.c +++ b/ocean/osrs/tests/test_gui_inventory.c @@ -3,8 +3,8 @@ * @brief Regression tests for GUI inventory snapshot/reset logic used by inferno human mode. * * BUILD: - * cc -std=c11 -O0 -g -I. -I./ocean/osrs/raylib-5.5_macos/include -o /tmp/test_gui_inventory \ - * ocean/osrs/tests/test_gui_inventory.c ./ocean/osrs/raylib-5.5_macos/lib/libraylib.a \ + * cc -std=c11 -O0 -g -I. -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 */ @@ -14,6 +14,7 @@ #include "ocean/osrs/osrs_pvp_actions.h" #include "ocean/osrs/osrs_gui.h" +#include "ocean/osrs/osrs_human_input.h" static int tests_run = 0; static int tests_passed = 0; @@ -156,11 +157,42 @@ static void test_gui_reset_rebuild_restores_potions(void) { ASSERT_INT_EQ("stamina snapshot restored", gs.inv_prev_stamina_doses, 4); } +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_human_equipment_click_queues_without_mutating_player(); printf("\n%d/%d tests passed", tests_passed, tests_run); if (tests_failed > 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 index 48d18ab175..f5d34bccd1 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -87,6 +87,54 @@ static HumanInput make_human_input(void) { 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; @@ -113,6 +161,7 @@ static void test_reward_switches_between_healer_tags_and_damage(void) { 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; @@ -122,16 +171,72 @@ static void test_reward_switches_between_healer_tags_and_damage(void) { 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("active healer reward uses tag path", + ASSERT_FLOAT_NEAR("final-wave active healer reward uses tag path", inf_compute_reward(&healing_state), 0.43f, 0.0001f); - ASSERT_FLOAT_NEAR("no active healer reward uses damage path", + 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"); @@ -204,8 +309,8 @@ static void test_overlap_shuffle_hold_after_recent_target_click(void) { ASSERT_INT_EQ("held overlap does not mark moved", state.npcs[0].moved_this_tick, 0); } -static void test_overlap_shuffle_respects_npc_occupancy(void) { - printf("--- overlap shuffle respects npc occupancy ---\n"); +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; @@ -219,13 +324,151 @@ static void test_overlap_shuffle_respects_npc_occupancy(void) { state.npcs[3] = make_test_npc(INF_NPC_HEALER_JAD, 20, 21, 1); state.npcs[3].active = 1; - inf_rebuild_occupancy(&state); + 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"); @@ -510,8 +753,8 @@ static void test_pending_hit_obs_timer_prefers_prayer_window(void) { ASSERT_INT_EQ("normal timer uses travel time", inf_pending_hit_obs_timer(&normal_hit), 2); } -static void test_jad_preview_and_obs_timing(void) { - printf("--- jad preview and obs timing ---\n"); +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; @@ -527,37 +770,175 @@ static void test_jad_preview_and_obs_timing(void) { inf_npc_attack(&state, 0); - ASSERT_INT_EQ("jad preview decrements to one", state.npcs[0].attack_timer, 1); - ASSERT_INT_EQ( - "jad preview style committed", - state.npcs[0].jad_attack_style == ATTACK_STYLE_RANGED || - state.npcs[0].jad_attack_style == ATTACK_STYLE_MAGIC, - 1); + 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 exposes next-tick jad telegraph", obs[37], 0.1f, 1e-6f); - ASSERT_INT_EQ( - "prayer-critical style is one-hot for jad preview", - (int)(obs[38] + obs[39] + obs[40]), - 1); + 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); +} - state.npcs[0].attack_timer = 1; - state.npcs[0].jad_attack_style = ATTACK_STYLE_MAGIC; - state.player_pending_hit_count = 0; - state.npcs[0].attacked_this_tick = 0; +static void test_jad_fire_tick_exposes_three_tick_prayer_deadline(void) { + printf("--- jad fire tick exposes three tick prayer deadline ---\n"); - inf_npc_attack(&state, 0); + 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 preview resets after firing", state.npcs[0].jad_attack_style, ATTACK_STYLE_NONE); - ASSERT_INT_EQ("jad pending hit keeps prayer delay", state.player_pending_hits[0].prayer_check_delay, 3); - ASSERT_INT_EQ("jad pending hit keeps land delay", state.player_pending_hits[0].ticks_remaining, 4); + 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 not impact delay", obs[pending_start + 3], 0.3f, 1e-6f); + 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) { @@ -836,6 +1217,161 @@ static void test_human_target_and_potion_translation(void) { } } +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(); @@ -844,18 +1380,36 @@ int main(void) { test_style_choice_sampling(); test_tagged_jad_healer_melee_geometry(); test_overlap_shuffle_hold_after_recent_target_click(); - test_overlap_shuffle_respects_npc_occupancy(); + 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_inferno_reset_supplies_match_current_inventory(); 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_preview_and_obs_timing(); + 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) { diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c index 69bba9201f..6e7cb6bfd6 100644 --- a/ocean/osrs/tests/test_npc_movement.c +++ b/ocean/osrs/tests/test_npc_movement.c @@ -1,12 +1,9 @@ /** * @file test_npc_movement.c - * @brief Tests for encounter_npc_step_toward (shared greedy NPC chase step). + * @brief Tests for encounter_npc_step_toward (shared OSRS NPC chase step). * - * Regression coverage for: ranged NPCs that are in attack range but without LOS - * (e.g., pillar between them and the player) must keep walking toward the - * player. Reference: InfernoTrainer Unit.ts:383 `canMove = !hasLOS`. The - * helper itself does not gate on range or LOS — those decisions belong to - * the caller. This test locks in the no-range-stop behavior. + * 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 @@ -55,7 +52,7 @@ static void test_in_range_still_steps(void) { /* 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, 10, blocked_never, NULL); + 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); @@ -70,7 +67,7 @@ static void test_pillar_stuck(void) { 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, 10, blocked_rect, &pillar); + 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); @@ -83,34 +80,46 @@ static void test_pillar_diagonal_path(void) { 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, 10, blocked_rect, &pillar); + 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_adjacent_natural_stop(void) { - printf("--- melee adjacent: helper tries to step, blocked by player tile ---\n"); - /* NPC at (4,5), target at (5,5), target_size=1 (player). - is_blocked returns 1 for the player tile so NPC can't land there. */ - BlockRect player = { 5, 5, 1 }; +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_rect, &player); - /* greedy: diagonal (5,6)? target (5,5), step (5,6) — not overlap → clear. - but we want the scenario where all forward moves land on player. - retry: player tile (5,5) blocks destination (5,5). diagonal (5,4) or (5,6) free. moves there. - this is OSRS "wiggle around the player" behavior — expected. */ - ASSERT("adjacent melee moves around player tile", moved == 0 || moved == 1); - /* more strict: NPC didn't land ON the player tile */ - ASSERT("NPC not on player tile", !(x == 5 && 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, 1, blocked_never, NULL); + 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); @@ -153,7 +162,9 @@ int main(void) { test_in_range_still_steps(); test_pillar_stuck(); test_pillar_diagonal_path(); - test_melee_adjacent_natural_stop(); + 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(); 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_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_inferno/binding.c b/ocean/osrs_inferno/binding.c index 79c6709827..de029f6bdb 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -72,26 +72,51 @@ typedef struct { /* global best episode tracking */ static int g_best_wave = 0; static int g_best_ticks = 999999; -static int g_best_zuk_hp = 999999; /* lowest Zuk HP seen (for Zuk-only training) */ +static int g_best_min_zuk_hp = 999999; /* lowest Zuk HP reached (for Zuk-only training) */ void c_step(Env* env) { /* tick pacing lives in c_render — it blocks at the tick deadline calling pvp_render at ~60fps so sub-tile interpolation can animate between sim ticks. nothing to do here timing-wise. */ + 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) { + 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; @@ -102,7 +127,8 @@ void c_step(Env* env) { } } - ENCOUNTER_INFERNO.step(env->enc_state, env->acts_staging); + 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); @@ -171,6 +197,9 @@ void c_step(Env* env) { 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; @@ -203,19 +232,14 @@ void c_step(Env* env) { /* 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 = most damage to zuk (lowest HP remaining). - if zuk is dead (winner==0), fastest kill (fewest ticks) wins. */ - int zuk_hp = 999999; - for (int n = 0; n < INF_MAX_NPCS; n++) { - if (st->npcs[n].active && st->npcs[n].type == INF_NPC_ZUK) { - zuk_hp = st->npcs[n].hp; - break; - } - } - if (st->winner == 0) zuk_hp = 0; /* zuk dead */ - is_new_best = (zuk_hp < g_best_zuk_hp || - (zuk_hp == g_best_zuk_hp && zuk_hp == 0 && ticks < g_best_ticks)); - if (is_new_best) g_best_zuk_hp = zuk_hp; + /* 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; @@ -233,8 +257,8 @@ void c_step(Env* env) { env->episode_action_len * NUM_ATNS, fp); fclose(fp); if (st->start_wave >= 68) { - fprintf(stderr, "replay: new best zuk hp=%d (%d ticks, rng=%u) saved to %s\n", - g_best_zuk_hp, env->episode_action_len, env->episode_rng_start, rpath); + 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); @@ -278,6 +302,10 @@ void c_close(Env* env) { 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) { @@ -465,7 +493,7 @@ void my_init(Env* env, Dict* kwargs) { } /* curriculum wave mixing: start some agents at later waves for late-game gradient signal. - wave-0 agents are scored normally; curriculum agents train but don't affect sweep metric. */ + 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, @@ -473,6 +501,8 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun 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 = base_start_wave_item ? (int)base_start_wave_item->value : 0; /* parse curriculum tiers from env config */ static const char* wave_keys[] = { @@ -516,15 +546,15 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun if (tier_counts[t] < 1) tier_counts[t] = 1; curriculum_total += tier_counts[t]; } - int wave0_count = num_envs - curriculum_total; - int cursor = wave0_count; + 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-0", wave0_count); + 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); @@ -582,6 +612,7 @@ void my_log(Log* log, Dict* out) { 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); @@ -593,8 +624,8 @@ void my_log(Log* log, Dict* out) { float score; int start_wave = (int)(log->start_wave + 0.5f); if (start_wave >= 68) { - /* Zuk-only: score = fraction of Zuk HP removed (0..1), wins = 1.0 */ - score = (1200.0f - log->zuk_hp_remaining) / 1200.0f; + /* 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; diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 1aaf59893d..28f49fd9d0 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -53,12 +53,33 @@ typedef struct { /* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h */ void c_step(Env* env) { - /* float actions -> int staging */ - for (int i = 0; i < NUM_ATNS; i++) { - env->acts_staging[i] = (int)env->actions[i]; + 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]; + } } - ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); + if (!used_human_commands) + ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); /* write obs + mask directly (mask appended after raw obs) */ float* obs = (float*)env->observations; @@ -115,6 +136,10 @@ void c_close(Env* env) { 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) { 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 From cc56028a7cb2fb3802c11a2324a5782d32829f81 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 28 Apr 2026 00:17:49 +0300 Subject: [PATCH 57/60] scale inferno supplies for late starts --- ocean/osrs/encounters/encounter_inferno.h | 144 +++++++++++++++- ocean/osrs/tests/test_gui_inventory.c | 36 +++- ocean/osrs/tests/test_inferno_attack_styles.c | 157 +++++++++++++++++- ocean/osrs_inferno/binding.c | 7 + pufferlib/config/ocean/osrs_inferno.ini | 1 + 5 files changed, 328 insertions(+), 17 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index b0199637ea..9e1aab9c62 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -541,6 +541,25 @@ static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { /* encounter state */ /* ======================================================================== */ +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; @@ -674,6 +693,7 @@ typedef struct { float damage_reward_coeff; float shield_penalty_coeff; float tag_reward_coeff; + float late_start_supply_profile_scale; Log log; } InfernoState; @@ -928,6 +948,7 @@ 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; } @@ -935,6 +956,103 @@ 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 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; @@ -947,6 +1065,7 @@ static void inf_reset(EncounterState* state, uint32_t seed) { 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; @@ -957,6 +1076,7 @@ static void inf_reset(EncounterState* state, uint32_t 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; @@ -997,10 +1117,13 @@ static void inf_reset(EncounterState* state, uint32_t seed) { } s->player.num_items_in_slot[GEAR_SLOT_AMMO] = 0; } - s->player.brew_doses = 24; /* 6 pots x 4 doses */ - s->player.restore_doses = 40; /* 10 pots x 4 doses */ - s->player.bastion_doses = 8; /* 2 pots x 4 doses */ - s->player.stamina_doses = 4; /* 1 pot x 4 doses */ + 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); @@ -3334,6 +3457,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3348,8 +3472,8 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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 / 24.0f; - obs[i++] = (float)s->player.restore_doses / 40.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; /* tick normalization: Zuk-only (~300 ticks) vs full runs (~18000 ticks) */ @@ -3360,8 +3484,8 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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 / 8.0f; - obs[i++] = (float)s->player.stamina_doses / 4.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; @@ -3932,6 +4056,10 @@ static void inf_put_float(EncounterState* state, const char* key, float value) { 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"); } diff --git a/ocean/osrs/tests/test_gui_inventory.c b/ocean/osrs/tests/test_gui_inventory.c index 9412103718..b22ed99ad7 100644 --- a/ocean/osrs/tests/test_gui_inventory.c +++ b/ocean/osrs/tests/test_gui_inventory.c @@ -3,7 +3,8 @@ * @brief Regression tests for GUI inventory snapshot/reset logic used by inferno human mode. * * BUILD: - * cc -std=c11 -O0 -g -I. -I./raylib-5.5_macos/include -o /tmp/test_gui_inventory \ + * 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 @@ -13,6 +14,7 @@ #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" @@ -45,6 +47,10 @@ static int count_slots_of_type(const GuiState* gs, InvSlotType type) { 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"); @@ -157,6 +163,33 @@ static void test_gui_reset_rebuild_restores_potions(void) { 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"); @@ -192,6 +225,7 @@ int main(void) { 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); diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index f5d34bccd1..df9be5b7c8 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -152,6 +152,39 @@ static int distance_to_player(const InfernoState* state, const InfNPC* 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_reward_switches_between_healer_tags_and_damage(void) { printf("--- inferno reward switches between healer tags and damage ---\n"); @@ -242,16 +275,121 @@ static void test_inferno_reset_supplies_match_current_inventory(void) { EncounterState* raw_state = inf_create(); InfernoState* state = (InfernoState*)raw_state; + InfSupplyDoses full = inf_full_starting_supplies(); - 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_reset(raw_state, 123); + 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]; - ASSERT_INT_EQ("inferno reset gives 24 brew doses", state->player.brew_doses, 24); - ASSERT_INT_EQ("inferno reset gives 40 restore doses", state->player.restore_doses, 40); - ASSERT_INT_EQ("inferno reset gives 8 bastion doses", state->player.bastion_doses, 8); - ASSERT_INT_EQ("inferno reset gives 4 stamina doses", state->player.stamina_doses, 4); + 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); } @@ -1390,6 +1528,9 @@ int main(void) { test_reward_switches_between_healer_tags_and_damage(); test_final_wave_reward_uses_zuk_low_watermark_progress(); 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(); diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index de029f6bdb..22df555565 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -421,6 +421,13 @@ void my_init(Env* env, Dict* kwargs) { 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); + } /* match the 1-indexed → 0-indexed conversion done by encounter's put_int */ int sw = start_wave ? (int)start_wave->value : 0; env->config_start_wave = (sw > 0) ? sw - 1 : 0; diff --git a/pufferlib/config/ocean/osrs_inferno.ini b/pufferlib/config/ocean/osrs_inferno.ini index 72626d0c91..3748b03507 100644 --- a/pufferlib/config/ocean/osrs_inferno.ini +++ b/pufferlib/config/ocean/osrs_inferno.ini @@ -14,6 +14,7 @@ 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 = "" From d8b29484f04c965be4295edcc7606e4fa0cee0d7 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 28 Apr 2026 00:30:52 +0300 Subject: [PATCH 58/60] validate inferno start waves --- config/osrs_inferno.ini | 14 +++++-- ocean/osrs/encounters/encounter_inferno.h | 16 ++++++-- ocean/osrs/tests/test_inferno_attack_styles.c | 21 ++++++++++ ocean/osrs_inferno/binding.c | 40 ++++++++++++++----- pufferlib/config/ocean/osrs_inferno.ini | 6 +-- 5 files changed, 76 insertions(+), 21 deletions(-) diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini index 3a9d6a47cd..2df58b091b 100644 --- a/config/osrs_inferno.ini +++ b/config/osrs_inferno.ini @@ -6,14 +6,20 @@ env_name = osrs_inferno score_metric = episode_return [env] -start_wave = 69.0 +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.0 +curriculum_wave_1 = 20 curriculum_frac_1 = 0.00 -curriculum_wave_2 = 40.0 +curriculum_wave_2 = 40 curriculum_frac_2 = 0.00 -curriculum_wave_3 = 60.0 +curriculum_wave_3 = 60 curriculum_frac_3 = 0.00 [vec] diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index 9e1aab9c62..bbf458cc2c 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -995,6 +995,15 @@ static void inf_require_valid_public_wave(int public_wave) { } } +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); @@ -1151,7 +1160,7 @@ static void inf_reset(EncounterState* state, uint32_t seed) { s->player.y = is_zuk_wave ? INF_ZUK_PLAYER_START_Y : INF_PLAYER_START_Y; inf_rebuild_player_collision_flags(s); - /* pillars: all destroyed at end of wave 66 (index 65), so waves 66+ have none */ + /* 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]; @@ -4041,8 +4050,9 @@ static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, i 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 */ - if (strcmp(key, "start_wave") == 0) s->start_wave = (value > 0) ? value - 1 : 0; + /* 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; diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c index df9be5b7c8..e6bb448520 100644 --- a/ocean/osrs/tests/test_inferno_attack_styles.c +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -185,6 +185,26 @@ static void assert_supply_doses(const char* 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"); @@ -1527,6 +1547,7 @@ int main(void) { 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(); diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index 22df555565..efbabd5011 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -33,7 +33,7 @@ typedef struct { Log log; EncounterState* enc_state; - int config_start_wave; /* the start_wave from config (not curriculum override) */ + int config_start_wave; /* internal start_wave from config, not curriculum override */ int acts_staging[INF_NUM_ACTION_HEADS]; unsigned char term_staging; @@ -69,6 +69,23 @@ typedef struct { #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; @@ -220,8 +237,8 @@ void c_step(Env* env) { if (is_term) { /* check if this episode is a new global best — if so, flush replay to disk. - for full runs (start_wave 0): best = highest wave reached, then fewest ticks. - for zuk-only (start_wave 68+): best = most damage to zuk (lowest zuk HP), then fewest ticks. + 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; @@ -410,8 +427,9 @@ void my_init(Env* env, Dict* kwargs) { 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", (int)start_wave->value); + 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); @@ -428,9 +446,7 @@ void my_init(Env* env, Dict* kwargs) { env->enc_state, "late_start_supply_profile_scale", (float)supply_profile_scale->value); } - /* match the 1-indexed → 0-indexed conversion done by encounter's put_int */ - int sw = start_wave ? (int)start_wave->value : 0; - env->config_start_wave = (sw > 0) ? sw - 1 : 0; + 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"); @@ -509,7 +525,8 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun 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 = base_start_wave_item ? (int)base_start_wave_item->value : 0; + 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[] = { @@ -525,7 +542,8 @@ Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_coun 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] = (int)w->value; + curriculum_waves[num_tiers] = + inferno_public_wave_from_config(w, wave_keys[i], 0); curriculum_fracs[num_tiers] = (float)f->value; num_tiers++; } @@ -629,8 +647,8 @@ void my_log(Log* log, Dict* out) { float wr = log->wins; float score; - int start_wave = (int)(log->start_wave + 0.5f); - if (start_wave >= 68) { + 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 { diff --git a/pufferlib/config/ocean/osrs_inferno.ini b/pufferlib/config/ocean/osrs_inferno.ini index 3748b03507..189321cf1f 100644 --- a/pufferlib/config/ocean/osrs_inferno.ini +++ b/pufferlib/config/ocean/osrs_inferno.ini @@ -19,11 +19,11 @@ 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.0 +curriculum_wave_1 = 20 curriculum_frac_1 = 0.0 -curriculum_wave_2 = 40.0 +curriculum_wave_2 = 40 curriculum_frac_2 = 0.0 -curriculum_wave_3 = 60.0 +curriculum_wave_3 = 60 curriculum_frac_3 = 0.0 [vec] From d0142ea0a4c34cf15c4baba173b178a503036380 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 28 Apr 2026 17:30:58 +0300 Subject: [PATCH 59/60] Clean OSRS PR noise --- ocean/osrs/encounters/encounter_inferno.h | 136 +++----------------- ocean/osrs/encounters/encounter_nh_pvp.h | 24 ---- ocean/osrs/encounters/encounter_zulrah.h | 75 ----------- ocean/osrs/osrs_anim.h | 27 ---- ocean/osrs/osrs_combat.h | 12 -- ocean/osrs/osrs_damage.h | 6 - ocean/osrs/osrs_encounter.h | 53 +------- ocean/osrs/osrs_gui.h | 57 -------- ocean/osrs/osrs_human_input.h | 15 --- ocean/osrs/osrs_interaction.h | 15 --- ocean/osrs/osrs_inventory.h | 9 -- ocean/osrs/osrs_items.h | 22 ---- ocean/osrs/osrs_models.h | 9 -- ocean/osrs/osrs_pvp_actions.h | 58 --------- ocean/osrs/osrs_pvp_api.h | 45 ------- ocean/osrs/osrs_pvp_combat.h | 63 --------- ocean/osrs/osrs_pvp_effects.h | 15 --- ocean/osrs/osrs_pvp_gear.h | 57 +------- ocean/osrs/osrs_pvp_observations.h | 7 - ocean/osrs/osrs_pvp_opponents.h | 14 +- ocean/osrs/osrs_render.h | 101 +-------------- ocean/osrs/osrs_special_attacks.h | 14 +- ocean/osrs/osrs_types.h | 91 +------------ ocean/osrs/scripts/export_models.py | 2 +- ocean/osrs/scripts/export_sprites_modern.py | 2 +- ocean/osrs/scripts/export_terrain.py | 4 - ocean/osrs/tests/test_bolt_procs.c | 45 ------- ocean/osrs/tests/test_combat_math.c | 6 - ocean/osrs/tests/test_damage.c | 30 ----- ocean/osrs/tests/test_interaction.c | 51 -------- ocean/osrs/tests/test_inventory.c | 48 ------- ocean/osrs/tests/test_item_effects.c | 12 -- ocean/osrs/tests/test_special_attacks.c | 12 -- ocean/osrs_inferno/binding.c | 7 - 34 files changed, 35 insertions(+), 1109 deletions(-) diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index bbf458cc2c..c892b0063d 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -31,9 +31,6 @@ #include #include -/* ======================================================================== */ -/* arena constants */ -/* ======================================================================== */ #define INF_ARENA_MIN_X 11 #define INF_ARENA_MAX_X 39 @@ -72,9 +69,6 @@ static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { #define INF_NUM_WAVES 69 #define INF_NUM_ACTION_HEADS 9 -/* ======================================================================== */ -/* NPC types */ -/* ======================================================================== */ typedef enum { INF_NPC_NIBBLER = 0, /* Jal-Nib: melee, eats pillars */ @@ -241,9 +235,6 @@ static void inf_build_npc_stats(void) { } } -/* ======================================================================== */ -/* wave compositions */ -/* ======================================================================== */ #define INF_MAX_NPCS_PER_WAVE 9 /* wave 62: NNN BB BL M R MA = 9 */ @@ -353,9 +344,6 @@ static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { #undef W }; -/* ======================================================================== */ -/* NPC state */ -/* ======================================================================== */ /* max active NPCs: wave 62 has 9 + blob splits (3 per blob, up to 2 blobs = 6) + healers */ #define INF_MAX_NPCS 32 @@ -441,9 +429,6 @@ typedef struct { int hit_spell_type; /* ENCOUNTER_SPELL_* from the pending hit that just landed */ } InfNPC; -/* ======================================================================== */ -/* pillar state */ -/* ======================================================================== */ typedef struct { int x, y; @@ -451,9 +436,6 @@ typedef struct { int active; } InfPillar; -/* ======================================================================== */ -/* zuk state */ -/* ======================================================================== */ typedef struct { /* shield */ @@ -475,9 +457,6 @@ typedef struct { int has_paused; } InfZukState; -/* ======================================================================== */ -/* weapon sets and pre-computed stats */ -/* ======================================================================== */ typedef enum { INF_GEAR_MAGE = 0, @@ -537,9 +516,6 @@ static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { }; /* tank overlay items (justiciar) */ -/* ======================================================================== */ -/* encounter state */ -/* ======================================================================== */ typedef struct { int brew_doses; @@ -707,9 +683,6 @@ static void inf_shuffle_spawns(InfernoState* s) { encounter_shuffle(s->spawn_order, INF_NUM_SPAWN_POS, &s->rng_state); } -/* ======================================================================== */ -/* LOS helper: rebuild blocker array from active pillars */ -/* ======================================================================== */ static void inf_rebuild_los(InfernoState* s) { s->los_blocker_count = 0; @@ -895,9 +868,6 @@ static inline int inf_choose_attack_style_for_tick( return inf_attack_style_from_mask(style_mask); } -/* ======================================================================== */ -/* dead mob store for mager resurrection */ -/* ======================================================================== */ static inline int inf_dead_mob_is_resurrectable(InfNPCType type) { switch (type) { @@ -927,9 +897,6 @@ static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { dm->max_hp = npc->max_hp; } -/* ======================================================================== */ -/* forward declarations */ -/* ======================================================================== */ static float inf_compute_reward(InfernoState* s); static void inf_spawn_wave(InfernoState* s); @@ -941,9 +908,6 @@ 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); -/* ======================================================================== */ -/* lifecycle */ -/* ======================================================================== */ static EncounterState* inf_create(void) { InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); @@ -1185,9 +1149,6 @@ static void inf_reset(EncounterState* state, uint32_t seed) { s->wave_spawn_delay = 10; } -/* ======================================================================== */ -/* spawn: place NPCs for current wave */ -/* ======================================================================== */ /* find a free NPC slot, return index or -1 */ static int inf_find_free_npc(InfernoState* s) { @@ -1456,9 +1417,6 @@ static void inf_spawn_wave(InfernoState* s) { } } -/* ======================================================================== */ -/* NPC AI: movement */ -/* ======================================================================== */ static int inf_in_arena(int x, int y) { return x >= INF_ARENA_MIN_X && x <= INF_ARENA_MAX_X && @@ -1649,9 +1607,6 @@ static void inf_npc_move(InfernoState* s, int idx) { inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); } -/* ======================================================================== */ -/* NPC AI: meleer dig mechanic */ -/* ======================================================================== */ /* meleer digs when no LOS for 38+ ticks, 10% per tick, forced at 50 */ static void inf_meleer_dig_check(InfernoState* s, int idx) { @@ -1748,9 +1703,6 @@ static int inf_player_weapon_is(const InfernoState* s, uint8_t item) { return s->player.equipped[GEAR_SLOT_WEAPON] == item; } -/* ======================================================================== */ -/* NPC AI: attacks */ -/* ======================================================================== */ static void inf_npc_attack(InfernoState* s, int idx) { InfNPC* npc = &s->npcs[idx]; @@ -2154,9 +2106,6 @@ static void inf_npc_attack(InfernoState* s, int idx) { } } -/* ======================================================================== */ -/* NPC AI: mager resurrection */ -/* ======================================================================== */ static int inf_find_mager_respawn_tile( InfernoState* s, int size, int* out_x, int* out_y @@ -2217,9 +2166,6 @@ static int inf_mager_resurrect(InfernoState* s, int idx) { return 1; } -/* ======================================================================== */ -/* NPC AI: jad healer spawning */ -/* ======================================================================== */ #define INF_JAD_HEALER_MAX_SPAWN_CANDIDATES 165 @@ -2291,9 +2237,6 @@ static void inf_jad_check_healers(InfernoState* s, int idx) { } } -/* ======================================================================== */ -/* NPC AI: zuk phases */ -/* ======================================================================== */ static void inf_zuk_tick(InfernoState* s) { if (!inf_is_final_wave(s)) return; @@ -2469,9 +2412,6 @@ static void inf_resolve_pending_sparks(InfernoState* s) { } } -/* ======================================================================== */ -/* NPC AI: tick all NPCs */ -/* ======================================================================== */ static void inf_tick_npcs(InfernoState* s) { /* NPC per-tick flags are cleared in inf_step BEFORE inf_tick_player, @@ -2516,9 +2456,6 @@ static void inf_tick_npcs(InfernoState* s) { } } -/* ======================================================================== */ -/* player actions */ -/* ======================================================================== */ #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) */ @@ -2779,7 +2716,7 @@ static void inf_tick_player(InfernoState* s, const int* actions) { 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 && + 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); @@ -3146,9 +3083,6 @@ static void inf_resolve_jad_prayer_checks_after_player(InfernoState* s) { } } -/* ======================================================================== */ -/* reward */ -/* ======================================================================== */ static int inf_healer_is_actively_healing(const InfernoState* s, const InfNPC* npc) { if (!npc->active || npc->death_ticks > 0) return 0; @@ -3194,9 +3128,6 @@ static float inf_compute_reward(InfernoState* s) { return reward; } -/* ======================================================================== */ -/* step */ -/* ======================================================================== */ static void inf_step(EncounterState* state, const int* actions) { InfernoState* s = (InfernoState*)state; @@ -3244,11 +3175,8 @@ static void inf_step(EncounterState* state, const int* actions) { 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); @@ -3432,9 +3360,6 @@ static void inf_step(EncounterState* state, const int* actions) { } } -/* ======================================================================== */ -/* observations */ -/* ======================================================================== */ /* 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) */ @@ -3485,10 +3410,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; - /* tick normalization: Zuk-only (~300 ticks) vs full runs (~18000 ticks) */ obs[i++] = 0.0f; - // (s->start_wave >= 68) ? (float)s->tick / 500.0f - // : (float)s->tick / (float)INF_MAX_TICKS; obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; @@ -3518,7 +3440,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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]; @@ -3533,8 +3455,6 @@ static void inf_write_obs(EncounterState* state, float* obs) { if (ph->attack_style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; if (ph->attack_style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; } - if (t == 1) { - } } } @@ -3544,19 +3464,14 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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_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; - - /* Relaxed check: if they can reach us or shoot us soon, track them. */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, npc->size); - if (dist == 0) continue; /* Under us, usually can't attack or moves out */ - - /* If they are completely out of range (more than 1 tile away from being able to attack), - we might ignore them, but to be safe we'll just track all aggroed NPCs that are off cooldown. - We'll just leave dist and LOS out of the strict filter. */ + if (dist == 0) continue; int style = npc->attack_style; if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { @@ -3572,7 +3487,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { int preview_style = inf_attack_style_obs_preview(style_mask); int t = npc->attack_timer; - if (t == 0) t = 1; /* Safety fallback if timer hit 0 */ + if (t == 0) t = 1; if (t < min_timer) { min_timer = t; @@ -3583,7 +3498,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3647,11 +3562,11 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3702,14 +3617,14 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3724,16 +3639,16 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3744,7 +3659,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { 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; @@ -3761,7 +3676,7 @@ static void inf_write_obs(EncounterState* state, float* obs) { } 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 { @@ -3918,9 +3833,6 @@ static void inf_write_mask(EncounterState* state, float* mask) { mask[offset++] = (s->player.current_prayer > 0 || s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; } -/* ======================================================================== */ -/* query functions */ -/* ======================================================================== */ static float inf_get_reward(EncounterState* state) { return ((InfernoState*)state)->reward; @@ -4113,9 +4025,6 @@ static void* inf_get_log(EncounterState* state) { return &s->log; } -/* ======================================================================== */ -/* render post-tick: populate overlay projectiles for renderer */ -/* ======================================================================== */ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { @@ -4212,9 +4121,8 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { /* 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. NOTE: sim-side hit delay - is currently NOT adjusted by +2 — damage lands 2 ticks - earlier than reference. */ + 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; @@ -4356,9 +4264,6 @@ static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { } } -/* ======================================================================== */ -/* human input translator */ -/* ======================================================================== */ static void* inf_get_player_for_input(void* state, int idx) { InfernoState* s = (InfernoState*)state; @@ -4474,9 +4379,6 @@ static void inf_step_human_commands(EncounterState* state, HumanInput* hi) { human_input_clear_pending(hi); } -/* ======================================================================== */ -/* encounter definition */ -/* ======================================================================== */ static const EncounterDef ENCOUNTER_INFERNO = { .name = "inferno", diff --git a/ocean/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h index 3302e12c6a..8487194fc2 100644 --- a/ocean/osrs/encounters/encounter_nh_pvp.h +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -22,17 +22,11 @@ static const int NH_PVP_ACTION_DIMS[] = { FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM }; -/* ======================================================================== */ -/* encounter state: just wraps OsrsEnv */ -/* ======================================================================== */ typedef struct { OsrsEnv env; } NhPvpState; -/* ======================================================================== */ -/* lifecycle */ -/* ======================================================================== */ static EncounterState* nh_pvp_create(void) { NhPvpState* s = (NhPvpState*)calloc(1, sizeof(NhPvpState)); @@ -69,9 +63,6 @@ static void nh_pvp_step(EncounterState* state, const int* actions) { pvp_step(&s->env); } -/* ======================================================================== */ -/* RL interface */ -/* ======================================================================== */ static void nh_pvp_write_obs(EncounterState* state, float* obs_out) { NhPvpState* s = (NhPvpState*)state; @@ -99,9 +90,6 @@ static int nh_pvp_is_terminal(EncounterState* state) { return s->env.episode_over; } -/* ======================================================================== */ -/* entity access */ -/* ======================================================================== */ static int nh_pvp_get_entity_count(EncounterState* state) { (void)state; @@ -113,9 +101,6 @@ static void* nh_pvp_get_entity(EncounterState* state, int index) { return &s->env.players[index]; } -/* ======================================================================== */ -/* render entity population */ -/* ======================================================================== */ static void nh_pvp_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { NhPvpState* s = (NhPvpState*)state; @@ -126,9 +111,6 @@ static void nh_pvp_fill_render_entities(EncounterState* state, RenderEntity* out *count = n; } -/* ======================================================================== */ -/* config */ -/* ======================================================================== */ static void nh_pvp_put_int(EncounterState* state, const char* key, int value) { NhPvpState* s = (NhPvpState*)state; @@ -160,9 +142,6 @@ static void nh_pvp_put_ptr(EncounterState* state, const char* key, void* value) } } -/* ======================================================================== */ -/* logging and state queries */ -/* ======================================================================== */ static void* nh_pvp_get_log(EncounterState* state) { NhPvpState* s = (NhPvpState*)state; @@ -179,9 +158,6 @@ static int nh_pvp_get_winner(EncounterState* state) { return s->env.winner; } -/* ======================================================================== */ -/* encounter definition */ -/* ======================================================================== */ static const EncounterDef ENCOUNTER_NH_PVP = { .name = "nh_pvp", diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h index cfc804bfa7..aca8603e56 100644 --- a/ocean/osrs/encounters/encounter_zulrah.h +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -47,9 +47,6 @@ #include #include -/* ======================================================================== */ -/* constants */ -/* ======================================================================== */ #define ZUL_ARENA_SIZE 28 #define ZUL_NPC_SIZE 5 @@ -150,9 +147,6 @@ static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { #define ZUL_PLAYER_RESTORE_DOSES 8 /* prayer potion doses (4 per pot = 2 pots) */ #define ZUL_MAX_TICKS 600 -/* ======================================================================== */ -/* observation and action space */ -/* ======================================================================== */ #define ZUL_NUM_OBS 84 /* +3 for offensive prayer one-hot (piety/rigour/augury) */ #define ZUL_NUM_ACTION_HEADS 7 @@ -181,9 +175,6 @@ static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { #define ZUL_ATK_MAGE 1 #define ZUL_ATK_RANGE 2 -/* ======================================================================== */ -/* enums */ -/* ======================================================================== */ typedef enum { ZUL_FORM_GREEN = 0, /* 2042: serpentine, ranged */ @@ -203,9 +194,6 @@ typedef enum { ZUL_GEAR_RANGE, } ZulrahGearStyle; -/* ======================================================================== */ -/* rotation data: action types for phase sequences */ -/* ======================================================================== */ typedef enum { ZA_END = 0, /* sentinel — end of action list */ @@ -362,9 +350,6 @@ static const int ZUL_ROT_LENGTHS[ZUL_NUM_ROTATIONS] = { 11, 11, 12, 13 }; #undef ZA #undef ZE -/* ======================================================================== */ -/* static arrays */ -/* ======================================================================== */ static const int ZUL_ACTION_HEAD_DIMS[ZUL_NUM_ACTION_HEADS] = { ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, @@ -442,9 +427,6 @@ static int zul_tile_is_safe(int x, int y, int stand_id, int stall_id) { return 0; } -/* ======================================================================== */ -/* structs */ -/* ======================================================================== */ typedef struct { int x, y; @@ -587,9 +569,6 @@ typedef struct { /* 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); -/* ======================================================================== */ -/* helpers */ -/* ======================================================================== */ static inline int zul_on_platform_bounds(int x, int y) { return x >= ZUL_PLATFORM_MIN && x <= ZUL_PLATFORM_MAX && @@ -633,9 +612,6 @@ static inline int zul_cap_damage(ZulrahState* s, int damage) { return damage; } -/* ======================================================================== */ -/* damage application */ -/* ======================================================================== */ /** 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. @@ -682,9 +658,6 @@ static void zul_try_envenom(ZulrahState* s) { s->venom_timer = ZUL_VENOM_INTERVAL; } -/* ======================================================================== */ -/* NPC accuracy roll */ -/* ======================================================================== */ /* OSRS accuracy formula: if att > def: 1 - (def+2)/(2*(att+1)), else att/(2*(def+1)) */ /* hit chance: use shared OSRS accuracy formula from osrs_combat.h */ @@ -703,9 +676,6 @@ static int zul_player_def_roll(ZulrahState* s, int attack_style) { return roll > 0 ? roll : 0; } -/* ======================================================================== */ -/* zulrah attack dispatch */ -/* ======================================================================== */ /* record a visual attack event for projectile rendering */ static void zul_record_attack(ZulrahState* s, int src_x, int src_y, @@ -834,9 +804,6 @@ static void zul_attack_jad(ZulrahState* s) { s->jad_is_magic_next = !s->jad_is_magic_next; } -/* ======================================================================== */ -/* player attacks zulrah */ -/* ======================================================================== */ /* per-form defence bonuses from MONSTER_DATABASE */ static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* def_ranged) { @@ -1031,9 +998,6 @@ static void zul_player_spec(ZulrahState* s) { s->zulrah.hit_was_successful = (total_dmg > 0); } -/* ======================================================================== */ -/* snakelings */ -/* ======================================================================== */ /* pick a walkable spawn position for a snakeling, falling back to player's tile */ static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { @@ -1140,9 +1104,6 @@ static void zul_snakeling_tick(ZulrahState* s) { } } -/* ======================================================================== */ -/* clouds */ -/* ======================================================================== */ /* forward: needed by zul_spawn_cloud to get current phase's safe tiles */ static const ZulRotationPhase* zul_current_phase(ZulrahState* s) { @@ -1274,9 +1235,6 @@ static void zul_cloud_tick(ZulrahState* s) { } } -/* ======================================================================== */ -/* venom */ -/* ======================================================================== */ static void zul_venom_tick(ZulrahState* s) { /* antivenom timer ticks down */ @@ -1293,9 +1251,6 @@ static void zul_venom_tick(ZulrahState* s) { s->venom_timer = ZUL_VENOM_INTERVAL; } -/* ======================================================================== */ -/* thrall: arceuus greater ghost */ -/* ======================================================================== */ static void zul_thrall_tick(ZulrahState* s) { if (!s->thrall_active) { @@ -1327,9 +1282,6 @@ static void zul_thrall_tick(ZulrahState* s) { s->total_damage_dealt += dmg; } -/* ======================================================================== */ -/* phase machine: execute current action in rotation table */ -/* ======================================================================== */ /* fire one instance of the current action (attack/cloud/snakeling) */ static void zul_fire_action(ZulrahState* s, ZulActionType type) { @@ -1523,9 +1475,6 @@ static void zul_phase_tick(ZulrahState* s) { } } -/* ======================================================================== */ -/* player action processing */ -/* ======================================================================== */ static void zul_process_movement(ZulrahState* s) { if (s->player_dest_x < 0 || s->player_dest_y < 0) return; @@ -1660,9 +1609,6 @@ static void zul_apply_human_player_commands(ZulrahState* s) { } -/* ======================================================================== */ -/* observations */ -/* ======================================================================== */ static void zul_write_obs(EncounterState* state, float* obs) { ZulrahState* s = (ZulrahState*)state; @@ -1760,9 +1706,6 @@ static void zul_write_obs(EncounterState* state, float* obs) { while (i < ZUL_NUM_OBS) obs[i++] = 0.0f; } -/* ======================================================================== */ -/* action masks */ -/* ======================================================================== */ static void zul_write_mask(EncounterState* state, float* mask) { ZulrahState* s = (ZulrahState*)state; @@ -1837,9 +1780,6 @@ static void zul_write_mask(EncounterState* state, float* mask) { } } -/* ======================================================================== */ -/* reward */ -/* ======================================================================== */ static float zul_compute_reward(ZulrahState* s) { /* terminal: +1 kill, 0 death (forfeiting future rewards is the penalty) */ @@ -1870,9 +1810,6 @@ static float zul_compute_reward(ZulrahState* s) { return r; } -/* ======================================================================== */ -/* lifecycle */ -/* ======================================================================== */ static EncounterState* zul_create(void) { return (EncounterState*)calloc(1, sizeof(ZulrahState)); @@ -2108,9 +2045,6 @@ static void zul_step(EncounterState* state, const int* actions) { s->episode_return += s->reward; } -/* ======================================================================== */ -/* heuristic policy (for visual debug + sanity checks) */ -/* ======================================================================== */ static void zul_heuristic_actions(ZulrahState* s, int* actions) { /* zero all heads */ @@ -2208,9 +2142,6 @@ static void zul_heuristic_actions(ZulrahState* s, int* actions) { } } -/* ======================================================================== */ -/* RL interface */ -/* ======================================================================== */ static float zul_get_reward(EncounterState* state) { return ((ZulrahState*)state)->reward; @@ -2380,9 +2311,6 @@ static void zul_render_post_tick(EncounterState* state, EncounterOverlay* ov) { } static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } -/* ======================================================================== */ -/* human input translator */ -/* ======================================================================== */ static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; @@ -2492,9 +2420,6 @@ static void zul_step_human_commands(EncounterState* state, HumanInput* hi) { human_input_clear_pending(hi); } -/* ======================================================================== */ -/* encounter definition */ -/* ======================================================================== */ static const EncounterDef ENCOUNTER_ZULRAH = { .name = "zulrah", diff --git a/ocean/osrs/osrs_anim.h b/ocean/osrs/osrs_anim.h index 5f9bde2ac9..8bb351288b 100644 --- a/ocean/osrs/osrs_anim.h +++ b/ocean/osrs/osrs_anim.h @@ -30,9 +30,6 @@ #define ANIM_MAX_LABELS 256 #define ANIM_SINE_COUNT 2048 -/* ======================================================================== */ -/* sine/cosine table (matches OSRS Rasterizer3D, fixed-point scale 65536) */ -/* ======================================================================== */ static int anim_sine[ANIM_SINE_COUNT]; static int anim_cosine[ANIM_SINE_COUNT]; @@ -48,9 +45,6 @@ static void anim_init_trig(void) { anim_trig_initialized = 1; } -/* ======================================================================== */ -/* data structures */ -/* ======================================================================== */ typedef struct { uint16_t base_id; @@ -105,9 +99,6 @@ typedef struct { int* group_counts; /* [ANIM_MAX_LABELS] count per group */ } AnimModelState; -/* ======================================================================== */ -/* loading */ -/* ======================================================================== */ static uint8_t anim_read_u8(const uint8_t** p) { uint8_t v = **p; (*p)++; @@ -235,9 +226,6 @@ static AnimCache* anim_cache_load(const char* path) { return cache; } -/* ======================================================================== */ -/* lookup */ -/* ======================================================================== */ static AnimSequence* anim_get_sequence(AnimCache* cache, uint16_t seq_id) { if (!cache) return NULL; @@ -259,9 +247,6 @@ static AnimFrameBase* anim_get_framebase(AnimCache* cache, uint16_t base_id) { return NULL; } -/* ======================================================================== */ -/* per-model animation state */ -/* ======================================================================== */ static AnimModelState* anim_model_state_create( const uint8_t* vertex_skins, @@ -310,9 +295,6 @@ static void anim_model_state_free(AnimModelState* state) { free(state); } -/* ======================================================================== */ -/* transform application (mirrors OSRS Model.transform) */ -/* ======================================================================== */ static void anim_apply_frame( AnimModelState* state, @@ -440,9 +422,6 @@ static void anim_apply_frame( } } -/* ======================================================================== */ -/* two-track interleaved animation (matches OSRS Model.applyAnimationFrames) */ -/* ======================================================================== */ /** * Apply a single transform slot to the vertex state (extracted from anim_apply_frame @@ -607,9 +586,6 @@ static void anim_apply_frame_interleaved( } } -/* ======================================================================== */ -/* mesh re-expansion (apply animated base verts → expanded rendering verts) */ -/* ======================================================================== */ /** * Re-expand animated base vertices into the raylib mesh's expanded vertex buffer. @@ -648,9 +624,6 @@ static void anim_update_mesh( } } -/* ======================================================================== */ -/* cleanup */ -/* ======================================================================== */ static void anim_cache_free(AnimCache* cache) { if (!cache) return; diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h index 63e3de68e2..90fdb5bd7f 100644 --- a/ocean/osrs/osrs_combat.h +++ b/ocean/osrs/osrs_combat.h @@ -89,9 +89,6 @@ static inline float osrs_tbow_dmg_mult(int target_magic) { return mult; } -/* ======================================================================== */ -/* shared encounter RNG (xorshift32) */ -/* ======================================================================== */ /* all encounters should use these instead of reimplementing. state must be non-zero. */ @@ -111,9 +108,6 @@ static inline float encounter_rand_float(uint32_t* rng_state) { return (float)(encounter_xorshift(rng_state) & 0xFFFF) / 65536.0f; } -/* ======================================================================== */ -/* barrage AoE (3x3) */ -/* ======================================================================== */ #define BARRAGE_MAX_HITS 9 #define BARRAGE_FREEZE_TICKS 32 @@ -205,9 +199,6 @@ static inline BarrageResult osrs_barrage_resolve( return result; } -/* ======================================================================== */ -/* NPC combat formulas (from InfernoTrainer/osrs-sdk) */ -/* ======================================================================== */ /* NPC melee max hit: floor((str + 8) * (melee_str_bonus + 64) + 320) / 640) */ static inline int osrs_npc_melee_max_hit(int str_level, int melee_str_bonus) { @@ -304,9 +295,6 @@ static inline int encounter_prayer_correct_for_style(int prayer, int attack_styl (attack_style == 3 /* ATTACK_STYLE_MAGIC */ && prayer == 1 /* PRAYER_PROTECT_MAGIC */); } -/* ======================================================================== */ -/* hit delay formulas (matching PvP + InfernoTrainer SDK) */ -/* ======================================================================== */ /* magic hit delay: floor((1 + distance) / 3) + 1, +1 if attacker is player */ static inline int encounter_magic_hit_delay(int distance, int is_player) { diff --git a/ocean/osrs/osrs_damage.h b/ocean/osrs/osrs_damage.h index edabc38c15..2a23171b5d 100644 --- a/ocean/osrs/osrs_damage.h +++ b/ocean/osrs/osrs_damage.h @@ -30,9 +30,6 @@ #include "osrs_combat.h" #include "osrs_items.h" -/* ======================================================================== */ -/* damage pipeline result */ -/* ======================================================================== */ typedef struct { int final_damage; /* damage after prayer reduction */ @@ -106,9 +103,6 @@ static inline DamageResult osrs_apply_damage_pipeline( ); } -/* ======================================================================== */ -/* helpers */ -/* ======================================================================== */ /* check if player has a recoil-capable ring equipped. ring of recoil (finite charges) or ring of suffering (i) (infinite). diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index eea74ec5b4..a30678949f 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -69,9 +69,6 @@ /* opaque encounter state — each encounter defines its own struct */ typedef struct EncounterState EncounterState; -/* ======================================================================== */ -/* shared pending hit system for delayed projectile damage */ -/* ======================================================================== */ #define ENCOUNTER_MAX_PENDING_HITS 8 @@ -227,9 +224,6 @@ static inline void encounter_set_projectile_offset( ov->projectiles[projectile_idx].offset_z = offset_z; } -/* ======================================================================== */ -/* render entity: shared abstraction for renderer (value type, not pointer) */ -/* ======================================================================== */ typedef struct { EntityType entity_type; @@ -388,9 +382,6 @@ static inline int encounter_apply_offensive_action(OffensivePrayer* offensive, i return activating; } -/* ======================================================================== */ -/* shared movement: 25-action system (idle + 8 walk + 16 run) */ -/* ======================================================================== */ /* 25 movement actions: idle(0), walk(1-8), run(9-24) */ #define ENCOUNTER_MOVE_ACTIONS 25 @@ -459,9 +450,6 @@ static inline int encounter_move_to_target( return steps; } -/* ======================================================================== */ -/* shared BFS click-to-move (human mode + destination-based movement) */ -/* ======================================================================== */ /* shared BFS pathfind wrapper — translates local coords to world coords for pathfind_step. extra_blocked/blocked_ctx: optional callback for dynamic obstacles (pillars etc.). @@ -532,9 +520,6 @@ static inline int encounter_move_toward_dest( return steps; } -/* ======================================================================== */ -/* shared attack-target chase (auto-walk toward out-of-range target) */ -/* ======================================================================== */ /* footprint helpers for player-vs-target chase and range checks. */ static inline int encounter_entity_footprint_distance( @@ -732,9 +717,6 @@ static inline int encounter_chase_attack_target( return steps > 0 ? 1 : 0; } -/* ======================================================================== */ -/* shared NPC step-out-from-under (OSRS: NPC shuffles off player tile) */ -/* ======================================================================== */ typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); typedef int (*encounter_npc_overlap_hold_fn)(void* ctx); @@ -790,9 +772,6 @@ static inline int encounter_npc_step_out_from_under( return ENCOUNTER_NPC_UNDER_PLAYER_NONE; } -/* ======================================================================== */ -/* shared NPC greedy pathfinding */ -/* ======================================================================== */ /** 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 @@ -948,9 +927,6 @@ static inline void encounter_damage_npc( *hit_damage = damage > 0 ? damage : 0; } -/* ======================================================================== */ -/* shared NPC pending hit resolution (barrage freeze + blood heal) */ -/* ======================================================================== */ /** resolve a single NPC's pending hit. tick down, apply damage when it lands. ice barrage: sets *frozen_ticks = BARRAGE_FREEZE_TICKS on hit. @@ -1013,8 +989,6 @@ static inline void encounter_resolve_player_pending_hits( if (hits[i].ticks_remaining <= 0) { int dmg = hits[i].damage; if (hits[i].check_prayer) { - /* legacy path: delay was 0 and check_prayer never decremented. - happens if encounter sets check_prayer=1 with no delay — check now. */ if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { dmg = 0; if (prayer_correct_count) (*prayer_correct_count)++; @@ -1022,16 +996,9 @@ static inline void encounter_resolve_player_pending_hits( if (off_prayer_hit_count) (*off_prayer_hit_count)++; } } else if (dmg > 0 && hits[i].attack_style != ATTACK_STYLE_NONE) { - /* Even if check_prayer was done at launch (e.g. non-Jad mobs), - it only zeroed dmg if correctly prayed AT LAUNCH. - Wait, actually, for non-Jad, check_prayer is 0 and dmg is already computed. - If dmg > 0, it means we took a hit. Was it an off-prayer hit? - Yes, because if we prayed correctly at launch, dmg would be 0. - BUT we don't know if we just missed the pray or if it was typeless. - We assume ATTACK_STYLE_NONE is typeless. */ if (off_prayer_hit_count) (*off_prayer_hit_count)++; } - + encounter_damage_player(player, dmg, damage_received_acc); hits[i] = hits[--(*hit_count)]; i--; @@ -1039,9 +1006,6 @@ static inline void encounter_resolve_player_pending_hits( } } -/* ======================================================================== */ -/* shared per-tick flag clearing for encounters */ -/* ======================================================================== */ /** clear all per-tick animation/event flags on a player. call at the start of each encounter tick, then set flags as events happen. @@ -1058,9 +1022,6 @@ static inline void encounter_clear_tick_flags(Player* p) { p->used_special_this_tick = 0; } -/* ======================================================================== */ -/* shared reset helpers */ -/* ======================================================================== */ /** resolve RNG seed for encounter reset. priority: explicit seed > saved state > default. all encounters MUST use this to ensure consistent RNG initialization. */ @@ -1504,9 +1465,6 @@ static inline int encounter_use_spec(Player* p, int cost) { return 1; } -/* ======================================================================== */ -/* shared gear switching helpers for encounters */ -/* ======================================================================== */ /** apply a full static loadout to player equipment and set gear state. used by Zulrah, Inferno, and future boss encounters with fixed loadouts. */ @@ -1549,9 +1507,6 @@ static void encounter_populate_inventory( } } -/* ======================================================================== */ -/* shared human input translate helpers */ -/* ======================================================================== */ /** translate movement: convert absolute tile to 8-directional walk action. writes to actions[head_move]. head_move < 0 = skip. */ @@ -1601,9 +1556,6 @@ static inline void encounter_translate_target(HumanInput* hi, int* actions, int actions[head_target] = hi->pending_target_idx + 1; } -/* ======================================================================== */ -/* encounter definition (vtable) */ -/* ======================================================================== */ typedef struct { const char* name; /* "nh_pvp", "cerberus", "jad", etc. */ @@ -1671,9 +1623,6 @@ typedef struct { int (*get_winner)(EncounterState* state); } EncounterDef; -/* ======================================================================== */ -/* encounter registry */ -/* ======================================================================== */ #define MAX_ENCOUNTERS 32 diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 003ce79531..70a20c35f2 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -30,9 +30,6 @@ #include "osrs_items.h" #include "osrs_pvp_gear.h" -/* ======================================================================== */ -/* OSRS color palette (from client widget rendering) */ -/* ======================================================================== */ #define GUI_BG_DARK CLITERAL(Color){ 62, 53, 41, 255 } #define GUI_BG_MEDIUM CLITERAL(Color){ 75, 67, 54, 255 } @@ -57,9 +54,6 @@ /* OSRS text shadow: draw black at (+1,+1) then color on top */ #define GUI_TEXT_SHADOW CLITERAL(Color){ 0, 0, 0, 255 } -/* ======================================================================== */ -/* tab system — 7 tabs matching OSRS fixed-mode, drawn at TOP of panel */ -/* ======================================================================== */ typedef enum { GUI_TAB_COMBAT = 0, @@ -72,18 +66,12 @@ typedef enum { GUI_TAB_COUNT = 7 } GuiTab; -/* ======================================================================== */ -/* equipment slot sprite indices (maps GEAR_SLOT_* to sprite array index) */ -/* ======================================================================== */ /* slot background sprite IDs from cache index 8: head=156, cape=157, neck=158, weapon=159, ring=160, body=161, shield=162, legs=163, hands=164, feet=165, tile=170 */ #define GUI_NUM_SLOT_SPRITES 12 /* 11 slots + tile background */ -/* ======================================================================== */ -/* prayer icon indices */ -/* ======================================================================== */ /* prayer icons — authoritative 29-entry standard book. enum order IS display order (left→right, top→bottom) in the 5×6 grid. @@ -121,9 +109,6 @@ typedef enum { GUI_NUM_PRAYERS /* = 29 */ } GuiPrayerIdx; -/* ======================================================================== */ -/* spell icon indices */ -/* ======================================================================== */ /* Ancient spellbook sorted by level (Smoke→Shadow→Blood→Ice per row, Rush→Burst→Blitz→Barrage per family). Only Ice/Blood/Vengeance are @@ -154,9 +139,6 @@ typedef enum { GUI_NUM_SPELLS } GuiSpellIdx; -/* ======================================================================== */ -/* inventory slot system — unified grid for equipment + consumables */ -/* ======================================================================== */ /* inventory slot types: either an equipment item (ITEM_DATABASE index) or a consumable. consumables are tracked as counts in Player, not as individual ITEM_DATABASE entries, @@ -231,9 +213,6 @@ typedef enum { INV_ACTION_DRINK, } InvAction; -/* ======================================================================== */ -/* gui state: textures + layout */ -/* ======================================================================== */ typedef struct { GuiTab active_tab; @@ -337,9 +316,6 @@ typedef struct { int pending_spell_highlight; } GuiState; -/* ======================================================================== */ -/* sprite loading (called after InitWindow in render_make_client) */ -/* ======================================================================== */ /** Try loading a texture, returns 1 on success. */ static int gui_try_load(Texture2D* tex, const char* path) { @@ -571,9 +547,6 @@ static void gui_unload_sprites(GuiState* gs) { gs->sprites_loaded = 0; } -/* ======================================================================== */ -/* short item names for slot display */ -/* ======================================================================== */ static const char* gui_item_short_name(uint8_t item_idx) { if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return ""; @@ -683,9 +656,6 @@ static const char* gui_item_short_name(uint8_t item_idx) { } } -/* ======================================================================== */ -/* drawing helpers */ -/* ======================================================================== */ /** Draw text with OSRS-style shadow (black at +1,+1, then color). */ static void gui_text_shadow(const char* text, int x, int y, int size, Color color) { @@ -744,9 +714,6 @@ static void gui_draw_equip_slot(GuiState* gs, int x, int y, int w, int h, } } -/* ======================================================================== */ -/* status bar — compact HP/prayer/spec display above tab row */ -/* ======================================================================== */ static void gui_draw_status_bar(GuiState* gs, Player* p) { int sx = gs->panel_x + 6; @@ -784,9 +751,6 @@ static void gui_draw_status_bar(GuiState* gs, Player* p) { sx + 4, sy + 1, 8, GUI_TEXT_WHITE); } -/* ======================================================================== */ -/* tab bar — 7 tabs at TOP of panel (real OSRS fixed-mode layout) */ -/* ======================================================================== */ static void gui_draw_tab_bar(GuiState* gs) { /* tabs drawn at top: right after the status bar */ @@ -846,17 +810,11 @@ static int gui_handle_tab_click(GuiState* gs, int mouse_x, int mouse_y) { return 0; } -/* ======================================================================== */ -/* content area Y: below status bar + tab row */ -/* ======================================================================== */ static int gui_content_y(GuiState* gs) { return gs->panel_y + gs->status_bar_h + gs->tab_h; } -/* ======================================================================== */ -/* inventory panel (interface 149) — 4x7 grid with equipment + consumables */ -/* ======================================================================== */ /* inventory grid dimensions — scaled to fill the 320px panel width. OSRS native: 42x36 cell pitch, 36x32 sprites. we scale ~1.81x so @@ -1594,9 +1552,6 @@ static void gui_draw_inventory(GuiState* gs, Player* p) { } } -/* ======================================================================== */ -/* equipment panel (interface 387: paperdoll layout only) */ -/* ======================================================================== */ static void gui_draw_equipment(GuiState* gs, Player* p) { int oy = gui_content_y(gs) + 8; @@ -1639,9 +1594,6 @@ static void gui_draw_equipment(GuiState* gs, Player* p) { gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_RING, p->equipped[GEAR_SLOT_RING]); } -/* ======================================================================== */ -/* prayer panel (interface 541) — single 5-column grid, all 25 prayers */ -/* ======================================================================== */ /* 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. */ @@ -1719,9 +1671,6 @@ static void gui_draw_prayer(GuiState* gs, Player* p) { } } -/* ======================================================================== */ -/* combat panel (interface 593) — weapon + 4 style buttons + spec bar */ -/* ======================================================================== */ static void gui_draw_combat(GuiState* gs, Player* p) { int ox = gs->panel_x + 8; @@ -1832,9 +1781,6 @@ static void gui_draw_combat(GuiState* gs, Player* p) { ox + spec_w / 2 - 10, oy + 4, 10, GUI_TEXT_WHITE); } -/* ======================================================================== */ -/* spellbook panel (interface 218: ancient magicks + vengeance) */ -/* ======================================================================== */ typedef struct { const char* name; @@ -2114,9 +2060,6 @@ static void gui_draw_stats(GuiState* gs, Player* p) { ox + 4, oy + 1, 10, GUI_TEXT_WHITE); } -/* ======================================================================== */ -/* main GUI draw (dispatches to active tab) */ -/* ======================================================================== */ static void gui_cycle_entity(GuiState* gs) { if (gs->gui_entity_count <= 0) return; diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index 9ec3518e1d..c50260ce68 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -20,9 +20,6 @@ /* forward declare — full struct lives in osrs_pvp_render.h */ struct RenderClient; -/* ======================================================================== */ -/* screen-to-world conversion */ -/* ======================================================================== */ /** Convert screen X to world tile X (inverse of render_world_to_screen_x_rc). tile_size = RENDER_TILE_SIZE (passed to avoid header ordering issues). */ @@ -40,9 +37,6 @@ static inline int human_screen_to_world_y(int screen_y, int arena_base_y, return arena_base_y + (arena_height - 1) - flipped; } -/* ======================================================================== */ -/* click handlers — set semantic intents on HumanInput */ -/* ======================================================================== */ /** Check if world tile (wx,wy) is within an NPC's bounding box. OSRS NPCs occupy npc_size x npc_size tiles anchored at (x,y) as southwest corner. @@ -300,9 +294,6 @@ static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, } } -/* ======================================================================== */ -/* action translators — convert semantic intents to action arrays */ -/* ======================================================================== */ /** Translate human input to PvP 7-head action array for agent 0. Movement is target-relative (ADJACENT/UNDER/DIAGONAL/FARCAST_N). */ @@ -383,18 +374,12 @@ static void human_to_pvp_actions(HumanInput* hi, int* actions, } } - /* offensive prayer: encounters wire their HEAD_OFFENSIVE_* in their own translate - helper via encounter_translate_offensive_prayer(). PvP's HEAD_OFFENSIVE is - routed by pvp_actions.h. nothing to do here — direct mutation was legacy. */ (void)agent; } /* shared translate helpers (encounter_translate_movement/prayer/target) live in osrs_encounter.h so encounter headers can use them directly. */ -/* ======================================================================== */ -/* visual feedback drawing */ -/* ======================================================================== */ /* click cross sprite textures: 4 yellow (move) + 4 red (attack) animation frames. loaded from data/sprites/gui/cross_*.png, indexed [0..3] yellow, [4..7] red. */ diff --git a/ocean/osrs/osrs_interaction.h b/ocean/osrs/osrs_interaction.h index 0d79928ac8..023bdc4122 100644 --- a/ocean/osrs/osrs_interaction.h +++ b/ocean/osrs/osrs_interaction.h @@ -13,17 +13,11 @@ #ifndef OSRS_INTERACTION_H #define OSRS_INTERACTION_H -/* ======================================================================== */ -/* interaction state */ -/* ======================================================================== */ typedef struct { int target_slot; /* target entity slot index, -1 = no interaction */ } OsrsInteraction; -/* ======================================================================== */ -/* interaction management */ -/* ======================================================================== */ static inline void osrs_interaction_set(OsrsInteraction* ix, int target_slot) { ix->target_slot = target_slot; @@ -41,9 +35,6 @@ static inline void osrs_interaction_init(OsrsInteraction* ix) { ix->target_slot = -1; } -/* ======================================================================== */ -/* action type constants */ -/* ======================================================================== */ #define OSRS_IACT_NONE 0 /* no action / idle — does NOT interrupt */ #define OSRS_IACT_MOVE 1 /* explicit ground click — INTERRUPTS */ @@ -54,9 +45,6 @@ static inline void osrs_interaction_init(OsrsInteraction* ix) { #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) */ -/* ======================================================================== */ -/* interrupt checking */ -/* ======================================================================== */ /* check if an action type interrupts the current interaction. if it does, clears the interaction and returns 1. otherwise returns 0. @@ -78,9 +66,6 @@ static inline int osrs_interaction_check_interrupt(OsrsInteraction* ix, int acti } } -/* ======================================================================== */ -/* spec armed state helpers */ -/* ======================================================================== */ /* spec toggle: arm/disarm special attack. in real OSRS: clicking the spec orb toggles spec_armed. diff --git a/ocean/osrs/osrs_inventory.h b/ocean/osrs/osrs_inventory.h index a39b908323..1199e68d5d 100644 --- a/ocean/osrs/osrs_inventory.h +++ b/ocean/osrs/osrs_inventory.h @@ -26,9 +26,6 @@ typedef struct { uint8_t inventory[OSRS_INVENTORY_SIZE]; } OsrsInventory; -/* ======================================================================== */ -/* inventory management */ -/* ======================================================================== */ /** initialize inventory: all slots to ITEM_NONE. */ static inline void osrs_inventory_init(OsrsInventory* inv) { @@ -86,9 +83,6 @@ static inline int osrs_inventory_remove_item(OsrsInventory* inv, uint8_t item_id return 1; } -/* ======================================================================== */ -/* gear slot mapping */ -/* ======================================================================== */ /** map item index to its gear slot. returns GearSlotIndex or -1 if unmapped. replaces item_to_gear_slot() in osrs_pvp_gear.h:820. */ @@ -110,9 +104,6 @@ static inline int osrs_item_gear_slot(uint8_t item_idx) { } } -/* ======================================================================== */ -/* equipment management */ -/* ======================================================================== */ /** equip item directly (not from inventory -- used for initial setup). places item in correct gear slot, no inventory interaction. diff --git a/ocean/osrs/osrs_items.h b/ocean/osrs/osrs_items.h index 002c21cba8..d258f432f5 100644 --- a/ocean/osrs/osrs_items.h +++ b/ocean/osrs/osrs_items.h @@ -15,10 +15,6 @@ #include #include -// ============================================================================ -// EQUIPMENT SLOTS -// ============================================================================ - typedef enum { SLOT_HEAD = 0, SLOT_CAPE = 1, @@ -46,10 +42,6 @@ typedef enum { OSRS_ITEM_EFFECT_ELYSIAN = 1u << 7, } OsrsItemEffectMask; -// ============================================================================ -// ITEM STRUCT -// ============================================================================ - typedef struct { uint16_t item_id; // Real OSRS item ID char name[32]; // Human-readable name @@ -72,17 +64,10 @@ typedef struct { int16_t prayer; uint32_t effect_mask; } Item; - -// ============================================================================ // ITEM DATABASE INDICES + STATIC DATABASE (auto-generated from equipment.json) -// ============================================================================ #include "osrs_items_generated.h" -// ============================================================================ -// LOOKUP TABLES -// ============================================================================ - // Max items per slot (inventory width for dynamic gear) #define MAX_ITEMS_PER_SLOT_DB 10 @@ -135,10 +120,6 @@ static const uint8_t NUM_ITEMS_IN_SLOT[NUM_EQUIPMENT_SLOTS] = { [SLOT_AMMO] = 3, // diamond bolts (e), dragon arrows, opal dragon bolts (e) }; -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - /** Get item from database by index. Returns NULL if invalid. */ static inline const Item* get_item(uint8_t item_index) { if (item_index >= NUM_ITEMS) return NULL; @@ -220,10 +201,7 @@ static inline int item_is_two_handed(uint8_t item_index) { return 0; } } - -// ============================================================================ // ITEM STATS EXTRACTION (for observations) -// ============================================================================ /** Normalization constants for item stats (max observed values in game). */ #define STAT_NORM_ATTACK 150.0f diff --git a/ocean/osrs/osrs_models.h b/ocean/osrs/osrs_models.h index 6233520c57..c0a4ddfb55 100644 --- a/ocean/osrs/osrs_models.h +++ b/ocean/osrs/osrs_models.h @@ -50,9 +50,6 @@ typedef struct { int count; } ModelCache; -/* ======================================================================== */ -/* loading */ -/* ======================================================================== */ static ModelCache* model_cache_load(const char* path) { FILE* f = fopen(path, "rb"); @@ -139,9 +136,6 @@ static ModelCache* model_cache_load(const char* path) { return cache; } -/* ======================================================================== */ -/* lookup */ -/* ======================================================================== */ static OsrsModel* model_cache_get(ModelCache* cache, uint32_t model_id) { if (!cache) return NULL; @@ -192,9 +186,6 @@ static int item_has_sleeves(uint16_t item_id) { return 0; } -/* ======================================================================== */ -/* cleanup */ -/* ======================================================================== */ static void model_cache_free(ModelCache* cache) { if (!cache) return; diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h index 93c0277afb..3eacde8e3d 100644 --- a/ocean/osrs/osrs_pvp_actions.h +++ b/ocean/osrs/osrs_pvp_actions.h @@ -20,10 +20,6 @@ #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 -// ============================================================================ // prayer drain: encounter_drain_all_prayers() in osrs_encounter.h drives both // overhead and offensive drain in a single call with activation-tick skip. @@ -31,11 +27,6 @@ // hardcoded because these are always equipped regardless of gear set. #define PRAYER_BONUS 6 - -// ============================================================================ -// CONSUMABLE ACTIONS -// ============================================================================ - /** * Eat food (regular or karambwan). * @@ -215,10 +206,6 @@ static void drink_potion(Player* p, int potion_type) { p->food_timer = 3; } -// ============================================================================ -// TIMER UPDATES -// ============================================================================ - /** Update all per-tick timers for a player. */ static void update_timers(Player* p) { p->damage_applied_this_tick = 0; @@ -294,10 +281,6 @@ static void reset_tick_flags(Player* p) { p->clicks_this_tick = 0; } -// ============================================================================ -// LOADOUT-BASED ACTION EXECUTION -// ============================================================================ - // 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); @@ -332,10 +315,6 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { p->consumable_used_this_tick = 0; - // ========================================================================= - // PHASE 1: OVERHEAD PRAYER - must happen first so attacks see new prayer - // ========================================================================= - int overhead_action = actions[HEAD_OVERHEAD]; int offensive_action = actions[HEAD_OFFENSIVE]; @@ -379,11 +358,6 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { p->offensive_prayer_just_activated = 1; } if (p->prayer != prev_prayer || p->offensive_prayer != prev_offensive) p->clicks_this_tick++; - - // ========================================================================= - // PHASE 2: LOADOUT SWITCH - equips dynamic gear slots, returns # changed - // ========================================================================= - int loadout_action = actions[HEAD_LOADOUT]; int loadout_switches = apply_loadout(p, loadout_action); p->clicks_this_tick += loadout_switches; @@ -395,18 +369,6 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { loadout_action == LOADOUT_SPEC_MAGIC || loadout_action == LOADOUT_GMAUL) { p->spec_armed = 1; } - - // ========================================================================= - // PHASE 3: OFFENSIVE PRAYER — agent-controlled via HEAD_OFFENSIVE, already - // applied in the overhead block above. auto-assignment based on loadout has - // been removed so the agent must manage offensive prayer like a real player - // (enabling prayer flicking). - // ========================================================================= - - // ========================================================================= - // PHASE 4: CONSUMABLES - eating delays attack timer - // ========================================================================= - int food_action = actions[HEAD_FOOD]; if (food_action == FOOD_EAT && can_eat_food(p)) { eat_food(p, 0); @@ -460,11 +422,6 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { p->clicks_this_tick++; osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EAT); } - - // ========================================================================= - // PHASE 5: MOVEMENT - // ========================================================================= - int combat_action = actions[HEAD_COMBAT]; int is_spec_loadout = (loadout_action == LOADOUT_SPEC_MELEE || loadout_action == LOADOUT_SPEC_RANGE || @@ -502,11 +459,6 @@ static void execute_switches(OsrsEnv* env, int agent_idx, int* actions) { } if (move_action != MOVE_NONE) osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_MOVE); - - // ========================================================================= - // PHASE 6: VENGEANCE - // ========================================================================= - int veng_action = actions[HEAD_VENG]; if (veng_action == VENG_CAST && p->is_lunar_spellbook && !p->veng_active && remaining_ticks(p->veng_cooldown) == 0 && @@ -771,10 +723,6 @@ static void execute_actions(OsrsEnv* env, int agent_idx, int* actions) { execute_attacks(env, agent_idx, actions); } -// ============================================================================ -// REWARD CALCULATION -// ============================================================================ - /** * Calculate reward for an agent. * @@ -848,10 +796,7 @@ static float calculate_reward(OsrsEnv* env, int agent_idx) { } } } - - // ========================================================================== // Per-tick reward shaping - // ========================================================================== float tick_shaping = 0.0f; // Damage dealt: reward aggression @@ -879,9 +824,6 @@ static float calculate_reward(OsrsEnv* env, int agent_idx) { } } - // NOTE: prayer switch penalty moved above !cfg->enabled gate (always-on). - // Not duplicated here to avoid double-counting when shaping is enabled. - // Off-prayer hit and offensive prayer checks: we attacked if (p->just_attacked) { if (!p->target_prayed_correct) { diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h index f9c3ee6166..ed4dbc35dd 100644 --- a/ocean/osrs/osrs_pvp_api.h +++ b/ocean/osrs/osrs_pvp_api.h @@ -20,10 +20,6 @@ #include "osrs_pvp_observations.h" #include "osrs_pvp_actions.h" -// ============================================================================ -// PLAYER INITIALIZATION -// ============================================================================ - /** * Initialize a player with default pure build stats and gear. * @@ -209,10 +205,6 @@ static void init_player(Player* p) { p->prev_hp_percent = 1.0f; // Full HP at start } -// ============================================================================ -// FIGHT POSITIONING -// ============================================================================ - /** * Set initial fight positions for both players. * @@ -271,10 +263,6 @@ static void set_fight_positions(OsrsEnv* env) { env->players[1].is_moving = 0; } -// ============================================================================ -// PUBLIC API -// ============================================================================ - /** * Initialize internal buffer pointers for ocean pattern. * @@ -373,8 +361,6 @@ void pvp_reset(OsrsEnv* env) { } env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks - // NOTE: is_lms is NOT reset here - it's controlled by set_lms() from Python - // env->is_lms = 0; env->pvp_runtime.is_pvp_arena = 0; env->_episode_return = 0.0f; @@ -382,9 +368,6 @@ void pvp_reset(OsrsEnv* env) { memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); memset(env->terminals, 0, NUM_AGENTS); - // Clear action buffers. With immediate application, pending is not used - // for timing - actions are applied in the same step they're input. - // This gives OSRS-correct 1-tick delay: action at tick N → effects at tick N+1. memset(env->pending_actions, 0, sizeof(env->pending_actions)); memset(env->last_executed_actions, 0, sizeof(env->last_executed_actions)); @@ -465,10 +448,6 @@ void pvp_step(OsrsEnv* env) { reset_tick_flags(&env->players[0]); reset_tick_flags(&env->players[1]); - // ======================================================================== - // PHASE 1: Gather actions from all sources into env->actions - // ======================================================================== - // Copy model's actions (player 0) or clear if C opponent controls p0 if (env->pvp_runtime.use_c_opponent_p0) { memset(env->actions, 0, NUM_ACTION_HEADS * sizeof(int)); @@ -510,10 +489,6 @@ void pvp_step(OsrsEnv* env) { int first = env->pid_holder; int second = 1 - env->pid_holder; - // ======================================================================== - // PHASE 2: Apply actions IMMEDIATELY (not pending from previous step) - // ======================================================================== - // Copy actions to local arrays for each agent int actions_p0[NUM_ACTION_HEADS]; int actions_p1[NUM_ACTION_HEADS]; @@ -645,10 +620,6 @@ void pvp_step(OsrsEnv* env) { if (env->players[1].veng_active) { env->players[0].observed_target_lunar_spellbook = 1; } - - // ======================================================================== - // PHASE 3: Increment tick - // ======================================================================== env->tick++; if (!env->has_rng_seed) { @@ -659,13 +630,8 @@ void pvp_step(OsrsEnv* env) { } } - // Keep pending in sync for compatibility (not used for timing anymore) memcpy(env->pending_actions, env->actions, NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); - - // ======================================================================== - // PHASE 4: Check win conditions - // ======================================================================== for (int i = 0; i < NUM_AGENTS; i++) { if (env->players[i].current_hitpoints <= 0) { env->episode_over = 1; @@ -678,10 +644,6 @@ void pvp_step(OsrsEnv* env) { env->episode_over = 1; env->winner = 1; } - - // ======================================================================== - // PHASE 5: Calculate rewards - // ======================================================================== for (int i = 0; i < NUM_AGENTS; i++) { env->rewards[i] = calculate_reward(env, i); @@ -692,10 +654,6 @@ void pvp_step(OsrsEnv* env) { // Accumulate agent 0's episode return (written to log at episode end) env->_episode_return += env->rewards[0]; - - // ======================================================================== - // PHASE 6: Generate observations (current state, BEFORE new actions apply) - // ======================================================================== for (int i = 0; i < NUM_AGENTS; i++) { generate_slot_observations(env, i); if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { @@ -703,9 +661,6 @@ void pvp_step(OsrsEnv* env) { } } - // NOTE: reset_tick_flags() moved to START of pvp_step() so flags survive - // for get_state() to read after step returns - // Write observations to PufferLib shared buffer ocean_write_obs(env); if (env->ocean_io.agent_obs_p1 != NULL) { diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h index 1fa3353d07..063f8e77ed 100644 --- a/ocean/osrs/osrs_pvp_combat.h +++ b/ocean/osrs/osrs_pvp_combat.h @@ -26,18 +26,9 @@ #include "osrs_bolt_procs.h" #include "osrs_pvp_gear.h" - -// ============================================================================ -// FORWARD DECLARATIONS -// ============================================================================ - static void register_hit_calculated(OsrsEnv* env, int attacker_idx, int defender_idx, AttackStyle style, int total_damage); -// ============================================================================ -// SPEC WEAPON ENUM-TO-ITEM MAPPING -// ============================================================================ - /* 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) { @@ -75,11 +66,6 @@ static inline int pvp_magic_spec_to_item(MagicSpecWeapon w) { default: return ITEM_NONE; } } - -// ============================================================================ -// SPEC WEAPON COSTS (kept for osrs_pvp_observations.h compatibility) -// ============================================================================ - static int get_melee_spec_cost(MeleeSpecWeapon weapon) { switch (weapon) { case MELEE_SPEC_AGS: return 50; @@ -120,11 +106,6 @@ static int get_magic_spec_cost(MagicSpecWeapon weapon) { default: return 50; } } - -// ============================================================================ -// SPEC WEAPON MULTIPLIERS (kept for osrs_pvp_observations.h compatibility) -// ============================================================================ - static float get_melee_spec_str_mult(MeleeSpecWeapon weapon) { switch (weapon) { case MELEE_SPEC_AGS: return 1.375f; @@ -200,10 +181,6 @@ static float get_magic_spec_acc_mult(MagicSpecWeapon weapon) { } } -// ============================================================================ -// PRAYER MULTIPLIERS -// ============================================================================ - static inline float get_defence_prayer_mult(Player* p) { switch (p->offensive_prayer) { case OFFENSIVE_PRAYER_MELEE_LOW: @@ -218,10 +195,7 @@ static inline float get_defence_prayer_mult(Player* p) { return 1.0f; } } - -// ============================================================================ // EFFECTIVE LEVEL ADAPTERS (delegate to osrs_player_eff_level) -// ============================================================================ static int calculate_effective_attack(Player* p, AttackStyle style) { int base_level; @@ -303,10 +277,6 @@ static int calculate_effective_defence(Player* p, AttackStyle incoming_style) { return osrs_player_eff_level(base_level, prayer_mult, style_bonus); } -// ============================================================================ -// ATTACK/DEFENCE BONUS LOOKUPS -// ============================================================================ - static MeleeBonusType get_melee_bonus_type(Player* p) { if (p->current_gear == GEAR_SPEC) { return MELEE_SPEC_BONUS_TYPES[p->melee_spec_weapon]; @@ -369,10 +339,7 @@ static int get_strength_bonus(Player* p, AttackStyle style) { 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) { @@ -414,10 +381,6 @@ static int calculate_max_hit(Player* p, AttackStyle style, float str_mult, int m return max_hit; } -// ============================================================================ -// MAGIC SPELL HELPERS -// ============================================================================ - static inline int get_ice_freeze_ticks(int current_magic) { if (current_magic >= ICE_BARRAGE_LEVEL) return 32; if (current_magic >= ICE_BLITZ_LEVEL) return 24; @@ -446,10 +409,6 @@ static inline int get_blood_heal_percent(int current_magic) { return 10; } -// ============================================================================ -// PVP HIT DELAY HELPERS -// ============================================================================ - /* 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 @@ -488,10 +447,6 @@ static inline int pvp_ranged_hit_delay_for_weapon(int distance, int is_special, } } -// ============================================================================ -// HIT QUEUE -// ============================================================================ - static void queue_hit(Player* attacker, Player* defender, int damage, AttackStyle style, int delay, int is_special, int hit_success, int freeze_ticks, int heal_percent, int drain_type, int drain_percent, @@ -515,10 +470,7 @@ static void queue_hit(Player* attacker, Player* defender, int damage, 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) { @@ -645,10 +597,6 @@ static void process_pending_hits(OsrsEnv* env, int attacker_idx, int defender_id } } -// ============================================================================ -// HIT STATISTICS TRACKING -// ============================================================================ - static inline void push_recent_attack(AttackStyle* buffer, int* index, AttackStyle style) { buffer[*index] = style; *index = (*index + 1) % HISTORY_SIZE; @@ -769,10 +717,6 @@ static void register_hit_calculated( } } -// ============================================================================ -// ATTACK AVAILABILITY CHECKS -// ============================================================================ - 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; @@ -923,10 +867,6 @@ static inline int get_ticks_until_next_hit(Player* p) { return min_ticks; } -// ============================================================================ -// WEAPON RANGE -// ============================================================================ - typedef enum { WEAPON_TYPE_STANDARD = 0, WEAPON_TYPE_HALBERD @@ -949,10 +889,7 @@ static inline int get_attack_range(Player* p, AttackStyle style) { 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) { diff --git a/ocean/osrs/osrs_pvp_effects.h b/ocean/osrs/osrs_pvp_effects.h index 82789478da..0b48c9ff5c 100644 --- a/ocean/osrs/osrs_pvp_effects.h +++ b/ocean/osrs/osrs_pvp_effects.h @@ -19,9 +19,6 @@ #define MAX_ACTIVE_EFFECTS 16 -/* ======================================================================== */ -/* spotanim metadata (hardcoded for the effects we care about) */ -/* ======================================================================== */ /* GFX IDs from spotanim.dat */ #define GFX_BOLT 27 @@ -42,9 +39,6 @@ #define GFX_DRAGON_DART 1122 /* dragon dart projectile (blowpipe) */ #define GFX_RUNE_DART 231 /* rune dart projectile */ #define GFX_BLOWPIPE_SPEC 1043 /* blowpipe special attack effect */ -/* TODO: add voidwaker lightning on-hit GFX (spotanim on opponent). - * TODO: add VLS special attack on-hit effect. - * combat mechanics for both work correctly, just missing visual effects. */ typedef struct { int gfx_id; @@ -83,9 +77,6 @@ static const SpotAnimMeta* spotanim_lookup(int gfx_id) { return NULL; } -/* ======================================================================== */ -/* effect types */ -/* ======================================================================== */ typedef enum { EFFECT_NONE = 0, @@ -129,9 +120,6 @@ typedef struct { int tilt_angle; } ActiveEffect; -/* ======================================================================== */ -/* internal helpers */ -/* ======================================================================== */ /** Free an effect's animation state and mark it inactive. */ static void effect_free(ActiveEffect* e) { @@ -173,9 +161,6 @@ static void effect_init_anim_state( om->vertex_skins, om->base_vert_count); } -/* ======================================================================== */ -/* effect lifecycle */ -/* ======================================================================== */ /** * Spawn a spotanim effect at a world position (impact splash, etc). diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h index bd73aea9ec..c30105401c 100644 --- a/ocean/osrs/osrs_pvp_gear.h +++ b/ocean/osrs/osrs_pvp_gear.h @@ -15,10 +15,6 @@ #include "osrs_combat.h" #include "osrs_item_effects.h" -// ============================================================================ -// MELEE SPEC WEAPON BONUS TYPES -// ============================================================================ - static const MeleeBonusType MELEE_SPEC_BONUS_TYPES[] = { [MELEE_SPEC_NONE] = MELEE_BONUS_SLASH, [MELEE_SPEC_AGS] = MELEE_BONUS_SLASH, @@ -37,10 +33,7 @@ static const MeleeBonusType MELEE_SPEC_BONUS_TYPES[] = { [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, @@ -59,10 +52,6 @@ static const uint8_t MAGE_WEAPON_PRIORITY[] = { }; #define MAGE_WEAPON_PRIORITY_LEN 5 -// ============================================================================ -// SPEC WEAPON PRIORITY TABLES -// ============================================================================ - static const uint8_t MELEE_SPEC_PRIORITY[] = { ITEM_VESTAS, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_DRAGON_CLAWS, ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_DAGGER @@ -80,10 +69,7 @@ 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[] = { @@ -154,10 +140,7 @@ static const uint8_t MELEE_RING_PRIORITY[] = {ITEM_BERSERKER_RING}; 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. @@ -176,10 +159,6 @@ static inline GearBonuses* get_slot_gear_bonuses(Player* p) { return &p->slot_cached_bonuses; } -// ============================================================================ -// SPEC WEAPON MAPPING -// ============================================================================ - /** Set spec weapon enums based on equipped weapon. */ static inline void update_spec_weapons_for_weapon(Player* p, uint8_t weapon_item) { p->melee_spec_weapon = MELEE_SPEC_NONE; @@ -246,10 +225,6 @@ static inline int item_is_spec_weapon(uint8_t weapon_item) { } } -// ============================================================================ -// EQUIP AND GEAR DETECTION -// ============================================================================ - /** * Equip item in slot-based mode. * Returns 1 if equipment changed, 0 if already equipped. @@ -303,10 +278,6 @@ static inline int slot_equip_item(Player* p, int gear_slot, uint8_t item_idx) { return 1; } -// ============================================================================ -// INVENTORY SEARCH HELPERS -// ============================================================================ - /** Check if player has an item in the given slot's inventory. */ static inline int player_has_item_in_slot(Player* p, int gear_slot, uint8_t item_idx) { for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { @@ -351,10 +322,6 @@ static inline int player_has_gmaul(Player* p) { return player_has_item_in_slot(p, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); } -// ============================================================================ -// DYNAMIC LOADOUT RESOLUTION -// ============================================================================ - /** * Resolve loadout for a given style from available inventory. * @@ -577,10 +544,6 @@ static inline int get_current_loadout(Player* p) { return 0; } -// ============================================================================ -// LOADOUT-TO-GEAR MAPPING -// ============================================================================ - /** Visible GearSet for each loadout (actual damage type, no GEAR_SPEC). */ static inline GearSet loadout_to_gear_set(int loadout) { switch (loadout) { @@ -596,10 +559,6 @@ static inline GearSet loadout_to_gear_set(int loadout) { } } -// ============================================================================ -// EQUIPMENT INIT -// ============================================================================ - /** Get attack style for currently equipped weapon. */ static inline AttackStyle get_slot_weapon_attack_style(Player* p) { uint8_t weapon = p->equipped[GEAR_SLOT_WEAPON]; @@ -702,10 +661,6 @@ static inline int add_item_to_inventory(Player* p, int gear_slot, uint8_t item_i return 1; } -// ============================================================================ -// UPGRADE REPLACEMENT TABLE -// ============================================================================ - // Maps each loot item to the basic item it replaces (ITEM_NONE = doesn't replace) static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { [ITEM_HELM_NEITIZNOT] = ITEM_NONE, @@ -795,10 +750,7 @@ static inline int remove_item_from_inventory(Player* p, int gear_slot, uint8_t i 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. @@ -932,10 +884,7 @@ static inline void add_loot_item(Player* p, uint8_t item_idx) { } } - -// ============================================================================ // 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 @@ -957,10 +906,6 @@ static inline int compute_food_count(Player* p) { return food > 1 ? food : 1; } -// ============================================================================ -// GEAR TIER RANDOMIZATION -// ============================================================================ - // Loot tables for gear tiers (items that can drop from LMS chests) // Each chest gives 2 rolls from the same combined pool static const uint8_t CHEST_LOOT[] = { @@ -1078,7 +1023,7 @@ static inline int sample_gear_tier(float weights[4], uint32_t* rng) { cumulative += weights[i]; if (r < cumulative) return i; } - return 0; // Fallback to tier 0 + return 0; } #endif // OSRS_PVP_GEAR_H diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h index acbffa53cf..570eda4410 100644 --- a/ocean/osrs/osrs_pvp_observations.h +++ b/ocean/osrs/osrs_pvp_observations.h @@ -212,10 +212,7 @@ static inline int can_move_diagonal(Player* p, Player* target, const CollisionMa } 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; @@ -332,10 +329,6 @@ static void ocean_write_obs_p1(OsrsEnv* env) { } } -// ============================================================================ -// SLOT-BASED MODE - OBSERVATION GENERATION -// ============================================================================ - /** * Generate slot-mode observations with per-slot item stats. * diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h index 2693169d60..14055157ea 100644 --- a/ocean/osrs/osrs_pvp_opponents.h +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -465,12 +465,8 @@ static inline int opp_toggle_for_prayer(OverheadPrayer p) { } } -/* convert an OverheadAction intent (legacy set-semantic: MAGE/RANGED/MELEE/NONE/etc) - into a toggle action given the opponent's current prayer state. if already - on target, emits NO_CHANGE (no-op). if target is NONE, emits the toggle - matching the currently-active prayer to deactivate it. all opponent-AI - prayer emissions go through this so the new ENCOUNTER_OVERHEAD_TOGGLE_* - encoding is respected. */ +/* 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) { @@ -2259,7 +2255,7 @@ static void opp_unpredictable_improved(OsrsEnv* env, OpponentState* opp, int* ac 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; @@ -2460,10 +2456,10 @@ static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* act } 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; diff --git a/ocean/osrs/osrs_render.h b/ocean/osrs/osrs_render.h index e4057ced60..13882d9bbc 100644 --- a/ocean/osrs/osrs_render.h +++ b/ocean/osrs/osrs_render.h @@ -29,9 +29,6 @@ #include #include -/* ======================================================================== */ -/* constants */ -/* ======================================================================== */ #define RENDER_TILE_SIZE 20 /* window sized at 1.5x the OSRS fixed-client layout (765x503 → 1148x755) @@ -71,9 +68,6 @@ #define COLOR_TEXT_DIM CLITERAL(Color){ 130, 130, 140, 255 } #define COLOR_LABEL CLITERAL(Color){ 170, 170, 180, 255 } -/* ======================================================================== */ -/* active projectile flights (sub-tick interpolation at 50 Hz) */ -/* ======================================================================== */ /* OSRS projectile flight parameters (from deob client Projectile.java): * x/y: linear interpolation from source to target @@ -122,9 +116,6 @@ typedef struct { int impact_gfx_id; /* landing spotanim to spawn on arrival */ } FlightProjectile; -/* ======================================================================== */ -/* per-player composite model */ -/* ======================================================================== */ /* OSRS composites all body parts + equipment into a single merged model * before animating. this ensures vertex skin label groups span the full @@ -158,9 +149,6 @@ typedef struct { int needs_rebuild; } PlayerComposite; -/* ======================================================================== */ -/* convex hull click detection (ported from RuneLite Jarvis.java) */ -/* ======================================================================== */ #define HULL_MAX_POINTS 256 /* max hull vertices (models rarely exceed 100) */ @@ -231,9 +219,6 @@ static int hull_contains(const ConvexHull2D* hull, int px, int py) { return inside; } -/* ======================================================================== */ -/* render client */ -/* ======================================================================== */ /* per-entity hitsplat slot matching OSRS Entity.java exactly: - hitmarkMove starts at +5.0, decreases by 0.25/client-tick, clamps at -5.0 @@ -248,9 +233,6 @@ typedef struct { int ticks_remaining; /* counts down from 70 client ticks */ } HitSplat; -/* ======================================================================== */ -/* right-click context menu (OSRS-style) */ -/* ======================================================================== */ #define CONTEXT_MENU_MAX_ITEMS 8 #define CONTEXT_MENU_ROW_H 15 @@ -489,9 +471,6 @@ static AnimFrameBase* render_get_framebase(RenderClient* rc, uint16_t base_id) { return fb; } -/* ======================================================================== */ -/* coordinate helpers */ -/* ======================================================================== */ static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x) { return (world_x - rc->arena_base_x) * RENDER_TILE_SIZE; @@ -504,7 +483,6 @@ static inline int render_world_to_screen_y_rc(RenderClient* rc, int world_y) { return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; } -/* legacy wrappers using default FIGHT_AREA bounds */ static inline int render_world_to_screen_x(int world_x) { return (world_x - FIGHT_AREA_BASE_X) * RENDER_TILE_SIZE; } @@ -522,9 +500,6 @@ 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); -/* ======================================================================== */ -/* right-click context menu helpers */ -/* ======================================================================== */ /** Resolve display name for a render entity (NPC or player). Uses the same lookup chain as render_draw_panel_npc: zulrah forms, @@ -800,9 +775,6 @@ static void context_menu_draw(RenderClient* rc) { } } -/* ======================================================================== */ -/* lifecycle */ -/* ======================================================================== */ static RenderClient* render_make_client(void) { RenderClient* rc = (RenderClient*)calloc(1, sizeof(RenderClient)); @@ -1034,9 +1006,6 @@ static void render_init_overlay_models(RenderClient* rc) { if (rc->cloud_proj_model_ready) printf("overlay: cloud projectile model loaded\n"); } -/* ======================================================================== */ -/* projectile flight system */ -/* ======================================================================== */ /** * Spawn a flight projectile with OSRS-accurate parabolic arc and target tracking. @@ -1353,9 +1322,6 @@ static void render_destroy_client(RenderClient* rc) { free(rc); } -/* ======================================================================== */ -/* input */ -/* ======================================================================== */ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { RenderHumanAttackCtx attack_ctx = { .rc = rc, .env = env }; @@ -1715,9 +1681,6 @@ static void render_handle_input(RenderClient* rc, OsrsEnv* env) { } } -/* ======================================================================== */ -/* rewind history */ -/* ======================================================================== */ /* save current env state to history ring buffer (call after each pvp_step) */ static void render_save_snapshot(RenderClient* rc, OsrsEnv* env) { @@ -1758,11 +1721,8 @@ static void render_clear_history(RenderClient* rc) { /* forward declaration: render_push_splat used by render_post_tick, defined later */ static void render_push_splat(RenderClient* rc, int damage, int pidx); -/* ======================================================================== */ -/* entity population */ -/* ======================================================================== */ -/* populate rc->entities from env->players (legacy) or encounter vtable. +/* 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) { @@ -1777,21 +1737,7 @@ static void render_populate_entities(RenderClient* rc, OsrsEnv* env) { for (int zi = 0; zi < count; zi++) { if (rc->entities[zi].npc_def_id == 7706) { rc->zuk_active = 1; break; } } - /* debug: print entity info on first populate */ - static int debug_once = 1; - if (debug_once && count > 0) { - debug_once = 0; - fprintf(stderr, "render_populate: %d entities\n", count); - for (int di = 0; di < count && di < 5; di++) { - fprintf(stderr, " [%d] type=%d npc_id=%d visible=%d size=%d pos=(%d,%d) hp=%d/%d\n", - di, rc->entities[di].entity_type, rc->entities[di].npc_def_id, - rc->entities[di].npc_visible, rc->entities[di].npc_size, - rc->entities[di].x, rc->entities[di].y, - rc->entities[di].current_hitpoints, rc->entities[di].base_hitpoints); - } - } } else { - /* legacy fallback: cast get_entity to Player* */ int count = def->get_entity_count(env->encounter_state); if (count > MAX_RENDER_ENTITIES) count = MAX_RENDER_ENTITIES; rc->entity_count = count; @@ -1815,9 +1761,6 @@ static void render_populate_entities(RenderClient* rc, OsrsEnv* env) { } } -/* ======================================================================== */ -/* tick notification: position tracking, facing, effects */ -/* ======================================================================== */ /** * Call BEFORE pvp_step to record pre-tick positions for movement direction. @@ -2377,9 +2320,6 @@ static void render_get_visual_pos( } } -/* ======================================================================== */ -/* hit splats */ -/* ======================================================================== */ /* advance splat animation by one client tick (20ms). exact OSRS logic from Client.java:6107-6143 (mode 2 animated): @@ -2430,9 +2370,6 @@ static void render_push_splat(RenderClient* rc, int damage, int pidx) { } -/* ======================================================================== */ -/* drawing: grid */ -/* ======================================================================== */ static void render_draw_grid(RenderClient* rc, OsrsEnv* env) { const CollisionMap* cmap = rc->collision_map; @@ -2583,9 +2520,6 @@ static void render_draw_grid(RenderClient* rc, OsrsEnv* env) { } } -/* ======================================================================== */ -/* drawing: players */ -/* ======================================================================== */ static const char* render_prayer_label(OverheadPrayer p) { switch (p) { @@ -2721,9 +2655,6 @@ static void render_draw_players(RenderClient* rc) { } } -/* ======================================================================== */ -/* drawing: destination markers */ -/* ======================================================================== */ static void render_draw_dest_markers(RenderClient* rc) { int ts = RENDER_TILE_SIZE; @@ -2739,9 +2670,6 @@ static void render_draw_dest_markers(RenderClient* rc) { } } -/* ======================================================================== */ -/* drawing: splats */ -/* ======================================================================== */ /* draw a hitsplat using the actual cache sprites (317 mode 0). Client.java:6052-6073: hitMarks[type].drawSprite(spriteDrawX - 12, spriteDrawY - 12) @@ -2806,9 +2734,6 @@ static void render_draw_splats_2d(RenderClient* rc) { } } -/* ======================================================================== */ -/* drawing: header */ -/* ======================================================================== */ static void render_draw_header(RenderClient* rc, OsrsEnv* env) { DrawRectangle(0, 0, RENDER_WINDOW_W, RENDER_HEADER_HEIGHT, COLOR_HEADER_BG); @@ -2851,9 +2776,6 @@ static void render_draw_header(RenderClient* rc, OsrsEnv* env) { } } -/* ======================================================================== */ -/* drawing: NPC/boss info panel (below GUI tabs) */ -/* ======================================================================== */ /** Look up inferno NPC name from npc_def_id. returns NULL if not an inferno NPC. */ static const char* inferno_npc_name(int npc_def_id) { @@ -2962,9 +2884,6 @@ static void render_draw_panel_npc(int x, int y, RenderEntity* p, OsrsEnv* env) { /* render_draw_panel removed — replaced by gui_draw() in osrs_pvp_gui.h */ -/* ======================================================================== */ -/* drawing: 3D world mode */ -/* ======================================================================== */ static Camera3D render_build_3d_camera(RenderClient* rc) { Camera3D cam = { 0 }; @@ -2986,9 +2905,6 @@ static Camera3D render_build_3d_camera(RenderClient* rc) { return cam; } -/* ======================================================================== */ -/* animation selection */ -/* ======================================================================== */ /* animation sequence IDs (from OSRS 317 cache via export_animations.py) */ #define ANIM_SEQ_IDLE 808 @@ -3095,9 +3011,6 @@ static int render_select_secondary(RenderClient* rc, int player_idx) { return ANIM_SEQ_WALK; } -/* ======================================================================== */ -/* composite model building */ -/* ======================================================================== */ /** * Append a single OsrsModel's geometry into the player composite. @@ -3410,9 +3323,6 @@ static void composite_free(PlayerComposite* comp) { comp->anim_state = NULL; } -/* ======================================================================== */ -/* per-player animation + composite orchestration */ -/* ======================================================================== */ /** * Rebuild composite if equipment changed, run two-track animation, draw. @@ -4205,9 +4115,6 @@ static void render_draw_3d_world(RenderClient* rc) { EndMode3D(); } -/* ======================================================================== */ -/* drawing: 2D overlay models (for 2D mode) */ -/* ======================================================================== */ static void render_draw_models_2d_overlay(RenderClient* rc) { if (!rc->model_cache) return; @@ -4262,9 +4169,6 @@ static void render_draw_models_2d_overlay(RenderClient* rc) { EndMode3D(); } -/* ======================================================================== */ -/* overhead status: prayer icons + HP bar (2D overlay on 3D scene) */ -/* ======================================================================== */ /** * Draw overhead prayer icons and HP bars above players in 3D mode. @@ -4438,9 +4342,6 @@ static void render_draw_overhead_status(RenderClient* rc, OsrsEnv* env) { } } -/* ======================================================================== */ -/* main render entry point */ -/* ======================================================================== */ void pvp_render(OsrsEnv* env) { RenderClient* rc = (RenderClient*)env->client; diff --git a/ocean/osrs/osrs_special_attacks.h b/ocean/osrs/osrs_special_attacks.h index 11623feedb..eb907128ff 100644 --- a/ocean/osrs/osrs_special_attacks.h +++ b/ocean/osrs/osrs_special_attacks.h @@ -8,7 +8,7 @@ * 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(...) legacy standalone blowpipe spec + * osrs_blowpipe_spec_resolve(...) blowpipe spec helper * * ref: .refs/osrs-dps-calc/src/lib/ for multipliers, * .refs/osrs-sdk/src/weapons/ for behavior, @@ -22,9 +22,6 @@ #include "osrs_combat.h" #include "osrs_items.h" -/* ======================================================================== */ -/* blowpipe spec constants (moved from osrs_combat.h) */ -/* ======================================================================== */ #define BLOWPIPE_SPEC_ACC_MULT 2 #define BLOWPIPE_SPEC_DMG_NUM 3 /* 1.5x = 3/2 */ @@ -32,8 +29,7 @@ #define BLOWPIPE_SPEC_HEAL_PCT 50 #define BLOWPIPE_SPEC_COST 50 -/* legacy standalone blowpipe spec (moved from osrs_combat.h). - prefer osrs_resolve_spec(ITEM_TOXIC_BLOWPIPE, ...) for new code. */ +/* 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, @@ -47,9 +43,6 @@ static inline int osrs_blowpipe_spec_resolve( return 0; } -/* ======================================================================== */ -/* SpecResult: shared result struct for all special attacks */ -/* ======================================================================== */ typedef struct { int num_hits; /* number of hits (1-4) */ @@ -64,9 +57,6 @@ typedef struct { int attack_speed_override; /* 0 = use weapon speed, >0 = override */ } SpecResult; -/* ======================================================================== */ -/* osrs_spec_cost: energy cost by weapon item index */ -/* ======================================================================== */ static inline int osrs_spec_cost(int weapon_item_idx) { switch (weapon_item_idx) { diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 8790642c6d..9ccb35f7aa 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -101,10 +101,6 @@ #include #include "osrs_interaction.h" -// ============================================================================ -// ENVIRONMENT CONSTANTS -// ============================================================================ - #define NUM_AGENTS 2 #define MAX_PENDING_HITS 8 #define HISTORY_SIZE 5 @@ -112,10 +108,6 @@ #define TICK_DURATION_MS 600 #define MAX_EPISODE_TICKS 300 -// ============================================================================ -// WILDERNESS AREA BOUNDS -// ============================================================================ - #define WILD_MIN_X 2940 #define WILD_MAX_X 3392 #define WILD_MIN_Y 3525 @@ -126,20 +118,12 @@ #define FIGHT_AREA_HEIGHT 28 #define FIGHT_NEARBY_RADIUS 5 -// ============================================================================ -// GAMEPLAY FLAGS -// ============================================================================ - #define ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK 1 #define ONLY_SWITCH_GEAR_WHEN_ATTACK_SOON 1 #define ALLOW_SMITE 1 #define ALLOW_REDEMPTION 1 #define ALLOW_MOVING_IF_CAN_ATTACK 0 -// ============================================================================ -// MAGIC SPELL LEVELS AND DAMAGE -// ============================================================================ - #define ICE_RUSH_LEVEL 58 #define ICE_BURST_LEVEL 70 #define ICE_BLITZ_LEVEL 82 @@ -162,16 +146,8 @@ #define ATTACK_TIMER_INACTIVE -1000000 -// ============================================================================ -// EQUIPMENT SLOTS -// ============================================================================ - // Number of equipment slots (HEAD, CAPE, NECK, AMMO, WEAPON, SHIELD, BODY, LEGS, HANDS, FEET, RING) #define NUM_GEAR_SLOTS 11 - -// ============================================================================ -// CURRENT LOADOUT-BASED ACTION SPACE -// ============================================================================ // 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. @@ -225,10 +201,7 @@ static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { //* 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 @@ -251,10 +224,6 @@ static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { #define MAXED_RANGED_ATTACK_SPEED_OBS 5 #define RUN_ENERGY_RECOVER_TICKS 3 -// ============================================================================ -// CORE ENUMS -// ============================================================================ - typedef enum { ATTACK_STYLE_NONE = 0, ATTACK_STYLE_MELEE, @@ -300,8 +269,7 @@ typedef enum { - 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 come first for backward compatibility with existing code - that cast `0` to mean ACCURATE. */ + 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. */ @@ -319,10 +287,6 @@ typedef enum { MELEE_BONUS_CRUSH } MeleeBonusType; -// ============================================================================ -// SPECIAL ATTACK WEAPON ENUMS -// ============================================================================ - typedef enum { MELEE_SPEC_NONE = 0, MELEE_SPEC_AGS, @@ -358,10 +322,6 @@ typedef enum { MAGIC_SPEC_VOLATILE_STAFF } MagicSpecWeapon; -// ============================================================================ -// LOADOUT-BASED ACTION ENUMS -// ============================================================================ - /** Equipment slot indices. */ typedef enum { GEAR_SLOT_HEAD = 0, @@ -463,10 +423,6 @@ typedef enum { VENG_CAST, } VengAction; -// ============================================================================ -// GEAR BONUS STRUCTS -// ============================================================================ - /* 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() @@ -501,10 +457,6 @@ typedef struct { int melee_defence; } VisibleGearBonuses; -// ============================================================================ -// COMBAT STRUCTS -// ============================================================================ - typedef struct { int damage; int ticks_until_hit; @@ -519,10 +471,7 @@ typedef struct { 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, @@ -579,10 +528,6 @@ typedef struct { OsrsTargetRef confliction_target; } OsrsItemEffectState; -// ============================================================================ -// PLAYER / ENTITY STRUCT -// ============================================================================ - typedef struct { EntityType entity_type; /* ENTITY_PLAYER or ENTITY_NPC */ int npc_def_id; /* NPC definition ID (unused for players) */ @@ -834,10 +779,6 @@ typedef struct { int gui_strength_bonus; } Player; -// ============================================================================ -// LOGGING STRUCT -// ============================================================================ - typedef struct { float episode_return; float episode_length; @@ -865,7 +806,7 @@ typedef struct { 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 */ - /* action noop rates per head (0=move,1=prayer,2=target,3=gear,4=eat,5=pot,6=spell,7=spec) */ + /* Inferno action noop rates by named head. */ float noop_move; float noop_prayer; float noop_target; @@ -883,10 +824,6 @@ typedef struct { float n; } Log; -// ============================================================================ -// REWARD SHAPING CONFIG -// ============================================================================ - typedef struct { // Per-tick shaping coefficients float damage_dealt_coef; // per-HP dealt @@ -898,7 +835,7 @@ typedef struct { float melee_frozen_penalty; // melee while frozen and out of range float wasted_eat_penalty; // per wasted HP of healing overflow float premature_eat_penalty; // eating above premature threshold - float magic_no_staff_penalty; // casting magic without staff (deprecated, use gear_mismatch) + float 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 @@ -920,10 +857,7 @@ typedef struct { 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, @@ -1049,10 +983,6 @@ typedef struct { // Combined observation size: raw obs + action masks (for ocean mode) #define OCEAN_OBS_SIZE (SLOT_NUM_OBSERVATIONS + ACTION_MASK_SIZE) -// ============================================================================ -// MAIN ENVIRONMENT STRUCT -// ============================================================================ - typedef struct { Log log; @@ -1090,8 +1020,7 @@ typedef struct { // PvP-only runtime state. encounters that bypass the PvP stack can ignore this. OsrsPvpRuntime pvp_runtime; - // Encounter dispatch (NULL = legacy step/reset path for backwards compat). - // When set, c_step/c_reset dispatch through these instead of the default path. + // 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 */ @@ -1114,10 +1043,6 @@ typedef struct { } OsrsEnv; -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - static inline int abs_int(int val) { return val < 0 ? -val : val; } @@ -1168,10 +1093,6 @@ static inline int is_in_melee_range(Player* p, Player* t) { return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); } -// ============================================================================ -// RNG FUNCTIONS -// ============================================================================ - static inline uint32_t xorshift32(uint32_t* state) { uint32_t x = *state; x ^= x << 13; @@ -1198,10 +1119,6 @@ static inline int tile_hash(int x, int y) { return (x << 15) | y; } -// ============================================================================ -// TIMER HELPERS -// ============================================================================ - static inline int remaining_ticks(int ticks) { return ticks > 0 ? ticks : 0; } diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py index e5dbc6c7f6..f4aaf232cd 100644 --- a/ocean/osrs/scripts/export_models.py +++ b/ocean/osrs/scripts/export_models.py @@ -97,7 +97,7 @@ def decode_identity_kits_modern(reader: ModernCacheReader) -> dict[int, Identity for kit_id in sorted(file_ids): try: data = reader.read_config_entry(MODERN_CONFIG_IDK_GROUP, kit_id) - except Exception: + except (KeyError, FileNotFoundError): continue kit = IdentityKitDef(kit_id=kit_id) diff --git a/ocean/osrs/scripts/export_sprites_modern.py b/ocean/osrs/scripts/export_sprites_modern.py index 115e4d72e2..9fcc3f1c9d 100644 --- a/ocean/osrs/scripts/export_sprites_modern.py +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -335,7 +335,7 @@ def main() -> None: try: frames = decode_sprites(sprite_id, data) - except Exception as e: + except (IndexError, struct.error, ValueError) as e: print(f" sprite {sprite_id}: decode error: {e}", file=sys.stderr) failed += 1 continue diff --git a/ocean/osrs/scripts/export_terrain.py b/ocean/osrs/scripts/export_terrain.py index cdf9355812..5868adf6bc 100644 --- a/ocean/osrs/scripts/export_terrain.py +++ b/ocean/osrs/scripts/export_terrain.py @@ -693,10 +693,6 @@ def build_terrain_mesh( verts.extend([wx, y_sw, nz, wx + 1, y_ne, nz - 1, wx, y_nw, nz - 1]) colors.extend([r, g, b, 255] * 3) - else: - # empty tile (no underlay or overlay) — skip entirely - pass - return verts, colors diff --git a/ocean/osrs/tests/test_bolt_procs.c b/ocean/osrs/tests/test_bolt_procs.c index a51d703d01..0b14dd10f1 100644 --- a/ocean/osrs/tests/test_bolt_procs.c +++ b/ocean/osrs/tests/test_bolt_procs.c @@ -18,9 +18,6 @@ #include "ocean/osrs/osrs_bolt_procs.h" -/* ======================================================================== */ -/* test harness */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -48,9 +45,6 @@ static int tests_failed = 0; } \ } while (0) -/* ======================================================================== */ -/* 1. diamond proc chance: ~11% over 10000 trials */ -/* ======================================================================== */ static void test_diamond_proc_chance(void) { printf("test_diamond_proc_chance\n"); @@ -66,9 +60,6 @@ static void test_diamond_proc_chance(void) { ASSERT_FLOAT_RANGE("diamond proc rate ~11%", rate, 0.08f, 0.14f); } -/* ======================================================================== */ -/* 2. diamond effect max: floor(max_hit * 115/100) normal, 126/100 ZCB */ -/* ======================================================================== */ static void test_diamond_effect_max(void) { printf("test_diamond_effect_max\n"); @@ -101,9 +92,6 @@ static void test_diamond_effect_max(void) { ASSERT_INT_EQ("diamond ZCB max_seen <= 63", max_seen_zcb <= 63, 1); } -/* ======================================================================== */ -/* 3. diamond ZCB spec: guaranteed proc on accurate hit, enhanced effectMax */ -/* ======================================================================== */ static void test_diamond_zcb_spec(void) { printf("test_diamond_zcb_spec\n"); @@ -118,9 +106,6 @@ static void test_diamond_zcb_spec(void) { ASSERT_INT_EQ("diamond ZCB spec guaranteed proc", procs, trials); } -/* ======================================================================== */ -/* 4. diamond miss: no proc on miss (unless ZCB spec) */ -/* ======================================================================== */ static void test_diamond_miss(void) { printf("test_diamond_miss\n"); @@ -144,9 +129,6 @@ static void test_diamond_miss(void) { ASSERT_INT_EQ("diamond ZCB spec procs on miss", zcb_procs, 100); } -/* ======================================================================== */ -/* 5. opal bonus damage: floor(99/10)=9 normal, floor(99/9)=11 ZCB */ -/* ======================================================================== */ static void test_opal_bonus_damage(void) { printf("test_opal_bonus_damage\n"); @@ -174,9 +156,6 @@ static void test_opal_bonus_damage(void) { ASSERT_INT_EQ("opal normal proc found", found_normal_bonus, 1); } -/* ======================================================================== */ -/* 6. opal works on miss: proc can trigger when hit_accurate=0 */ -/* ======================================================================== */ static void test_opal_works_on_miss(void) { printf("test_opal_works_on_miss\n"); @@ -191,9 +170,6 @@ static void test_opal_works_on_miss(void) { ASSERT_INT_EQ("opal procs on miss", procs > 0, 1); } -/* ======================================================================== */ -/* 7. opal proc chance: ~5.5% over many trials */ -/* ======================================================================== */ static void test_opal_proc_chance(void) { printf("test_opal_proc_chance\n"); @@ -209,9 +185,6 @@ static void test_opal_proc_chance(void) { ASSERT_FLOAT_RANGE("opal proc rate ~5.5%", rate, 0.04f, 0.07f); } -/* ======================================================================== */ -/* 8. ruby HP-based damage: 500HP → normal=100, ZCB=110 */ -/* ======================================================================== */ static void test_ruby_hp_based_damage(void) { printf("test_ruby_hp_based_damage\n"); @@ -237,9 +210,6 @@ static void test_ruby_hp_based_damage(void) { ASSERT_INT_EQ("ruby normal proc found", found, 1); } -/* ======================================================================== */ -/* 9. ruby cap: 1000HP capped at 100 (normal) / 110 (ZCB) */ -/* ======================================================================== */ static void test_ruby_cap(void) { printf("test_ruby_cap\n"); @@ -264,9 +234,6 @@ static void test_ruby_cap(void) { ASSERT_INT_EQ("ruby normal cap proc found", found, 1); } -/* ======================================================================== */ -/* 10. ruby miss: no proc on miss */ -/* ======================================================================== */ static void test_ruby_miss(void) { printf("test_ruby_miss\n"); @@ -280,9 +247,6 @@ static void test_ruby_miss(void) { ASSERT_INT_EQ("ruby miss = no proc", procs, 0); } -/* ======================================================================== */ -/* 11. ruby ZCB spec: guaranteed proc + enhanced */ -/* ======================================================================== */ static void test_ruby_zcb_spec(void) { printf("test_ruby_zcb_spec\n"); @@ -299,9 +263,6 @@ static void test_ruby_zcb_spec(void) { ASSERT_INT_EQ("ruby ZCB spec guaranteed", procs, trials); } -/* ======================================================================== */ -/* 12. unknown bolt: dragon arrows → no proc */ -/* ======================================================================== */ static void test_unknown_bolt(void) { printf("test_unknown_bolt\n"); @@ -317,9 +278,6 @@ static void test_unknown_bolt(void) { ASSERT_INT_EQ("unknown bolt + ZCB no proc", r2.proc_triggered, 0); } -/* ======================================================================== */ -/* 13. edge cases: max_hit=0, ranged_level=1, target_hp=1 */ -/* ======================================================================== */ static void test_edge_cases(void) { printf("test_edge_cases\n"); @@ -349,9 +307,6 @@ static void test_edge_cases(void) { ASSERT_INT_EQ("diamond dragon bolts proc", rdd.proc_triggered, 1); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== bolt proc tests ===\n\n"); diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index 954a99a5de..895df781f2 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -28,9 +28,6 @@ #include "ocean/osrs/osrs_encounter.h" #include "ocean/osrs/osrs_special_attacks.h" -/* ======================================================================== */ -/* test harness */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -1066,9 +1063,6 @@ static void test_edge_cases(void) { ASSERT_INT_EQ("lv1 magic max", stats.max_hit, 30); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== combat math tests (cross-referenced with osrs-dps-calc) ===\n\n"); diff --git a/ocean/osrs/tests/test_damage.c b/ocean/osrs/tests/test_damage.c index 82761f9742..00aa4c9d80 100644 --- a/ocean/osrs/tests/test_damage.c +++ b/ocean/osrs/tests/test_damage.c @@ -23,9 +23,6 @@ #include "ocean/osrs/osrs_damage.h" -/* ======================================================================== */ -/* test harness */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -41,9 +38,6 @@ static int tests_failed = 0; } \ } while (0) -/* ======================================================================== */ -/* prayer reduction through pipeline */ -/* ======================================================================== */ static void test_prayer_reduction(void) { printf("--- prayer reduction ---\n"); @@ -95,9 +89,6 @@ static void test_prayer_reduction(void) { ASSERT_INT_EQ("pve magic prayer block", r.final_damage, 0); } -/* ======================================================================== */ -/* vengeance */ -/* ======================================================================== */ static void test_vengeance(void) { printf("--- vengeance ---\n"); @@ -142,9 +133,6 @@ static void test_vengeance(void) { ASSERT_INT_EQ("veng pve prayer block no reflect", r.veng_damage, 0); } -/* ======================================================================== */ -/* recoil */ -/* ======================================================================== */ static void test_recoil(void) { printf("--- recoil ---\n"); @@ -192,9 +180,6 @@ static void test_recoil(void) { ASSERT_INT_EQ("recoil 99 -> 10", r.recoil_damage, 10); } -/* ======================================================================== */ -/* smite */ -/* ======================================================================== */ static void test_smite(void) { printf("--- smite ---\n"); @@ -237,9 +222,6 @@ static void test_smite(void) { ASSERT_INT_EQ("smite after pvp prayer", r.smite_drain, 4); } -/* ======================================================================== */ -/* full pipeline: PvP with all effects active */ -/* ======================================================================== */ static void test_full_pipeline_pvp(void) { printf("--- full pipeline PvP ---\n"); @@ -273,9 +255,6 @@ static void test_full_pipeline_pvp(void) { ASSERT_INT_EQ("full pvp no prayer_blocked", r.prayer_blocked, 0); } -/* ======================================================================== */ -/* full pipeline: PvE with veng + recoil, no smite, 100% prayer block */ -/* ======================================================================== */ static void test_full_pipeline_pve(void) { printf("--- full pipeline PvE ---\n"); @@ -304,9 +283,6 @@ static void test_full_pipeline_pve(void) { ASSERT_INT_EQ("pve no prayer smite", r.smite_drain, 0); } -/* ======================================================================== */ -/* edge cases */ -/* ======================================================================== */ static void test_edge_cases(void) { printf("--- edge cases ---\n"); @@ -341,9 +317,6 @@ static void test_edge_cases(void) { ASSERT_INT_EQ("99 damage smite", r.smite_drain, 24); } -/* ======================================================================== */ -/* osrs_has_recoil_ring helper */ -/* ======================================================================== */ static void test_has_recoil_ring(void) { printf("--- has_recoil_ring ---\n"); @@ -367,9 +340,6 @@ static void test_has_recoil_ring(void) { ASSERT_INT_EQ("other ring", osrs_has_recoil_ring(equipped), 0); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== osrs_damage.h test suite ===\n\n"); diff --git a/ocean/osrs/tests/test_interaction.c b/ocean/osrs/tests/test_interaction.c index e4060ccc2a..e14c154962 100644 --- a/ocean/osrs/tests/test_interaction.c +++ b/ocean/osrs/tests/test_interaction.c @@ -14,9 +14,6 @@ #include "ocean/osrs/osrs_interaction.h" -/* ======================================================================== */ -/* test harness */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -33,9 +30,6 @@ static int tests_failed = 0; } \ } while (0) -/* ======================================================================== */ -/* test: init */ -/* ======================================================================== */ static void test_init(void) { printf("--- init ---\n"); @@ -45,9 +39,6 @@ static void test_init(void) { ASSERT_INT_EQ("not active", osrs_interaction_active(&ix), 0); } -/* ======================================================================== */ -/* test: set */ -/* ======================================================================== */ static void test_set(void) { printf("--- set ---\n"); @@ -58,9 +49,6 @@ static void test_set(void) { ASSERT_INT_EQ("active", osrs_interaction_active(&ix), 1); } -/* ======================================================================== */ -/* test: clear */ -/* ======================================================================== */ static void test_clear(void) { printf("--- clear ---\n"); @@ -72,9 +60,6 @@ static void test_clear(void) { ASSERT_INT_EQ("not active", osrs_interaction_active(&ix), 0); } -/* ======================================================================== */ -/* test: interrupt — MOVE */ -/* ======================================================================== */ static void test_interrupt_move(void) { printf("--- interrupt: MOVE ---\n"); @@ -86,9 +71,6 @@ static void test_interrupt_move(void) { ASSERT_INT_EQ("target cleared", ix.target_slot, -1); } -/* ======================================================================== */ -/* test: interrupt — EAT */ -/* ======================================================================== */ static void test_interrupt_eat(void) { printf("--- interrupt: EAT ---\n"); @@ -100,9 +82,6 @@ static void test_interrupt_eat(void) { ASSERT_INT_EQ("target cleared", ix.target_slot, -1); } -/* ======================================================================== */ -/* test: interrupt — DRINK */ -/* ======================================================================== */ static void test_interrupt_drink(void) { printf("--- interrupt: DRINK ---\n"); @@ -114,9 +93,6 @@ static void test_interrupt_drink(void) { ASSERT_INT_EQ("target cleared", ix.target_slot, -1); } -/* ======================================================================== */ -/* test: interrupt — EQUIP */ -/* ======================================================================== */ static void test_interrupt_equip(void) { printf("--- interrupt: EQUIP ---\n"); @@ -128,9 +104,6 @@ static void test_interrupt_equip(void) { ASSERT_INT_EQ("target cleared", ix.target_slot, -1); } -/* ======================================================================== */ -/* test: no interrupt — NONE */ -/* ======================================================================== */ static void test_no_interrupt_none(void) { printf("--- no interrupt: NONE ---\n"); @@ -142,9 +115,6 @@ static void test_no_interrupt_none(void) { ASSERT_INT_EQ("target persists", ix.target_slot, 5); } -/* ======================================================================== */ -/* test: no interrupt — PRAYER */ -/* ======================================================================== */ static void test_no_interrupt_prayer(void) { printf("--- no interrupt: PRAYER ---\n"); @@ -156,9 +126,6 @@ static void test_no_interrupt_prayer(void) { ASSERT_INT_EQ("target persists", ix.target_slot, 5); } -/* ======================================================================== */ -/* test: no interrupt — SPEC */ -/* ======================================================================== */ static void test_no_interrupt_spec(void) { printf("--- no interrupt: SPEC ---\n"); @@ -170,9 +137,6 @@ static void test_no_interrupt_spec(void) { ASSERT_INT_EQ("target persists", ix.target_slot, 5); } -/* ======================================================================== */ -/* test: no interrupt — ATTACK */ -/* ======================================================================== */ static void test_no_interrupt_attack(void) { printf("--- no interrupt: ATTACK ---\n"); @@ -184,9 +148,6 @@ static void test_no_interrupt_attack(void) { ASSERT_INT_EQ("target persists", ix.target_slot, 5); } -/* ======================================================================== */ -/* test: interrupt when no interaction */ -/* ======================================================================== */ static void test_interrupt_when_inactive(void) { printf("--- interrupt when no interaction ---\n"); @@ -197,9 +158,6 @@ static void test_interrupt_when_inactive(void) { ASSERT_INT_EQ("target still -1", ix.target_slot, -1); } -/* ======================================================================== */ -/* test: set replaces */ -/* ======================================================================== */ static void test_set_replaces(void) { printf("--- set replaces ---\n"); @@ -210,9 +168,6 @@ static void test_set_replaces(void) { ASSERT_INT_EQ("target_slot is 3", ix.target_slot, 3); } -/* ======================================================================== */ -/* test: spec toggle */ -/* ======================================================================== */ static void test_spec_toggle(void) { printf("--- spec toggle ---\n"); @@ -223,9 +178,6 @@ static void test_spec_toggle(void) { ASSERT_INT_EQ("disarmed after second toggle", spec_armed, 0); } -/* ======================================================================== */ -/* test: spec disarm */ -/* ======================================================================== */ static void test_spec_disarm(void) { printf("--- spec disarm ---\n"); @@ -234,9 +186,6 @@ static void test_spec_disarm(void) { ASSERT_INT_EQ("disarmed", spec_armed, 0); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== osrs_interaction tests ===\n\n"); diff --git a/ocean/osrs/tests/test_inventory.c b/ocean/osrs/tests/test_inventory.c index 281202ba95..3197857f39 100644 --- a/ocean/osrs/tests/test_inventory.c +++ b/ocean/osrs/tests/test_inventory.c @@ -15,9 +15,6 @@ #include "ocean/osrs/osrs_inventory.h" -/* ======================================================================== */ -/* test harness */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -45,9 +42,6 @@ static int tests_failed = 0; } \ } while (0) -/* ======================================================================== */ -/* test: init */ -/* ======================================================================== */ static void test_init(void) { printf("--- init ---\n"); @@ -64,9 +58,6 @@ static void test_init(void) { ASSERT_INT_EQ("free 28", osrs_inventory_free_slots(&inv), 28); } -/* ======================================================================== */ -/* test: inventory add/remove */ -/* ======================================================================== */ static void test_add_remove(void) { printf("--- inventory add/remove ---\n"); @@ -99,9 +90,6 @@ static void test_add_remove(void) { ASSERT_INT_EQ("remove ags again fails", ok, 0); } -/* ======================================================================== */ -/* test: inventory full */ -/* ======================================================================== */ static void test_inventory_full(void) { printf("--- inventory full ---\n"); @@ -120,9 +108,6 @@ static void test_inventory_full(void) { ASSERT_INT_EQ("29th fails", s, -1); } -/* ======================================================================== */ -/* test: find */ -/* ======================================================================== */ static void test_find(void) { printf("--- find ---\n"); @@ -138,9 +123,6 @@ static void test_find(void) { ASSERT_INT_EQ("find missing", osrs_inventory_find(&inv, ITEM_AGS), -1); } -/* ======================================================================== */ -/* test: equip direct */ -/* ======================================================================== */ static void test_equip_direct(void) { printf("--- equip direct ---\n"); @@ -163,9 +145,6 @@ static void test_equip_direct(void) { ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); } -/* ======================================================================== */ -/* test: equip from inventory */ -/* ======================================================================== */ static void test_equip_from_inventory(void) { printf("--- equip from inventory ---\n"); @@ -180,9 +159,6 @@ static void test_equip_from_inventory(void) { ASSERT_INT_EQ("inv count 0", osrs_inventory_count(&inv), 0); } -/* ======================================================================== */ -/* test: equip swap (slot occupied, old item goes to inventory) */ -/* ======================================================================== */ static void test_equip_swap(void) { printf("--- equip swap ---\n"); @@ -201,9 +177,6 @@ static void test_equip_swap(void) { ASSERT_INT_EQ("inv count 1", osrs_inventory_count(&inv), 1); } -/* ======================================================================== */ -/* test: two-handed equip (shield unequipped to inventory) */ -/* ======================================================================== */ static void test_two_handed_equip(void) { printf("--- two-handed equip ---\n"); @@ -227,9 +200,6 @@ static void test_two_handed_equip(void) { ASSERT_INT_EQ("inv count 2", osrs_inventory_count(&inv), 2); } -/* ======================================================================== */ -/* test: two-handed equip with no old weapon */ -/* ======================================================================== */ static void test_two_handed_no_old_weapon(void) { printf("--- two-handed no old weapon ---\n"); @@ -250,9 +220,6 @@ static void test_two_handed_no_old_weapon(void) { ASSERT_INT_EQ("inv count 1", osrs_inventory_count(&inv), 1); } -/* ======================================================================== */ -/* test: two-handed fail (full inventory + shield equipped) */ -/* ======================================================================== */ static void test_two_handed_fail(void) { printf("--- two-handed fail ---\n"); @@ -278,9 +245,6 @@ static void test_two_handed_fail(void) { ASSERT_UINT8_EQ("ags still in inv", inv.inventory[27], ITEM_AGS); } -/* ======================================================================== */ -/* test: two-handed succeeds when exactly enough space */ -/* ======================================================================== */ static void test_two_handed_exact_space(void) { printf("--- two-handed exact space ---\n"); @@ -303,9 +267,6 @@ static void test_two_handed_exact_space(void) { ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); } -/* ======================================================================== */ -/* test: unequip to inventory */ -/* ======================================================================== */ static void test_unequip(void) { printf("--- unequip ---\n"); @@ -333,9 +294,6 @@ static void test_unequip(void) { ASSERT_UINT8_EQ("head still equipped", inv.equipment[GEAR_SLOT_HEAD], ITEM_HELM_NEITIZNOT); } -/* ======================================================================== */ -/* test: gear slot mapping */ -/* ======================================================================== */ static void test_gear_slot_mapping(void) { printf("--- gear slot mapping ---\n"); @@ -353,9 +311,6 @@ static void test_gear_slot_mapping(void) { ASSERT_INT_EQ("ags -> weapon", osrs_item_gear_slot(ITEM_AGS), GEAR_SLOT_WEAPON); } -/* ======================================================================== */ -/* test: edge cases */ -/* ======================================================================== */ static void test_edge_cases(void) { printf("--- edge cases ---\n"); @@ -388,9 +343,6 @@ static void test_edge_cases(void) { ASSERT_INT_EQ("unequip slot 11", osrs_unequip_to_inventory(&inv, NUM_GEAR_SLOTS), 0); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== osrs_inventory.h tests ===\n\n"); diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index 08c3534eba..c4f135fb1a 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -26,9 +26,6 @@ #include "ocean/osrs/osrs_encounter.h" -/* ======================================================================== */ -/* test harness (same macros as test_combat_math.c) */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -870,9 +867,6 @@ static void test_loadout_defence_into_def_roll(void) { ASSERT_INT_EQ("def roll vs magic", def_roll_magic, 7811); } -/* ======================================================================== */ -/* test: shared attack prep applies virtus ancient bonus only to ancients */ -/* ======================================================================== */ static void test_shared_prepare_attack_virtus_ancient_bonus(void) { printf("--- shared attack prep: virtus ancient bonus ---\n"); @@ -911,9 +905,6 @@ static void test_shared_prepare_attack_virtus_ancient_bonus(void) { ASSERT_INT_EQ("virtus non-ancient unchanged", non_ancient.max_hit, stats.max_hit); } -/* ======================================================================== */ -/* test: shared attack prep applies tbow scaling from magic attack bonus */ -/* ======================================================================== */ static void test_shared_prepare_attack_tbow_scaling(void) { printf("--- shared attack prep: tbow scaling ---\n"); @@ -954,9 +945,6 @@ static void test_shared_prepare_attack_tbow_scaling(void) { ); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== item effects tests (cross-referenced with osrs-dps-calc) ===\n\n"); diff --git a/ocean/osrs/tests/test_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c index eae9c5dcd6..127c8c49df 100644 --- a/ocean/osrs/tests/test_special_attacks.c +++ b/ocean/osrs/tests/test_special_attacks.c @@ -29,9 +29,6 @@ #include "ocean/osrs/osrs_combat.h" #include "ocean/osrs/osrs_special_attacks.h" -/* ======================================================================== */ -/* test harness (same pattern as test_combat_math.c) */ -/* ======================================================================== */ static int tests_run = 0; static int tests_passed = 0; @@ -85,9 +82,6 @@ static void test_melee_spec_costs(void) { ASSERT_INT_EQ("abyssal bludgeon cost", get_melee_spec_cost(MELEE_SPEC_ABYSSAL_BLUDGEON), 50); } -/* ======================================================================== */ -/* test: ranged spec energy costs */ -/* ======================================================================== */ static void test_ranged_spec_costs(void) { printf("--- ranged spec energy costs ---\n"); @@ -100,9 +94,6 @@ static void test_ranged_spec_costs(void) { ASSERT_INT_EQ("morrigan's cost", get_ranged_spec_cost(RANGED_SPEC_MORRIGANS), 50); } -/* ======================================================================== */ -/* test: magic spec energy costs */ -/* ======================================================================== */ static void test_magic_spec_costs(void) { printf("--- magic spec energy costs ---\n"); @@ -1146,9 +1137,6 @@ static void test_spec_dispatch(void) { ASSERT_INT_EQ("non-weapon damage", sr.total_damage, 0); } -/* ======================================================================== */ -/* main */ -/* ======================================================================== */ int main(void) { printf("=== special attack tests (cross-referenced with osrs-dps-calc) ===\n\n"); diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c index efbabd5011..818b41d883 100644 --- a/ocean/osrs_inferno/binding.c +++ b/ocean/osrs_inferno/binding.c @@ -92,10 +92,6 @@ 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) { - /* tick pacing lives in c_render — it blocks at the tick deadline calling - pvp_render at ~60fps so sub-tile interpolation can animate between sim - ticks. nothing to do here timing-wise. */ - int used_human_commands = 0; RenderClient* render_client = (RenderClient*)env->render_env.client; @@ -625,14 +621,11 @@ void my_log(Log* log, Dict* out) { float unavoidable_rate = (off_prayer > 0.0f) ? log->unavoidable_off_prayer / off_prayer : 0.0f; dict_set(out, "unavoidable_off_prayer_rate", unavoidable_rate); - //dict_set(out, "unavoidable_off_prayer", log->unavoidable_off_prayer); dict_set(out, "brews_remaining", log->brews_remaining); dict_set(out, "restores_remaining", log->restores_remaining); dict_set(out, "prayer_at_death", log->prayer_at_death); - //dict_set(out, "npc_kills", log->npc_kills); - //dict_set(out, "gear_switches", log->gear_switches); dict_set(out, "current_ranged", log->current_ranged); dict_set(out, "current_magic", log->current_magic); dict_set(out, "behind_shield_pct", log->behind_shield_pct); From 16acdd223d65e3062b239bde7d169d2e8dfed8e3 Mon Sep 17 00:00:00 2001 From: Valtteri Valo Date: Tue, 28 Apr 2026 17:47:01 +0300 Subject: [PATCH 60/60] Simplify OSRS PR code --- ocean/osrs/binding.c | 38 +----- ocean/osrs/encounters/encounter_inferno.h | 2 - ocean/osrs/encounters/encounter_nh_pvp.h | 3 +- ocean/osrs/osrs_collision.h | 60 -------- ocean/osrs/osrs_combat.h | 8 -- ocean/osrs/osrs_encounter.h | 29 ---- ocean/osrs/osrs_gui.h | 4 - ocean/osrs/osrs_human_input.h | 1 - ocean/osrs/osrs_pvp_api.h | 19 --- ocean/osrs/osrs_pvp_gear.h | 8 -- ocean/osrs/osrs_pvp_opponents.h | 158 +--------------------- ocean/osrs/osrs_special_attacks.h | 18 +-- ocean/osrs/osrs_types.h | 91 ------------- ocean/osrs/osrs_visual.c | 7 +- ocean/osrs/scripts/ExportItemSprites.java | 41 ++---- ocean/osrs/scripts/export_inferno_npcs.py | 36 +---- ocean/osrs/scripts/export_models.py | 51 +++---- ocean/osrs/scripts/export_terrain.py | 36 ++--- ocean/osrs/tests/test_collision.c | 35 +---- ocean/osrs/tests/test_combat_math.c | 84 ------------ ocean/osrs/tests/test_item_effects.c | 72 ---------- ocean/osrs/tests/test_npc_movement.c | 4 - ocean/osrs/tests/test_special_attacks.c | 120 ---------------- ocean/osrs/tools/export_encounter_npcs.py | 53 +------- ocean/osrs_pvp/binding.c | 37 +---- ocean/osrs_zulrah/binding.c | 16 +-- 26 files changed, 63 insertions(+), 968 deletions(-) diff --git a/ocean/osrs/binding.c b/ocean/osrs/binding.c index baceb24975..17bb4814e4 100644 --- a/ocean/osrs/binding.c +++ b/ocean/osrs/binding.c @@ -10,12 +10,7 @@ #include "osrs_env.h" -/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. - * vecenv.h's create_static_vec assigns to env->observations, env->actions, - * env->rewards, env->terminals directly. These fields must match vecenv's - * expected types (void*, float*, float*, float*). The embedded OsrsEnv has - * its own identically-named fields with different types — pvp_init sets those - * to internal inline buffers, so there's no conflict. */ +/* vecenv-compatible header fields must stay first. */ typedef struct { void* observations; float* actions; @@ -27,7 +22,6 @@ typedef struct { OsrsEnv pvp; - /* staging buffers for type conversion */ int ocean_acts_staging[NUM_ACTION_HEADS]; unsigned char ocean_term_staging; } MetalPvpEnv; @@ -38,22 +32,15 @@ typedef struct { #define OBS_TENSOR_T FloatTensor #define Env MetalPvpEnv -/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h - * because vecenv.h calls them inside its implementation section without - * forward-declaring them (they're expected to come from the env header). */ - void c_step(Env* env) { - /* float actions from vecenv → int staging for PVP */ for (int i = 0; i < NUM_ATNS; i++) { env->ocean_acts_staging[i] = (int)env->actions[i]; } pvp_step(&env->pvp); - /* terminal: unsigned char → float for vecenv */ env->terminals[0] = (float)env->ocean_term_staging; - /* copy PVP log to wrapper log on episode end */ if (env->ocean_term_staging) { env->log.episode_return = env->pvp.log.episode_return; env->log.episode_length = env->pvp.log.episode_length; @@ -70,8 +57,6 @@ void c_step(Env* env) { } void c_reset(Env* env) { - /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because - * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ env->pvp.ocean_io.agent_obs = (float*)env->observations; env->pvp.ocean_io.agent_rewards = env->rewards; env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; @@ -94,13 +79,6 @@ void my_init(Env* env, Dict* kwargs) { pvp_init(&env->pvp); - /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside - * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers - * (env->observations, env->actions, env->rewards, env->terminals are NULL - * at this point). c_reset runs after buffer assignment and does the wiring. - * - * For now, point ocean pointers at internal staging so pvp_reset doesn't - * crash on writes to ocean_term/ocean_rew. */ env->pvp.ocean_io.agent_obs = NULL; env->pvp.ocean_io.agent_rewards = env->pvp._rews_buf; env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; @@ -108,7 +86,6 @@ void my_init(Env* env, Dict* kwargs) { env->pvp.ocean_io.agent_obs_p1 = NULL; env->pvp.ocean_io.selfplay_mask = NULL; - /* config from Dict (all values are double) */ env->pvp.pvp_runtime.use_c_opponent = 1; env->pvp.auto_reset = 1; env->pvp.is_lms = 1; @@ -122,7 +99,6 @@ void my_init(Env* env, Dict* kwargs) { DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; - /* reward shaping coefficients (same defaults as ocean_binding.c) */ env->pvp.shaping.damage_dealt_coef = 0.005f; env->pvp.shaping.damage_received_coef = -0.005f; env->pvp.shaping.correct_prayer_bonus = 0.03f; @@ -149,14 +125,11 @@ void my_init(Env* env, Dict* kwargs) { env->pvp.shaping.click_penalty_threshold = 5; env->pvp.shaping.click_penalty_coef = -0.003f; - /* gear: default tier 0 (basic LMS) */ 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 sets up game state (players, positions, gear, etc.) - * but does NOT write to ocean buffers — that happens in c_reset. */ pvp_reset(&env->pvp); } @@ -168,11 +141,6 @@ void my_log(Log* log, Dict* out) { dict_set(out, "damage_received", log->damage_received); } -/* ======================================================================== - * PFSP: set/get opponent pool weights across all envs - * Called from Python via pybind11 wrappers in metal_bindings.mm - * ======================================================================== */ - void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { Env* envs = (Env*)vec->envs; if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; @@ -183,9 +151,6 @@ void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int p envs[e].pvp.pvp_runtime.pfsp.pool[i] = (OpponentType)pool[i]; envs[e].pvp.pvp_runtime.pfsp.cum_weights[i] = cum_weights[i]; } - /* Only reset on first configuration — restarts the episode that was started - * during env creation before the pool was set (would have used fallback opponent). - * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ if (was_unconfigured) { c_reset(&envs[e]); } @@ -206,7 +171,6 @@ void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes out_episodes[i] = 0.0f; } - /* Aggregate and reset (read-and-reset pattern) */ for (int e = 0; e < vec->size; e++) { for (int i = 0; i < envs[e].pvp.pvp_runtime.pfsp.pool_size; i++) { out_wins[i] += envs[e].pvp.pvp_runtime.pfsp.wins[i]; diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h index c892b0063d..5656a24b67 100644 --- a/ocean/osrs/encounters/encounter_inferno.h +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -1978,7 +1978,6 @@ static void inf_npc_attack(InfernoState* s, int idx) { return; } - /* inferno has NPC-specific melee fallback rules when close enough to hit. */ { int style_mask = inf_attack_style_options_mask( s, npc, stats, actual_style, dist); @@ -3709,7 +3708,6 @@ static void inf_write_obs(EncounterState* state, float* obs) { } } - /* sanity: verify we wrote exactly INF_NUM_OBS features */ if (i != INF_NUM_OBS) { fprintf(stderr, "BUG: inf_write_obs wrote %d features, expected %d\n", i, INF_NUM_OBS); abort(); diff --git a/ocean/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h index 8487194fc2..deb1a0b95c 100644 --- a/ocean/osrs/encounters/encounter_nh_pvp.h +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -184,12 +184,11 @@ static const EncounterDef ENCOUNTER_NH_PVP = { .put_float = nh_pvp_put_float, .put_ptr = nh_pvp_put_ptr, - .render_post_tick = NULL, /* NH PvP uses existing render_post_tick for now */ + .render_post_tick = NULL, .get_log = nh_pvp_get_log, .get_tick = nh_pvp_get_tick, .get_winner = nh_pvp_get_winner, - /* NH PvP uses its own human_to_pvp_actions translator via the PvP code path. */ .translate_human_input = NULL, .head_move = -1, .head_prayer = -1, diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h index 9055bb5274..0c015a428d 100644 --- a/ocean/osrs/osrs_collision.h +++ b/ocean/osrs/osrs_collision.h @@ -20,10 +20,6 @@ #include #include -/* ========================================================================= - * COLLISION FLAG CONSTANTS (from TraversalConstants.java) - * ========================================================================= */ - #define COLLISION_NONE 0x000000 #define COLLISION_WALL_NORTH_WEST 0x000001 #define COLLISION_WALL_NORTH 0x000002 @@ -47,10 +43,6 @@ #define COLLISION_BRIDGE 0x040000 #define COLLISION_BLOCKED 0x200000 -/* ========================================================================= - * REGION DATA STRUCTURE - * ========================================================================= */ - #define REGION_SIZE 64 #define REGION_HEIGHT_LEVELS 4 @@ -59,10 +51,6 @@ typedef struct { int flags[REGION_HEIGHT_LEVELS][REGION_SIZE][REGION_SIZE]; } CollisionRegion; -/* ========================================================================= - * REGION MAP (hash map of regions keyed by region hash) - * ========================================================================= */ - #define REGION_MAP_CAPACITY 256 /* power of 2, enough for wilderness + surroundings */ typedef struct { @@ -75,10 +63,6 @@ typedef struct { int count; } CollisionMap; -/* ========================================================================= - * COORDINATE HELPERS - * ========================================================================= */ - /** Compute region hash from global tile coordinates. */ static inline int collision_region_hash(int x, int y) { return ((x >> 6) << 8) | (y >> 6); @@ -89,10 +73,6 @@ static inline int collision_local(int coord) { return coord & 0x3F; } -/* ========================================================================= - * REGION MAP OPERATIONS - * ========================================================================= */ - /** Initialize a collision map (all slots empty). */ static inline void collision_map_init(CollisionMap* map) { map->count = 0; @@ -166,10 +146,6 @@ static inline void collision_map_free(CollisionMap* map) { free(map); } -/* ========================================================================= - * FLAG READ/WRITE - * ========================================================================= */ - /** Get collision flags for a global tile coordinate. Returns 0 if region not loaded. */ static inline int collision_get_flags(const CollisionMap* map, int height, int x, int y) { if (map == NULL) return COLLISION_NONE; @@ -219,17 +195,6 @@ static inline void collision_mark_occupant(CollisionMap* map, int height, int x, } } -/* ========================================================================= - * TRAVERSAL CHECKS (ported from TraversalMap.java) - * - * Each check tests the DESTINATION tile for incoming wall flags + BLOCKED. - * For diagonals: also checks the two cardinal intermediate tiles. - * - * All functions take a CollisionMap* which may be NULL (= all traversable). - * Height is always 0 for PvP (single plane). The height param is kept for - * future multi-plane support. - * ========================================================================= */ - /** Check if flag bits are INACTIVE (none set) on a tile. */ static inline int collision_is_inactive(const CollisionMap* map, int height, int x, int y, int flag) { return (collision_get_flags(map, height, x, y) & flag) == 0; @@ -332,18 +297,6 @@ static inline int collision_traversable_step(const CollisionMap* map, int height return 1; } -/* ========================================================================= - * BINARY COLLISION MAP I/O - * - * Format: - * 4 bytes: magic "CMAP" - * 4 bytes: version (1) - * 4 bytes: region_count - * For each region: - * 4 bytes: region_hash (key) - * REGION_HEIGHT_LEVELS * REGION_SIZE * REGION_SIZE * 4 bytes: flags - * ========================================================================= */ - #define COLLISION_MAP_MAGIC 0x50414D43 /* "CMAP" in little-endian */ #define COLLISION_MAP_VERSION 1 @@ -424,19 +377,6 @@ static inline int collision_map_save(const CollisionMap* map, const char* path) return 0; } -/* ========================================================================= - * LINE OF SIGHT — fixed-point ray tracing with directional masks - * - * used by inferno pillars, zulrah safespots, and any future encounter - * that needs projectile blocking around obstacles. - * - * algorithm: Bresenham-style ray trace in Q16 fixed-point from tile center. - * each blocker has a directional bitmask indicating which sides block sight. - * FULL_MASK blocks from all directions. - * - * reference: osrs-sdk LineOfSight.ts - * ========================================================================= */ - #define LOS_FULL_MASK 0x20000 #define LOS_EAST_MASK 0x01000 #define LOS_WEST_MASK 0x10000 diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h index 90fdb5bd7f..3addfe2ff5 100644 --- a/ocean/osrs/osrs_combat.h +++ b/ocean/osrs/osrs_combat.h @@ -331,15 +331,11 @@ static inline void encounter_shuffle(int* arr, int n, uint32_t* rng) { 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). @@ -347,8 +343,6 @@ static inline void encounter_shuffle(int* arr, int n, uint32_t* rng) { 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 */ @@ -358,8 +352,6 @@ static inline int osrs_player_eff_level(int base_level, float prayer_mult, int s /* */ /* 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. diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h index a30678949f..c12467e6c0 100644 --- a/ocean/osrs/osrs_encounter.h +++ b/ocean/osrs/osrs_encounter.h @@ -303,8 +303,6 @@ static inline void encounter_resolve_attack_target( } } } - -/* ======================================================================== */ /* canonical prayer action encoding (toggle semantics, matches OSRS) */ /* */ /* real OSRS has no "turn off" button — clicking an active prayer icon */ @@ -321,8 +319,6 @@ static inline void encounter_resolve_attack_target( /* 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 */ @@ -888,14 +884,10 @@ static inline int encounter_npc_step_toward( 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. @@ -1031,8 +1023,6 @@ static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t expli if (explicit_seed != 0) rng = explicit_seed; return rng; } - -/* ======================================================================== */ /* shared prayer drain */ /* */ /* ENCOUNTERS: call encounter_drain_all_prayers() each tick to drain prayer */ @@ -1046,8 +1036,6 @@ static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t expli /* */ /* 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. */ @@ -1123,9 +1111,6 @@ static inline void encounter_drain_all_prayers(Player* p, int prayer_bonus) { } } } - - -/* ======================================================================== */ /* shared loadout stat computation */ /* */ /* ENCOUNTERS: do NOT manually compute attack bonuses, max hits, or */ @@ -1136,8 +1121,6 @@ static inline void encounter_drain_all_prayers(Player* p, int prayer_bonus) { /* 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 @@ -1281,16 +1264,12 @@ static inline void encounter_compute_loadout_stats( 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) @@ -1349,8 +1328,6 @@ static inline void encounter_compute_player_equipped_stats( 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 */ @@ -1360,8 +1337,6 @@ static inline void encounter_compute_player_equipped_stats( /* 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. @@ -1442,16 +1417,12 @@ static inline void encounter_recompute_loadout_max_hits( } } } - -/* ======================================================================== */ /* 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); diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h index 70a20c35f2..bbc2a8774b 100644 --- a/ocean/osrs/osrs_gui.h +++ b/ocean/osrs/osrs_gui.h @@ -1883,8 +1883,6 @@ static void gui_draw_spellbook(GuiState* gs, Player* p) { 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, */ @@ -1897,8 +1895,6 @@ static void gui_draw_spellbook(GuiState* gs, Player* p) { /* 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 diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h index c50260ce68..79f4c724fe 100644 --- a/ocean/osrs/osrs_human_input.h +++ b/ocean/osrs/osrs_human_input.h @@ -408,7 +408,6 @@ static void human_draw_click_cross(HumanInput* hi, Texture2D* cross_sprites, int /* center sprite on click position (OSRS draws at mouseX-8, mouseY-8 for 16px) */ DrawTexture(tex, cx - tex.width / 2, cy - tex.height / 2, WHITE); } else { - /* fallback: simple X lines */ float progress = 1.0f - (float)hi->click_cross_timer / CLICK_CROSS_ANIM_TICKS; int alpha = (int)(progress * 255); Color c = hi->click_is_attack diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h index ed4dbc35dd..e65d84c61d 100644 --- a/ocean/osrs/osrs_pvp_api.h +++ b/ocean/osrs/osrs_pvp_api.h @@ -523,32 +523,24 @@ void pvp_step(OsrsEnv* env) { update_timers(&env->players[0]); update_timers(&env->players[1]); - // Save HP percent BEFORE actions execute (for reward shaping eat checks) for (int i = 0; i < NUM_AGENTS; i++) { Player* pi = &env->players[i]; pi->prev_hp_percent = (float)pi->current_hitpoints / (float)pi->base_hitpoints; } - // Resolve local action arrays by PID order int* agent_actions[NUM_AGENTS]; agent_actions[0] = actions_p0; agent_actions[1] = actions_p1; - // Save positions before movement for walk/run detection int pre_move_x[NUM_AGENTS], pre_move_y[NUM_AGENTS]; for (int i = 0; i < NUM_AGENTS; i++) { pre_move_x[i] = env->players[i].x; pre_move_y[i] = env->players[i].y; } - // CRITICAL: Two-phase execution for correct prayer timing - // Phase 2A: Apply switches (gear, prayer, consumables, movement) for BOTH players - // This ensures attacks will see the correct prayer state execute_switches(env, first, agent_actions[first]); execute_switches(env, second, agent_actions[second]); - // Decrement consumable timers AFTER eating so obs shows correct countdown - // (eat → 2, 1, Ready instead of eat → 3, 2, 1 with Ready never visible) for (int i = 0; i < NUM_AGENTS; i++) { Player* pi = &env->players[i]; if (pi->food_timer > 0) pi->food_timer--; @@ -556,38 +548,27 @@ void pvp_step(OsrsEnv* env) { if (pi->karambwan_timer > 0) pi->karambwan_timer--; } - // Resolve same-tile stacking (OSRS prevents unfrozen players from sharing a tile) if (env->players[0].x == env->players[1].x && env->players[0].y == env->players[1].y) { resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); } - // Phase 2B: Attack movement for BOTH players (auto-walk to melee range, step-out) - // All movements resolve before any range checks, matching OSRS tick processing. - // This prevents PID-dependent behavior where one player's movement check depends - // on whether the other player has already stepped out. execute_attack_movement(env, first, agent_actions[first]); execute_attack_movement(env, second, agent_actions[second]); - // Resolve same-tile after attack movements (step-out may have caused overlap) if (env->players[0].x == env->players[1].x && env->players[0].y == env->players[1].y) { resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); } - // Phase 2C: Attack combat for BOTH players (range check + attack + chase) - // dist is recomputed from CURRENT positions after all movements resolved. execute_attack_combat(env, first, agent_actions[first]); execute_attack_combat(env, second, agent_actions[second]); - // Resolve same-tile after attack-phase chase movement if (env->players[0].x == env->players[1].x && env->players[0].y == env->players[1].y) { resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); } - // Compute walk vs run: Chebyshev distance moved this tick - // 1 tile = walk, 2+ tiles = run (OSRS sends 1 waypoint for walk, 2 for run) for (int i = 0; i < NUM_AGENTS; i++) { int dx = abs(env->players[i].x - pre_move_x[i]); int dy = abs(env->players[i].y - pre_move_y[i]); diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h index c30105401c..6edb05cd79 100644 --- a/ocean/osrs/osrs_pvp_gear.h +++ b/ocean/osrs/osrs_pvp_gear.h @@ -386,8 +386,6 @@ static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAM } case LOADOUT_MAGE: case LOADOUT_TANK: { - // MAGE uses best magic weapon + magic gear - // TANK uses best magic weapon + defensive body/legs/shield uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MAGE_WEAPON_PRIORITY, MAGE_WEAPON_PRIORITY_LEN); if (weapon != ITEM_NONE) out[0] = weapon; @@ -403,7 +401,6 @@ static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAM uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); if (neck != ITEM_NONE) out[6] = neck; } else { - // TANK: defensive shield/body/legs/head/neck uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); if (shield != ITEM_NONE) out[1] = shield; uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); @@ -906,22 +903,17 @@ static inline int compute_food_count(Player* p) { return food > 1 ? food : 1; } -// Loot tables for gear tiers (items that can drop from LMS chests) -// Each chest gives 2 rolls from the same combined pool static const uint8_t CHEST_LOOT[] = { - // offensive ITEM_DRAGON_CLAWS, ITEM_AGS, ITEM_ANCIENT_GS, ITEM_GRANITE_MAUL, ITEM_VOLATILE_STAFF, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, ITEM_DARK_BOW, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD, ITEM_ELDER_MAUL, ITEM_HEAVY_BALLISTA, ITEM_OCCULT_NECKLACE, ITEM_INFERNAL_CAPE, ITEM_SEERS_RING_I, ITEM_MAGES_BOOK, - // defensive ITEM_ANCESTRAL_HAT, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, ITEM_KARILS_TOP, ITEM_BANDOS_TASSETS, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_FURY, ITEM_ETERNAL_BOOTS, - // barrows armor + opal bolts ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, ITEM_OPAL_DRAGON_BOLTS, diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h index 14055157ea..25e881185f 100644 --- a/ocean/osrs/osrs_pvp_opponents.h +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -33,10 +33,6 @@ #define OPP_STYLE_MELEE 2 #define OPP_STYLE_SPEC 3 -/* ========================================================================= - * Utility: map OPP_STYLE_* to LOADOUT_* presets - * ========================================================================= */ - static inline int opp_style_to_loadout(int style) { switch (style) { case OPP_STYLE_MAGE: return LOADOUT_MAGE; @@ -51,20 +47,14 @@ static inline void opp_apply_gear_switch(int* actions, int style) { actions[HEAD_LOADOUT] = opp_style_to_loadout(style); } -/* Fake switch: same loadout set, no attack action follows */ static inline void opp_apply_fake_switch(int* actions, int style) { actions[HEAD_LOADOUT] = opp_style_to_loadout(style); } -/* Tank gear: LOADOUT_TANK equips dhide body, rune legs, spirit shield */ static inline void opp_apply_tank_gear(int* actions) { actions[HEAD_LOADOUT] = LOADOUT_TANK; } -/* ========================================================================= - * Consumable availability helpers - * ========================================================================= */ - typedef struct { int can_food; int can_brew; @@ -92,18 +82,12 @@ static inline OppConsumables opp_get_consumables(OpponentState* opp, Player* sel return c; } -/* (opp_apply_gear_switch is defined above as inline loadout assignment) */ - -/* ========================================================================= - * Prayer helpers - * ========================================================================= */ - static inline AttackStyle opp_get_gear_style(Player* p) { int s = get_item_attack_style(p->equipped[GEAR_SLOT_WEAPON]); if (s == 3) return ATTACK_STYLE_MAGIC; if (s == 2) return ATTACK_STYLE_RANGED; if (s == 1) return ATTACK_STYLE_MELEE; - return ATTACK_STYLE_MAGIC; /* Default */ + return ATTACK_STYLE_MAGIC; } static inline int opp_get_defensive_prayer(Player* target) { @@ -111,7 +95,7 @@ static inline int opp_get_defensive_prayer(Player* target) { if (target_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; if (target_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; if (target_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; - return OVERHEAD_MAGE; /* Default to mage */ + return OVERHEAD_MAGE; } static inline int opp_has_prayer_active(Player* self, int prayer_action) { @@ -121,10 +105,6 @@ static inline int opp_has_prayer_active(Player* self, int prayer_action) { return 0; } -/* ========================================================================= - * Attack style helpers - * ========================================================================= */ - static inline int opp_attack_ready(Player* self) { return self->attack_timer <= 0; } @@ -186,10 +166,6 @@ static inline void opp_update_flee_tracking(OpponentState* opp, Player* self, Pl opp->prev_dist_to_target = dist; } -/* ========================================================================= - * Per-episode randomization: ranges table for all opponent types - * ========================================================================= */ - typedef struct { float base; float variance; } RandRange; typedef struct { @@ -286,10 +262,6 @@ static inline int opp_apply_prayer_mistake(OsrsEnv* env, OpponentState* opp, int return correct_prayer; } -/* ========================================================================= - * Phase 2: probability constants for unpredictable policies - * ========================================================================= */ - /* unpredictable_improved prayer delays: 70% instant, 20% 1-tick, 8% 2-tick, 2% 3-tick */ static const float UNPREDICTABLE_IMP_PRAYER_CUM[] = {0.70f, 0.90f, 0.98f, 1.00f}; #define UNPREDICTABLE_IMP_PRAYER_CUM_LEN 4 @@ -312,10 +284,6 @@ static const float UNPREDICTABLE_OT_ACTION_CUM[] = {0.90f, 0.98f, 1.00f}; #define UNPREDICTABLE_OT_FAKE_FAIL 0.12f #define UNPREDICTABLE_OT_WRONG_PREDICT 0.08f -/* ========================================================================= - * Phase 2: helper functions for onetick + unpredictable policies - * ========================================================================= */ - /* Weighted delay sampling from cumulative weight array */ static inline int opp_sample_delay(OsrsEnv* env, const float* cum_weights, int num_weights) { float r = rand_float(env); @@ -432,7 +400,6 @@ static int opp_apply_consumables(OsrsEnv* env, OpponentState* opp, int* actions, actions[HEAD_FOOD] = FOOD_EAT; opp->food_cooldown = 3; } else if (hp_pct < 0.60f && cons.can_karambwan) { - /* Karambwan as fallback food (no sharks left) */ actions[HEAD_KARAMBWAN] = KARAM_EAT; opp->karambwan_cooldown = 2; } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { @@ -554,10 +521,6 @@ static void opp_handle_delayed_prayer(OsrsEnv* env, OpponentState* opp, int* act opp_process_pending_prayer(opp, actions, self); } -/* ========================================================================= - * Policy implementations - * ========================================================================= */ - /* --- TrueRandom: random value per action head --- */ static void opp_true_random(OsrsEnv* env, int* actions) { for (int i = 0; i < NUM_ACTION_HEADS; i++) { @@ -1034,12 +997,6 @@ static void opp_improved(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Novice NH: learning player — 60% correct prayer, random attacks, good eating - * Bridges easy opponents to intermediate. No off-prayer logic, no offensive - * prayers, no movement. Just consistent attacking and sometimes-correct prayer. - * ========================================================================= */ - static void opp_novice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1146,11 +1103,6 @@ static void opp_novice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Apprentice NH: 60% correct prayer, 20% off-prayer attacks, 20% offensive - * prayer, random 30% spec, drain restore. Bridges novice_nh to competent_nh. - * ========================================================================= */ - static void opp_apprentice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1259,11 +1211,6 @@ static void opp_apprentice_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Competent NH: 75% correct prayer, 25% off-prayer attacks, 25% offensive - * prayers, 50% conditional spec. Bridges apprentice_nh to intermediate_nh. - * ========================================================================= */ - static void opp_competent_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1395,12 +1342,6 @@ static void opp_competent_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Intermediate NH: getting the hang of it — 85% correct prayer, 70% off-prayer - * attacks, 50% offensive prayers. No movement, no fakes. Bridges novice_nh - * to improved. - * ========================================================================= */ - static void opp_intermediate_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1531,12 +1472,6 @@ static void opp_intermediate_nh(OsrsEnv* env, OpponentState* opp, int* actions) } } -/* ========================================================================= - * Advanced NH: near-improved — 100% correct prayer, 90% off-prayer attacks, - * 75% offensive prayers, same spec as improved, farcast 3 but no step under. - * Bridges intermediate_nh to improved. - * ========================================================================= */ - static void opp_advanced_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1672,12 +1607,6 @@ static void opp_advanced_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Proficient NH: 92% off-prayer, 80% offensive prayer, 25% step under. - * Introduces step under at low rate between advanced_nh (no step under) - * and expert_nh (50% step under). - * ========================================================================= */ - static void opp_proficient_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1816,12 +1745,6 @@ static void opp_proficient_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Expert NH: 95% off-prayer, 85% offensive prayer, 50% step under. - * Introduces step under mechanic at reduced rate while keeping attack - * parameters between advanced_nh and improved. - * ========================================================================= */ - static void opp_expert_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -1960,11 +1883,6 @@ static void opp_expert_nh(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Phase 2 Policy: Onetick - * Fake switches, tank gear, smart spec, boost pots, 1-tick attacks. - * ========================================================================= */ - static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -2175,11 +2093,6 @@ static void opp_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { (void)prayer_pct; } -/* ========================================================================= - * Phase 2 Policy: RealisticImproved - * Improved with prayer delays, wrong prayer chance, attack delays. - * ========================================================================= */ - static void opp_unpredictable_improved(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -2280,11 +2193,6 @@ static void opp_unpredictable_improved(OsrsEnv* env, OpponentState* opp, int* ac (void)potion_used; } -/* ========================================================================= - * Phase 2 Policy: RealisticOnetick - * Onetick + prayer delays + fake execution failures + wrong prediction. - * ========================================================================= */ - static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -2481,11 +2389,6 @@ static void opp_unpredictable_onetick(OsrsEnv* env, OpponentState* opp, int* act } } -/* ========================================================================= - * Helper: decode agent's pending action to extract attack style and prayer - * Used by boss opponents (master_nh, savant_nh) for "reading" ability. - * ========================================================================= */ - static void opp_read_agent_action(OsrsEnv* env, OpponentState* opp) { opp->has_read_this_tick = 0; opp->read_agent_style = ATTACK_STYLE_NONE; @@ -2543,19 +2446,16 @@ static void opp_read_agent_action(OsrsEnv* env, OpponentState* opp) { 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; - /* Extract movement intent */ opp->read_agent_moving = is_move_action(attack) ? 1 : 0; } -/* Get defensive prayer against agent's read attack style */ static inline int opp_get_read_defensive_prayer(OpponentState* opp) { if (opp->read_agent_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; if (opp->read_agent_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; if (opp->read_agent_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; - return -1; /* No read or unknown */ + return -1; } -/* Check if a style would hit agent off-prayer (using read info) */ static inline int opp_style_off_read_prayer(OpponentState* opp, int style) { if (opp->read_agent_prayer == PRAYER_NONE) return 1; /* No read, assume off */ if (style == OPP_STYLE_MAGE && opp->read_agent_prayer != PRAYER_PROTECT_MAGIC) return 1; @@ -2564,12 +2464,6 @@ static inline int opp_style_off_read_prayer(OpponentState* opp, int style) { return 0; /* Would hit on-prayer */ } -/* ========================================================================= - * Boss Policy: Master NH - * Onetick-perfect mechanics + 10% chance to "read" agent's pending action. - * When read succeeds: prays correctly against incoming attack, attacks off-prayer. - * ========================================================================= */ - static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -2781,32 +2675,16 @@ static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { (void)prayer_pct; } -/* ========================================================================= - * Boss Policy: Savant NH - * Onetick-perfect mechanics + 25% chance to "read" agent's pending action. - * Same as master_nh but with higher read chance. - * ========================================================================= */ - static void opp_savant_nh(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); } -/* ========================================================================= - * Boss Policy: Nightmare NH - * Same as master/savant but with 50% read chance - extremely difficult. - * ========================================================================= */ - 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); } -/* ========================================================================= - * Vengeance Fighter: lunar spellbook, melee/range only, no freeze/blood. - * Expert-level prayer/eating, veng on cooldown, melee spec only. - * ========================================================================= */ - static void opp_veng_fighter(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -2924,12 +2802,6 @@ static void opp_veng_fighter(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Blood Healer: sustain fighter using blood barrage as primary healing. - * Works at all tiers — ahrim staff can cast blood spells regardless of gear. - * Farcast-5, reduced food reliance, blood barrage heavy when damaged. - * ========================================================================= */ - static void opp_blood_healer(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -3071,12 +2943,6 @@ static void opp_blood_healer(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Gmaul Combo: KO specialist with spec → gmaul instant follow-up. - * Degrades to improved-style at tier 0 (DDS spec only, no gmaul combo). - * At tier 1+ with gmaul available, fires spec→gmaul for burst KO. - * ========================================================================= */ - #define COMBO_IDLE 0 #define COMBO_SPEC_FIRED 1 @@ -3242,13 +3108,6 @@ static void opp_gmaul_combo(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Range Kiter: ranged-dominant fighter who maintains distance. - * Works at all tiers — rune crossbow does ranged damage at tier 0. - * Gains spec capability at higher tiers (ACB/ZCB/dark bow/morr jav). - * Maintains farcast-5, ice barrage to freeze, ranged primary (~60-70%). - * ========================================================================= */ - static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { Player* self = &env->players[1]; Player* target = &env->players[0]; @@ -3362,10 +3221,8 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { actual_attack = 2; /* ATK */ } - /* Boost potions */ opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); - /* Melee spec (DDS etc) when close — fallback */ int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); int can_melee_spec = (self->special_energy >= melee_spec_cost && target->prayer != PRAYER_PROTECT_MELEE && @@ -3407,10 +3264,6 @@ static void opp_range_kiter(OsrsEnv* env, OpponentState* opp, int* actions) { } } -/* ========================================================================= - * Mixed policy selection (MixedEasy/MixedMedium/MixedHard/MixedHardBalanced) - * ========================================================================= */ - /* MixedEasy weights: panicking=0.18, true_random=0.18, weak_random=0.18, semi_random=0.15, sticky_prayer=0.10, random_eater=0.10, prayer_rookie=0.06, improved=0.05 */ @@ -3458,17 +3311,12 @@ static OpponentType opp_select_from_pool( return pool[pool_size - 1]; } -/* ========================================================================= - * Main entry point: generate opponent action - * ========================================================================= */ - 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; - /* Phase 2 state reset */ opp->fake_switch_pending = 0; opp->fake_switch_style = -1; opp->opponent_prayer_at_fake = -1; diff --git a/ocean/osrs/osrs_special_attacks.h b/ocean/osrs/osrs_special_attacks.h index eb907128ff..59c777c821 100644 --- a/ocean/osrs/osrs_special_attacks.h +++ b/ocean/osrs/osrs_special_attacks.h @@ -87,17 +87,7 @@ static inline int osrs_spec_cost(int weapon_item_idx) { default: return 0; } } - -/* ======================================================================== */ -/* osrs_resolve_spec: dispatch special attack by weapon item index */ -/* */ -/* att_roll: base attack roll (eff_level * (bonus + 64)), unmodified */ -/* max_hit: base max hit, unmodified by spec */ -/* def_roll: target's base defence roll (eff_def * (def_bonus + 64)) */ -/* target_def_level: target's current defence level (for drain calcs) */ -/* rng_state: pointer to xorshift32 RNG state */ -/* ======================================================================== */ - +/** 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 @@ -106,8 +96,6 @@ static inline SpecResult osrs_resolve_spec( switch (weapon_item_idx) { - /* ---- MELEE ---- */ - /* 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: { @@ -329,8 +317,6 @@ static inline SpecResult osrs_resolve_spec( break; } - /* ---- RANGED ---- */ - /* 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: { @@ -433,8 +419,6 @@ static inline SpecResult osrs_resolve_spec( break; } - /* ---- MAGIC ---- */ - /* 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 */ diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h index 9ccb35f7aa..9d3036231a 100644 --- a/ocean/osrs/osrs_types.h +++ b/ocean/osrs/osrs_types.h @@ -6,91 +6,6 @@ * shared ABI, not universal game truth. */ -/* ============================================================================ - * CRITICAL: OSRS TICK-BASED TIMING MODEL - * ============================================================================ - * - * READ THIS BEFORE MODIFYING ANY COMBAT OR MOVEMENT CODE. - * - * The current simulation models a 600ms tick cycle. actions are queued and - * execute on the NEXT tick, not immediately. encounter-specific ordering can - * still layer on top of that shared queue. - * - * --------------------------------------------------------------------------- - * TICK TIMING OVERVIEW - * --------------------------------------------------------------------------- - * - * TICK N (current state): - * - Player SEES: positions, HP, gear, prayers, everything visible - * - Player QUEUES: their reaction to what they see (actions for next tick) - * - Actions execute: NOTHING YET - actions are just queued - * - * TICK N+1 (next tick): - * - Queued actions from tick N EXECUTE (movement first, then attacks) - * - New state becomes visible - * - Player queues new reaction - * - * --------------------------------------------------------------------------- - * MOVEMENT + ATTACK IN SAME TICK - * --------------------------------------------------------------------------- - * - * When you queue an attack, the game automatically handles movement: - * - * Example: dist=3, melee weapon (range=1), queue "attack" - * - Tick N+1: move 2 tiles (running), now dist=1, attack fires - * - Both movement and attack happen in the SAME tick - * - * Example: dist=0 (under target), queue "attack" - * - Tick N+1: auto-step to adjacent tile (dist=1), attack fires - * - The step-out is IMPLICIT - part of the attack action - * - * --------------------------------------------------------------------------- - * CONFLICTING ACTIONS (IMPORTANT!) - * --------------------------------------------------------------------------- - * - * When EXPLICIT movement conflicts with IMPLICIT attack movement: - * - * Example: dist=0, queue BOTH "attack" AND "move under" - * - Attack needs: step out to dist=1 - * - Movement wants: stay at dist=0 - * - RESULT: Explicit movement wins, attack is CANCELLED - * - * This is because the end state cannot be BOTH dist=1 (for attack) AND - * dist=0 (from explicit movement). Explicit actions override implicit ones. - * - * --------------------------------------------------------------------------- - * STEP UNDER STRATEGY (current NH/PvP model) - * --------------------------------------------------------------------------- - * - * Common tactic when opponent is frozen: - * - * Tick 9: You're under frozen opponent (dist=0), queue "attack" ONLY - * Tick 10: Step out to dist=1, attack fires, queue "move under" ONLY - * Tick 11: Move back under (dist=0), opponent couldn't hit you - * - * The frozen opponent can only hit you if they ALSO queue attack on tick 9. - * Both attacks would fire on tick 10 when you're both effectively at dist=1. - * - * --------------------------------------------------------------------------- - * RECORDING FORMAT - * --------------------------------------------------------------------------- - * - * Fight recordings show for each tick: - * - STATE: What the player sees RIGHT NOW (positions, HP, gear, etc.) - * - ACTIONS: What the player QUEUED as reaction (executes NEXT tick) - * - * Example (valid sequence): - * Tick 9 state: dist=3, actions=["RNG"] - * Tick 10 state: dist=1, attack fires (moved 2 tiles + attacked) - * - * Counter-example (conflicting sequence - attack cancelled): - * Tick 9 state: dist=0, actions=["RNG", "under"] - * Tick 10 state: dist=0, NO attack (explicit "under" cancelled the attack) - * The attack needed dist=1, but "under" forced dist=0. Conflict resolved - * by cancelling the attack - explicit movement wins over implicit step-out. - * - * ========================================================================= */ - #ifndef OSRS_TYPES_H #define OSRS_TYPES_H @@ -916,7 +831,6 @@ typedef struct { int potion_cooldown; int karambwan_cooldown; - /* Phase 2: onetick + realistic policy state */ int fake_switch_pending; /* 0/1 */ int fake_switch_style; /* OPP_STYLE_* or -1 */ int opponent_prayer_at_fake; /* OPP_STYLE_* or -1 (style they were praying) */ @@ -925,30 +839,25 @@ typedef struct { int pending_prayer_delay; /* ticks remaining before applying */ int last_target_gear_style; /* OPP_STYLE_* or -1, tracks previous tick */ - /* Per-episode eating thresholds (randomized with noise) */ float eat_triple_threshold; /* base 0.30, range [0.25, 0.35] */ float eat_double_threshold; /* base 0.50, range [0.45, 0.55] */ float eat_brew_threshold; /* base 0.70, range [0.65, 0.75] */ - /* Per-episode randomized decision parameters */ float prayer_accuracy; /* chance of correct defensive prayer [0,1] */ float off_prayer_rate; /* chance of attacking off-prayer [0,1] */ float offensive_prayer_rate; /* chance of using offensive prayer [0,1] */ float action_delay_chance; /* per-tick chance to skip prayer+attack [0,0.3] */ float mistake_rate; /* per-tick chance to pick random prayer [0,0.15] */ - /* Boss opponent reading ability (master_nh, savant_nh) */ float read_chance; /* 0.0-1.0, chance to "read" agent action each tick */ int has_read_this_tick; /* 1 if read succeeded this tick */ AttackStyle read_agent_style; /* agent's pending attack style (if read) */ OverheadPrayer read_agent_prayer;/* agent's pending overhead prayer (if read) */ int read_agent_moving; /* boss read: 1 if agent is moving (not attacking) */ - /* Anti-kite flee tracking */ int prev_dist_to_target; /* previous tick distance for flee tracking */ int target_fleeing_ticks; /* consecutive ticks distance has been increasing */ - /* gmaul_combo state */ int combo_state; /* 0=idle, 1=spec_fired (follow with gmaul next tick) */ float ko_threshold; /* target HP fraction to trigger KO sequence */ diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c index f73e287b2f..5bf6bc519e 100644 --- a/ocean/osrs/osrs_visual.c +++ b/ocean/osrs/osrs_visual.c @@ -494,9 +494,7 @@ static void run_visual(OsrsEnv* env, const char* encounter_name, const char* rep rc->terrain = terrain_load("data/zulrah.terrain"); rc->objects = objects_load("data/zulrah.objects"); - /* Zulrah coordinate alignment - ============================ - three coordinate spaces are in play: + /* 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. @@ -723,8 +721,7 @@ int main(int argc, char** argv) { env.ocean_io.agent_rewards = env.rewards; env.ocean_io.agent_terminals = env.terminals; - printf("OSRS PvP C Environment Demo\n"); - printf("===========================\n\n"); + printf("OSRS PvP C Environment Demo\n\n"); printf("Running single verbose episode...\n"); run_random_episode(&env, 1); diff --git a/ocean/osrs/scripts/ExportItemSprites.java b/ocean/osrs/scripts/ExportItemSprites.java index 07f023542c..053fcb59b4 100644 --- a/ocean/osrs/scripts/ExportItemSprites.java +++ b/ocean/osrs/scripts/ExportItemSprites.java @@ -156,14 +156,8 @@ public static void main(String[] args) throws Exception { SpriteManager spriteManager = new SpriteManager(store); spriteManager.load(); - // texture manager — may fail on modern cache format, potions don't need textures TextureManager textureManager = new TextureManager(store); - try { - textureManager.load(); - } catch (Exception e) { - System.err.println("warning: TextureManager.load() failed: " + e.getMessage()); - System.err.println(" continuing without textures (potions render fine without them)"); - } + textureManager.load(); // parse item IDs to export Set targetIds = new HashSet<>(); @@ -192,27 +186,22 @@ public static void main(String[] args) throws Exception { if (itemDef.name == null || itemDef.name.equalsIgnoreCase("null")) continue; if (targetIds.isEmpty() && itemDef.inventoryModel <= 0) continue; - try { - BufferedImage sprite = ItemSpriteFactory.createSprite( - itemManager, modelProvider, spriteManager, textureManager, - itemDef.id, 1, 1, 0x303030, false); - - if (sprite == null) { - System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): null sprite"); - failed++; - continue; - } - - File out = new File(outDir, itemDef.id + ".png"); - ImageIO.write(sprite, "PNG", out); - exported++; - - System.out.println(" " + itemDef.id + " (" + itemDef.name + "): " - + sprite.getWidth() + "x" + sprite.getHeight()); - } catch (Exception ex) { - System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): " + ex.getMessage()); + 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"); diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py index 609960c413..10e5e8b2b2 100644 --- a/ocean/osrs/scripts/export_inferno_npcs.py +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -13,9 +13,7 @@ """ import argparse -import copy import io -import struct import sys from dataclasses import dataclass, field from pathlib import Path @@ -23,7 +21,6 @@ sys.path.insert(0, str(Path(__file__).parent)) from modern_cache_reader import ( ModernCacheReader, - read_big_smart, read_i32, read_string, read_u8, @@ -32,22 +29,17 @@ read_u32, ) from export_models import ( - MDL2_MAGIC, ModelData, _merge_models, decode_model, - expand_model, load_model_modern, write_models_binary, ) from export_animations import ( - ANIM_MAGIC, - FrameBaseDef, FrameDef, SequenceDef, _parse_normal_frame, load_modern_framebases, - parse_modern_framebase, write_animations_binary, ) from modern_cache_reader import parse_sequence as parse_modern_sequence @@ -525,10 +517,6 @@ def main() -> None: reader = ModernCacheReader(args.modern_cache) output_dir = args.output_dir output_dir.mkdir(parents=True, exist_ok=True) - - # ================================================================ - # step 1: read NPC definitions from config index 2, group 9 - # ================================================================ print("reading NPC definitions from modern cache (index 2, group 9)...") npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) print(f" {len(npc_files)} total NPC entries in group 9") @@ -567,10 +555,6 @@ def main() -> None: all_anim_ids.add(attack_anim) for anim_id in INFERNO_EXTRA_ANIMS.get(npc_id, {}).values(): all_anim_ids.add(anim_id) - - # ================================================================ - # step 2: read SpotAnim/GFX definitions - # ================================================================ print("\n\nreading SpotAnim/GFX definitions (index 2, group 13)...") spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) print(f" {len(spotanim_files)} total spotanim entries") @@ -596,10 +580,6 @@ def main() -> None: print(f" {sorted(all_model_ids)}") print(f"total unique animation IDs to export: {len(all_anim_ids)}") print(f" {sorted(all_anim_ids)}") - - # ================================================================ - # step 3: export NPC models - # ================================================================ print("\n\nexporting NPC + GFX models...") all_models: list[ModelData] = [] @@ -669,10 +649,6 @@ def main() -> None: write_models_binary(models_path, all_models) file_size = models_path.stat().st_size print(f"\nwrote {len(all_models)} models ({file_size:,} bytes) to {models_path}") - - # ================================================================ - # step 4: export animations - # ================================================================ print("\n\nexporting animations...") seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) @@ -746,10 +722,6 @@ def main() -> None: 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) - - # ================================================================ - # step 5: update npc_models.h - # ================================================================ print("\n\nupdating npc_models_inferno.h...") header_path = Path(__file__).resolve().parent.parent / "data" / "npc_models_inferno.h" @@ -814,13 +786,7 @@ def main() -> None: f.write("#endif /* NPC_MODELS_INFERNO_H */\n") print(f"wrote {header_path}") - - # ================================================================ - # step 6: print encounter_inferno.h mapping table - # ================================================================ - print("\n\n========================================") - print("INF_NPC_DEF_IDS mapping table for encounter_inferno.h:") - print("========================================") + print("\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 = { diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py index f4aaf232cd..8caa522819 100644 --- a/ocean/osrs/scripts/export_models.py +++ b/ocean/osrs/scripts/export_models.py @@ -25,6 +25,7 @@ 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 @@ -713,20 +714,12 @@ def _decode_type2(model_id: int, data: bytes) -> ModelData | None: var14 = _read_ubyte(data, off + 7) # has transparency var15 = _read_ubyte(data, off + 8) # has face skins var16 = _read_ubyte(data, off + 9) # has vertex skins - var17 = _read_ubyte(data, off + 10) # has animaya + _ = _read_ubyte(data, off + 10) # has animaya var18 = _read_ushort(data, off + 11) # vertex X len var19 = _read_ushort(data, off + 13) # vertex Y len - var20 = _read_ushort(data, off + 15) # vertex Z len + _ = _read_ushort(data, off + 15) # vertex Z len var21 = _read_ushort(data, off + 17) # face index len - # off+19..20 unused? Actually var22 = readUShort at off+19 - # Wait — the footer is 23 bytes. 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. Plus magic 2 = 21. - # Actually decodeType2 footer reads: 2+2+1+1+1+1+1+1+1+2+2+2+2+2 = 21 data + 2 magic = 23 - # Let me re-check: var9(2)+var10(2)+var11(1)+var12(1)+var13(1)+var14(1)+var15(1)+var16(1)+var17(1) - # +var18(2)+var19(2)+var20(2)+var21(2)+magic(2) = 4+7+8+2 = 21. Hmm that's 21 not 23. - # Actually: 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. So there must be one more ushort. - # Looking at Java: var22 = var4.readUShort() at the end of the footer. That's face tex len. var22 = _read_ushort(data, off + 19) # tex len (= face_tex_len not used by us, but 0xFF-2) - # Actually off + 19 + 2 = off + 21, then magic at off+21..22. Total = 23. Correct. # section offsets (mirrors Java decodeType2) # type2: vertex flags start at offset 0 (var23=0 in Java) @@ -739,7 +732,6 @@ def _decode_type2(model_id: int, data: bytes) -> ModelData | None: if var13 == 255: var24 += var10 - var27 = var24 # face skin offset if var15 == 1: var24 += var10 @@ -750,7 +742,6 @@ def _decode_type2(model_id: int, data: bytes) -> ModelData | None: var29 = var24 # vertex skin offset var24 += var22 # tex len - var30 = var24 # face transparency offset if var14 == 1: var24 += var10 @@ -760,7 +751,6 @@ def _decode_type2(model_id: int, data: bytes) -> ModelData | None: var32 = var24 # face color offset var24 += var10 * 2 - var33 = var24 # texture coords offset var24 += var11 * 6 var34 = var24 # vertex X offset @@ -811,7 +801,7 @@ def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: var16 = _read_ubyte(data, off + 9) # has vertex skins var17 = _read_ushort(data, off + 10) # vertex X len var18 = _read_ushort(data, off + 12) # vertex Y len - var19 = _read_ushort(data, off + 14) # vertex Z len + _ = _read_ushort(data, off + 14) # vertex Z len var20 = _read_ushort(data, off + 16) # face index len # section offset calculation (exact mirror of Java) @@ -824,7 +814,6 @@ def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: if var13 == 255: var22 += var10 - var25 = var22 # face skin offset if var15 == 1: var22 += var10 @@ -836,7 +825,6 @@ def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: if var16 == 1: var22 += var9 - var28 = var22 # face transparency offset if var14 == 1: var22 += var10 @@ -846,7 +834,6 @@ def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: var30 = var22 # face color offset var22 += var10 * 2 - var31 = var22 # texture coords offset var22 += var11 * 6 var32 = var22 # vertex X offset @@ -919,7 +906,6 @@ def _decode_type1(model_id: int, data: bytes) -> ModelData | None: # section offsets (exact mirror of Java decodeType1) var26 = var11 + var9 - var56 = var26 # face render type offset if var12 == 1: var26 += var10 @@ -930,7 +916,6 @@ def _decode_type1(model_id: int, data: bytes) -> ModelData | None: if var13 == 255: var26 += var10 - var30 = var26 # face skin offset if var15 == 1: var26 += var10 @@ -938,7 +923,6 @@ def _decode_type1(model_id: int, data: bytes) -> ModelData | None: if var17 == 1: var26 += var9 - var32 = var26 # face transparency offset if var14 == 1: var26 += var10 @@ -949,7 +933,6 @@ def _decode_type1(model_id: int, data: bytes) -> ModelData | None: if var16 == 1: var26 += var10 * 2 - var35 = var26 # tex map offset var26 += var22 var36 = var26 # face color offset @@ -963,9 +946,7 @@ def _decode_type1(model_id: int, data: bytes) -> ModelData | None: var26 += var20 # texture coordinates - var40 = var26 # tex type 0 coords var26 += tex_type0 * 6 - var41 = var26 # tex type 1-3 coords var26 += tex_type13 * 6 # ... (more tex data, but we don't need it) @@ -1011,7 +992,7 @@ def _decode_type3(model_id: int, data: bytes) -> ModelData | None: var15 = _read_ubyte(data, off + 8) # has face skins var16 = _read_ubyte(data, off + 9) # has face textures var17 = _read_ubyte(data, off + 10) # has vertex skins - var18 = _read_ubyte(data, off + 11) # has animaya + _ = _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 @@ -1036,7 +1017,6 @@ def _decode_type3(model_id: int, data: bytes) -> ModelData | None: # section offsets (exact mirror of Java decodeType3) var28 = var11 + var9 - var58 = var28 # face render type offset if var12 == 1: var28 += var10 @@ -1047,14 +1027,12 @@ def _decode_type3(model_id: int, data: bytes) -> ModelData | None: if var13 == 255: var28 += var10 - var32 = var28 # face skin offset if var15 == 1: var28 += var10 var33 = var28 # tex index / vertex skin region var28 += var24 # tex_index_len - var34 = var28 # face transparency offset if var14 == 1: var28 += var10 @@ -1065,7 +1043,6 @@ def _decode_type3(model_id: int, data: bytes) -> ModelData | None: if var16 == 1: var28 += var10 * 2 - var37 = var28 # tex map offset var28 += var23 # tex_map_len var38 = var28 # FACE COLOR offset @@ -1079,9 +1056,7 @@ def _decode_type3(model_id: int, data: bytes) -> ModelData | None: var28 += var21 # texture coordinates section (we skip but need for total size check) - var42 = var28 # tex type 0 coords var28 += tex_type0 * 6 - var43 = var28 # tex type 1-3 coords var28 += tex_type13 * 6 # ... (more tex data for type 1-3 and type 2) @@ -1214,7 +1189,7 @@ def _merge_models(models: list[ModelData]) -> ModelData: def expand_model( model: ModelData, tex_colors: dict[int, int] | None = None, - atlas: "TextureAtlas | 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). @@ -1282,9 +1257,15 @@ def expand_model( nx *= bias ny *= bias nz *= bias - ax -= nx; ay -= ny; az -= nz - bx -= nx; by -= ny; bz -= nz - cx -= nx; cy -= ny; cz -= nz + 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]) @@ -1796,7 +1777,7 @@ def _load_model(mid: int) -> ModelData | None: dragon_bolt_base.face_colors[fi] = dst dragon_bolt_base.model_id = 0xD0001 # synthetic ID for dragon bolt wield_models.append(dragon_bolt_base) - print(f" built dragon bolt model (recolored 3135 -> 0xD0001)") + print(" built dragon bolt model (recolored 3135 -> 0xD0001)") print(f"\n{len(needed_models)} unique equipment + spotanim models to export") diff --git a/ocean/osrs/scripts/export_terrain.py b/ocean/osrs/scripts/export_terrain.py index 5868adf6bc..6f96d0b5e0 100644 --- a/ocean/osrs/scripts/export_terrain.py +++ b/ocean/osrs/scripts/export_terrain.py @@ -126,10 +126,10 @@ def _rgb_to_hsl(rgb: int, flo: FloorDef) -> None: h = 0.0 s = 0.0 - l = (mn + mx) / 2.0 + lightness = (mn + mx) / 2.0 if mn != mx: - if l < 0.5: + if lightness < 0.5: s = (mx - mn) / (mx + mn) else: s = (mx - mn) / (2.0 - mx - mn) @@ -145,13 +145,13 @@ def _rgb_to_hsl(rgb: int, flo: FloorDef) -> None: flo.hue = max(0, min(255, int(h * 256.0))) flo.saturation = max(0, min(255, int(s * 256.0))) - flo.lightness = max(0, min(255, int(l * 256.0))) + flo.lightness = max(0, min(255, int(lightness * 256.0))) flo.luminance = flo.lightness - if l > 0.5: - flo.blend_hue_multiplier = int((1.0 - l) * s * 512.0) + if lightness > 0.5: + flo.blend_hue_multiplier = int((1.0 - lightness) * s * 512.0) else: - flo.blend_hue_multiplier = int(l * s * 512.0) + flo.blend_hue_multiplier = int(lightness * s * 512.0) if flo.blend_hue_multiplier < 1: flo.blend_hue_multiplier = 1 @@ -160,17 +160,17 @@ def _rgb_to_hsl(rgb: int, flo: FloorDef) -> None: flo.hsl16 = _hsl24to16(flo.hue, flo.saturation, flo.luminance) -def _hsl24to16(h: int, s: int, l: int) -> int: +def _hsl24to16(h: int, s: int, lightness: int) -> int: """Convert 24-bit HSL to 16-bit packed HSL (FloorDefinition.hsl24to16).""" - if l > 179: + if lightness > 179: s //= 2 - if l > 192: + if lightness > 192: s //= 2 - if l > 217: + if lightness > 217: s //= 2 - if l > 243: + if lightness > 243: s //= 2 - return ((h // 4) << 10) + ((s // 32) << 7) + l // 2 + return ((h // 4) << 10) + ((s // 32) << 7) + lightness // 2 @@ -417,12 +417,12 @@ def apply_light(hsl16: int, light: int) -> int: """Apply lighting intensity to an HSL16 color (method187).""" if hsl16 == -1: return 0xBC614E - l = (light * (hsl16 & 0x7F)) // 128 - if l < 2: - l = 2 - elif l > 126: - l = 126 - return (hsl16 & 0xFF80) + l + 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]: diff --git a/ocean/osrs/tests/test_collision.c b/ocean/osrs/tests/test_collision.c index 68b2b34c7a..8b577cbc7f 100644 --- a/ocean/osrs/tests/test_collision.c +++ b/ocean/osrs/tests/test_collision.c @@ -34,10 +34,6 @@ static int tests_failed = 0; } \ } while(0) -/* ========================================================================= - * collision map basics - * ========================================================================= */ - TEST(test_null_map_all_traversable) { ASSERT(collision_traversable_north(NULL, 0, 100, 100)); ASSERT(collision_traversable_south(NULL, 0, 100, 100)); @@ -94,10 +90,6 @@ TEST(test_flag_set_and_unset) { collision_map_free(map); } -/* ========================================================================= - * directional traversal checks - * ========================================================================= */ - TEST(test_blocked_tile_not_traversable) { CollisionMap* map = collision_map_create(); @@ -162,10 +154,6 @@ TEST(test_multi_tile_occupant) { collision_map_free(map); } -/* ========================================================================= - * binary save/load - * ========================================================================= */ - TEST(test_save_and_load) { CollisionMap* map = collision_map_create(); collision_mark_blocked(map, 0, 3200, 3520); @@ -190,10 +178,6 @@ TEST(test_save_and_load) { remove(path); } -/* ========================================================================= - * pathfinding - * ========================================================================= */ - TEST(test_pathfind_already_at_dest) { PathResult r = pathfind_step(NULL, 0, 100, 100, 100, 100, NULL, NULL); ASSERT(r.found == 1); @@ -279,13 +263,6 @@ TEST(test_pathfind_respects_wall_flags) { collision_map_free(map); } -/* ========================================================================= - * integration: step_toward_destination with collision - * - * we can't include the full osrs_pvp.h here (too many deps), so we - * replicate the core logic of step_toward_destination for testing. - * ========================================================================= */ - typedef struct { int x, y, dest_x, dest_y; } TestPlayer; static int test_step_toward(TestPlayer* p, const CollisionMap* cmap) { @@ -356,10 +333,6 @@ TEST(test_step_no_collision_map) { ASSERT(p.x == 101 && p.y == 101); } -/* ========================================================================= - * wilderness coordinates test — verify the region hash works for real coords - * ========================================================================= */ - TEST(test_wilderness_coordinates) { CollisionMap* map = collision_map_create(); @@ -377,13 +350,8 @@ TEST(test_wilderness_coordinates) { collision_map_free(map); } -/* ========================================================================= - * main - * ========================================================================= */ - int main(void) { - printf("collision system tests\n"); - printf("======================\n\n"); + printf("collision system tests\n\n"); printf("collision map basics:\n"); RUN(test_null_map_all_traversable); @@ -416,7 +384,6 @@ int main(void) { printf("\nwilderness coordinates:\n"); RUN(test_wilderness_coordinates); - printf("\n======================\n"); printf("%d passed, %d failed\n", tests_passed, tests_failed); return tests_failed > 0 ? 1 : 0; } diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c index 895df781f2..86b15baf8d 100644 --- a/ocean/osrs/tests/test_combat_math.c +++ b/ocean/osrs/tests/test_combat_math.c @@ -54,15 +54,11 @@ static int tests_failed = 0; (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"); @@ -108,14 +104,10 @@ static void test_hit_chance(void) { 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"); @@ -134,13 +126,9 @@ static void test_npc_melee_max_hit(void) { /* 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"); @@ -156,13 +144,9 @@ static void test_npc_ranged_max_hit(void) { /* 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"); @@ -172,15 +156,11 @@ static void test_npc_magic_max_hit(void) { 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"); @@ -201,13 +181,9 @@ static void test_npc_attack_roll(void) { 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"); @@ -229,15 +205,11 @@ static void test_npc_max_hit_dispatch(void) { /* 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"); @@ -267,13 +239,9 @@ static void test_player_def_roll(void) { 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"); @@ -290,16 +258,12 @@ static void test_player_def_bonus(void) { 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"); @@ -329,16 +293,12 @@ static void test_prayer_correct(void) { 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"); @@ -362,14 +322,10 @@ static void test_hit_delays(void) { 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"); @@ -394,8 +350,6 @@ static void test_dist_to_npc(void) { /* 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 */ @@ -413,8 +367,6 @@ static void test_dist_to_npc(void) { /* */ /* 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; @@ -479,16 +431,12 @@ static void test_tbow_multipliers(void) { 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"); @@ -526,16 +474,12 @@ static void test_blowpipe_spec(void) { /* 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); @@ -719,13 +663,9 @@ static void test_loadout_magic_no_prayer(void) { /* 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"); @@ -768,13 +708,9 @@ static void test_loadout_full_ranged(void) { 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"); @@ -825,14 +761,10 @@ static void test_update_loadout_level(void) { /* 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"); @@ -857,14 +789,10 @@ static void test_full_npc_attack_scenario(void) { 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"); @@ -906,14 +834,10 @@ static void test_rng(void) { } 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"); @@ -974,14 +898,10 @@ static void test_barrage_resolve(void) { 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"); @@ -1022,13 +942,9 @@ static void test_loadout_def_bonuses(void) { 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"); diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c index c4f135fb1a..ddc17dc9e1 100644 --- a/ocean/osrs/tests/test_item_effects.c +++ b/ocean/osrs/tests/test_item_effects.c @@ -57,15 +57,11 @@ static int tests_failed = 0; 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; @@ -89,15 +85,11 @@ static float ref_tbow_dmg(int magic) { 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"); @@ -149,14 +141,10 @@ static void test_tbow_acc_edge_cases(void) { } 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"); @@ -196,13 +184,9 @@ static void test_tbow_dmg_edge_cases(void) { 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"); @@ -216,14 +200,10 @@ static void test_tbow_cap_behavior(void) { 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"); @@ -257,14 +237,10 @@ static void test_prayer_pvp_reduction(void) { 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"); @@ -305,15 +281,11 @@ static void test_prayer_pve_block(void) { 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"); @@ -339,16 +311,12 @@ static void test_prayer_wrong_no_reduction(void) { 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"); @@ -393,16 +361,12 @@ static void test_npc_def_roll_vs_player(void) { 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"); @@ -445,13 +409,9 @@ static void test_player_att_roll_full_mage(void) { 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"); @@ -489,13 +449,9 @@ static void test_player_att_roll_melee_with_defender(void) { 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"); @@ -528,13 +484,9 @@ static void test_player_att_roll_ranged_blowpipe(void) { 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"); @@ -579,14 +531,10 @@ static void test_loadout_empty_all_styles(void) { /* 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"); @@ -632,14 +580,10 @@ static void test_loadout_all_slots_filled(void) { 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"); @@ -680,14 +624,10 @@ static void test_loadout_two_handed_weapon(void) { 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"); @@ -713,14 +653,10 @@ static void test_two_handed_classification(void) { 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"); @@ -798,14 +734,10 @@ static void test_hit_chance_player_vs_npc(void) { 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"); @@ -822,14 +754,10 @@ static void test_def_bonus_selection(void) { 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"); diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c index 6e7cb6bfd6..87345ef33c 100644 --- a/ocean/osrs/tests/test_npc_movement.c +++ b/ocean/osrs/tests/test_npc_movement.c @@ -124,13 +124,9 @@ static void test_far_npc_walks(void) { 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); diff --git a/ocean/osrs/tests/test_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c index 127c8c49df..3cd0f1c514 100644 --- a/ocean/osrs/tests/test_special_attacks.c +++ b/ocean/osrs/tests/test_special_attacks.c @@ -55,13 +55,9 @@ static int tests_failed = 0; (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"); @@ -100,14 +96,10 @@ static void test_magic_spec_costs(void) { 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"); @@ -145,8 +137,6 @@ static void test_melee_spec_acc_multipliers(void) { /* 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 */ @@ -155,8 +145,6 @@ static void test_melee_spec_acc_multipliers(void) { /* 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"); @@ -191,15 +179,11 @@ static void test_melee_spec_str_multipliers(void) { /* 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"); @@ -210,15 +194,11 @@ static void test_ranged_spec_acc_multipliers(void) { 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"); @@ -229,25 +209,17 @@ static void test_ranged_spec_str_multipliers(void) { 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"); @@ -258,14 +230,10 @@ static void test_blowpipe_spec_constants(void) { 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"); @@ -302,8 +270,6 @@ static void test_blowpipe_spec_resolve(void) { /* 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 */ @@ -317,8 +283,6 @@ static void test_blowpipe_spec_resolve(void) { /* */ /* 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"); @@ -349,15 +313,11 @@ static void test_dragon_claws_cascade(void) { 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"); @@ -379,15 +339,11 @@ static void test_voidwaker_mechanics(void) { 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"); @@ -409,14 +365,10 @@ static void test_vls_mechanics(void) { 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"); @@ -439,15 +391,11 @@ static void test_statius_warhammer_mechanics(void) { 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"); @@ -469,13 +417,9 @@ static void test_bgs_mechanics(void) { 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"); @@ -484,13 +428,9 @@ static void test_zgs_mechanics(void) { 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"); @@ -499,15 +439,11 @@ static void test_sgs_mechanics(void) { 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"); @@ -527,8 +463,6 @@ static void test_ancient_gs_mechanics(void) { 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. */ @@ -536,8 +470,6 @@ static void test_ancient_gs_mechanics(void) { /* 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"); @@ -567,16 +499,12 @@ static void test_dark_bow_mechanics(void) { /* 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"); @@ -630,15 +558,11 @@ static void test_morrigans_bleed(void) { 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"); @@ -659,16 +583,12 @@ static void test_volatile_staff_mechanics(void) { 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"); @@ -692,13 +612,9 @@ static void test_double_hit_specs(void) { 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"); @@ -706,14 +622,10 @@ static void test_granite_maul_mechanics(void) { 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"); @@ -721,14 +633,10 @@ static void test_ballista_mechanics(void) { 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"); @@ -738,14 +646,10 @@ static void test_crossbow_specs(void) { 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"); @@ -766,13 +670,9 @@ static void test_melee_spec_two_handed(void) { 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"); @@ -782,15 +682,11 @@ static void test_melee_spec_bonus_types(void) { 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"); @@ -817,13 +713,9 @@ static void test_ranged_spec_hit_delays(void) { 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"); @@ -852,15 +744,11 @@ static void test_spec_energy_checks(void) { 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"); @@ -943,15 +831,11 @@ static void test_max_hit_with_spec_mult(void) { 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"); @@ -1020,14 +904,10 @@ static void test_hit_chance_with_spec_acc(void) { 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; diff --git a/ocean/osrs/tools/export_encounter_npcs.py b/ocean/osrs/tools/export_encounter_npcs.py index 9c2be219ee..a8968e30d0 100644 --- a/ocean/osrs/tools/export_encounter_npcs.py +++ b/ocean/osrs/tools/export_encounter_npcs.py @@ -23,7 +23,6 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) from modern_cache_reader import ( ModernCacheReader, - read_big_smart, read_i32, read_string, read_u8, @@ -33,17 +32,13 @@ ) from modern_cache_reader import parse_sequence as parse_modern_sequence from export_models import ( - MDL2_MAGIC, ModelData, _merge_models, decode_model, - expand_model, load_model_modern, write_models_binary, ) from export_animations import ( - ANIM_MAGIC, - FrameBaseDef, FrameDef, SequenceDef, _parse_normal_frame, @@ -53,7 +48,7 @@ # gameval parser from tools dir sys.path.insert(0, str(Path(__file__).parent)) -from gameval_parser import load_gameval, resolve_names, reverse_lookup +from gameval_parser import load_gameval, resolve_names # modern cache layout MODERN_NPC_CONFIG_GROUP = 9 @@ -62,9 +57,6 @@ MODERN_FRAME_INDEX = 0 MODERN_FRAMEBASE_INDEX = 1 - -# ---- dataclasses (copied from export_inferno_npcs.py) ---- - @dataclass class NpcDef: """NPC definition from modern OSRS cache.""" @@ -106,9 +98,6 @@ class SpotAnimDef: ambient: int = 0 contrast: int = 0 - -# ---- cache parsers (copied from export_inferno_npcs.py) ---- - 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) @@ -323,9 +312,6 @@ def parse_modern_spotanim(spotanim_id: int, data: bytes) -> SpotAnimDef: return d - -# ---- model helpers (copied from export_inferno_npcs.py) ---- - 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): @@ -346,9 +332,6 @@ def apply_scale(md: ModelData, width_scale: int, height_scale: int) -> None: md.vertices_y[i] = int(md.vertices_y[i] * hs) md.vertices_z[i] = int(md.vertices_z[i] * ws) - -# ---- main pipeline ---- - def main() -> None: """Manifest-driven export of encounter NPC models + animations.""" parser = argparse.ArgumentParser( @@ -373,17 +356,9 @@ def main() -> None: args = parser.parse_args() group = args.group group_upper = group.upper() - - # ================================================================ - # step 1: load gameval constants - # ================================================================ 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") - - # ================================================================ - # step 2: load manifest, filter by group - # ================================================================ print(f"\nloading manifest {args.manifest}, filtering group={group!r}...") with open(args.manifest) as f: manifest = json.load(f) @@ -393,10 +368,6 @@ def main() -> None: 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}") - - # ================================================================ - # step 3: open cache, read NPC + spotanim configs - # ================================================================ reader = ModernCacheReader(args.modern_cache) output_dir = args.output_dir output_dir.mkdir(parents=True, exist_ok=True) @@ -458,10 +429,6 @@ def main() -> None: 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}") - - # ================================================================ - # step 4: export NPC models - # ================================================================ print("\nexporting NPC models...") all_models: list[ModelData] = [] @@ -494,10 +461,6 @@ def main() -> None: 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") - - # ================================================================ - # step 5: resolve spotanims, export GFX models - # ================================================================ spotanim_defs: dict[int, SpotAnimDef] = {} spotanim_name_for_id: dict[int, str] = {} @@ -545,18 +508,10 @@ def main() -> None: print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") exported_gfx_models.add(md.model_id) all_models.append(md) - - # ================================================================ - # step 6: write models binary - # ================================================================ 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}") - - # ================================================================ - # step 7: export animations (follows export_inferno_npcs.py lines 618-690) - # ================================================================ print("\nexporting animations...") seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) @@ -632,10 +587,6 @@ def main() -> None: 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}") - - # ================================================================ - # step 8: write C header - # ================================================================ prefix = group_upper[:3] + "_GEN" header_path = output_dir / f"npc_models_{group}.h" @@ -644,7 +595,7 @@ def main() -> None: print(f"\nwriting C header {header_path}...") with open(header_path, "w") as f: - f.write(f"/* generated by tools/export_encounter_npcs.py -- do not edit */\n") + 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') diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c index ab4f47512e..8e6048e40f 100644 --- a/ocean/osrs_pvp/binding.c +++ b/ocean/osrs_pvp/binding.c @@ -16,12 +16,7 @@ #include "osrs_render.h" #pragma GCC diagnostic pop -/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsEnv. - * vecenv.h's create_static_vec assigns to env->observations, env->actions, - * env->rewards, env->terminals directly. These fields must match vecenv's - * expected types (void*, float*, float*, float*). The embedded OsrsEnv has - * its own identically-named fields with different types — pvp_init sets those - * to internal inline buffers, so there's no conflict. */ +/* vecenv-compatible header fields must stay first. */ typedef struct { void* observations; float* actions; @@ -33,7 +28,6 @@ typedef struct { OsrsEnv pvp; - /* staging buffers for type conversion */ int ocean_acts_staging[NUM_ACTION_HEADS]; unsigned char ocean_term_staging; } PvpEnv; @@ -44,22 +38,15 @@ typedef struct { #define OBS_TENSOR_T FloatTensor #define Env PvpEnv -/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h - * because vecenv.h calls them inside its implementation section without - * forward-declaring them (they're expected to come from the env header). */ - void c_step(Env* env) { - /* float actions from vecenv → int staging for PVP */ for (int i = 0; i < NUM_ATNS; i++) { env->ocean_acts_staging[i] = (int)env->actions[i]; } pvp_step(&env->pvp); - /* terminal: unsigned char → float for vecenv */ env->terminals[0] = (float)env->ocean_term_staging; - /* copy PVP log to wrapper log on episode end */ if (env->ocean_term_staging) { env->log.episode_return += env->pvp.log.episode_return; env->log.episode_length += env->pvp.log.episode_length; @@ -83,8 +70,6 @@ void c_step(Env* env) { } void c_reset(Env* env) { - /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because - * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ env->pvp.ocean_io.agent_obs = (float*)env->observations; env->pvp.ocean_io.agent_rewards = env->rewards; env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; @@ -109,13 +94,6 @@ void my_init(Env* env, Dict* kwargs) { pvp_init(&env->pvp); - /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside - * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers - * (env->observations, env->actions, env->rewards, env->terminals are NULL - * at this point). c_reset runs after buffer assignment and does the wiring. - * - * For now, point ocean pointers at internal staging so pvp_reset doesn't - * crash on writes to ocean_term/ocean_rew. */ env->pvp.ocean_io.agent_obs = NULL; env->pvp.ocean_io.agent_rewards = env->pvp._rews_buf; env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; @@ -123,7 +101,6 @@ void my_init(Env* env, Dict* kwargs) { env->pvp.ocean_io.agent_obs_p1 = NULL; env->pvp.ocean_io.selfplay_mask = NULL; - /* config from Dict (all values are double) */ env->pvp.pvp_runtime.use_c_opponent = 1; env->pvp.auto_reset = 1; env->pvp.is_lms = 1; @@ -137,7 +114,6 @@ void my_init(Env* env, Dict* kwargs) { DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; - /* reward shaping coefficients (same defaults as ocean_binding.c) */ env->pvp.shaping.damage_dealt_coef = 0.005f; env->pvp.shaping.damage_received_coef = -0.005f; env->pvp.shaping.correct_prayer_bonus = 0.03f; @@ -164,14 +140,11 @@ void my_init(Env* env, Dict* kwargs) { env->pvp.shaping.click_penalty_threshold = 5; env->pvp.shaping.click_penalty_coef = -0.003f; - /* gear: default tier 0 (basic LMS) */ 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 sets up game state (players, positions, gear, etc.) - * but does NOT write to ocean buffers — that happens in c_reset. */ pvp_reset(&env->pvp); } @@ -206,10 +179,6 @@ void my_log(Log* log, Dict* out) { dict_set(out, "score", score); } -/* ======================================================================== - * PFSP: set/get opponent pool weights across all envs - * ======================================================================== */ - 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; @@ -220,9 +189,6 @@ void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int p envs[e].pvp.pvp_runtime.pfsp.pool[i] = (OpponentType)pool[i]; envs[e].pvp.pvp_runtime.pfsp.cum_weights[i] = cum_weights[i]; } - /* Only reset on first configuration — restarts the episode that was started - * during env creation before the pool was set (would have used fallback opponent). - * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ if (was_unconfigured) { c_reset(&envs[e]); } @@ -243,7 +209,6 @@ void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes out_episodes[i] = 0.0f; } - /* Aggregate and reset (read-and-reset pattern) */ for (int e = 0; e < vec->size; e++) { for (int i = 0; i < envs[e].pvp.pvp_runtime.pfsp.pool_size; i++) { out_wins[i] += envs[e].pvp.pvp_runtime.pfsp.wins[i]; diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c index 28f49fd9d0..b0483ff274 100644 --- a/ocean/osrs_zulrah/binding.c +++ b/ocean/osrs_zulrah/binding.c @@ -20,12 +20,9 @@ #include "osrs_render.h" #pragma GCC diagnostic pop -/* total obs = raw obs + action mask */ #define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) -/* wrapper struct: vecenv-compatible fields at top + encounter state. - * vecenv.h's create_static_vec assigns env->observations, env->actions, - * env->rewards, env->terminals directly. */ +/* vecenv-compatible header fields must stay first. */ typedef struct { void* observations; float* actions; @@ -37,7 +34,6 @@ typedef struct { EncounterState* enc_state; - /* staging buffer for action type conversion */ int acts_staging[ZUL_NUM_ACTION_HEADS]; unsigned char term_staging; @@ -50,8 +46,6 @@ typedef struct { #define OBS_TENSOR_T FloatTensor #define Env ZulrahEnv -/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h */ - void c_step(Env* env) { int used_human_commands = 0; RenderClient* render_client = (RenderClient*)env->render_env.client; @@ -81,15 +75,12 @@ void c_step(Env* env) { if (!used_human_commands) ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); - /* write obs + mask directly (mask appended after raw obs) */ float* obs = (float*)env->observations; ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); - /* reward */ env->rewards[0] = ENCOUNTER_ZULRAH.get_reward(env->enc_state); - /* terminal */ int is_term = ENCOUNTER_ZULRAH.is_terminal(env->enc_state); env->term_staging = (unsigned char)is_term; env->terminals[0] = (float)is_term; @@ -112,7 +103,6 @@ void c_step(Env* env) { env->log.wave += (float)zs->total_phases_completed; env->log.n += 1.0f; - /* auto-reset */ ENCOUNTER_ZULRAH.reset(env->enc_state, 0); ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); @@ -162,7 +152,6 @@ void c_render(Env* env) { rc->npc_anim_cache = anim_cache_load("data/zulrah.anims"); } - /* eval pacing: sleep to match tick rate (9/0 keys slow/speed up) */ RenderClient* rc = (RenderClient*)re->client; if (rc && rc->ticks_per_second > 0.0f) { double interval = 1.0 / rc->ticks_per_second; @@ -179,7 +168,6 @@ void my_init(Env* env, Dict* kwargs) { env->enc_state = ENCOUNTER_ZULRAH.create(); memset(&env->log, 0, sizeof(Log)); - /* gear tier config (default 0 = budget) */ DictItem* gear = dict_get_unsafe(kwargs, "gear_tier"); if (gear) { ENCOUNTER_ZULRAH.put_int(env->enc_state, "gear_tier", (int)gear->value); @@ -193,7 +181,6 @@ void my_log(Log* log, Dict* out) { 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); @@ -205,7 +192,6 @@ void my_log(Log* log, Dict* out) { dict_set(out, "venom_ticks", log->brews_used); /* reused field */ dict_set(out, "phases_completed", log->wave); /* reused field */ - /* composite score: winrate-gated efficiency */ float wr = log->wins; float speed_bonus = (wr > 0.1f) ? (1.0f - log->episode_length / (float)ZUL_MAX_TICKS) * 0.3f : 0.0f;