From 2ad00435239b07d33f90ab6db8bb2f23c2e7ed53 Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 24 Jul 2026 12:31:04 +0700 Subject: [PATCH] benilla: initial public release A from-scratch World of Warcraft 1.12.1 client in Rust + Bevy. Co-Authored-By: Claude Fable 5 --- .gitignore | 21 + Cargo.lock | 8323 +++++++++++++++++ Cargo.toml | 40 + LICENSE-APACHE | 202 + LICENSE-MIT | 21 + README.md | 82 + crates/benilla-adt/Cargo.toml | 9 + crates/benilla-adt/src/combined_alpha.rs | 173 + crates/benilla-adt/src/lib.rs | 668 ++ crates/benilla-assets/Cargo.toml | 15 + crates/benilla-assets/src/adt.rs | 252 + crates/benilla-assets/src/blp.rs | 187 + crates/benilla-assets/src/coords.rs | 279 + crates/benilla-assets/src/lib.rs | 485 + crates/benilla-assets/src/m2.rs | 582 ++ crates/benilla-assets/src/minimap_grid.rs | 79 + crates/benilla-assets/src/model.rs | 773 ++ crates/benilla-assets/src/model/anims.rs | 437 + crates/benilla-assets/src/terrain.rs | 245 + crates/benilla-assets/src/wdt.rs | 92 + crates/benilla-assets/src/wmo.rs | 647 ++ crates/benilla-blp/Cargo.toml | 12 + crates/benilla-blp/src/bin/blpx.rs | 25 + crates/benilla-blp/src/lib.rs | 377 + crates/benilla-bytes/Cargo.toml | 8 + crates/benilla-bytes/src/lib.rs | 152 + crates/benilla-dbc/Cargo.toml | 9 + crates/benilla-dbc/src/lib.rs | 620 ++ crates/benilla-formats/Cargo.toml | 19 + .../examples/dump_anim_names.rs | 25 + .../examples/dump_bone_keys.rs | 69 + crates/benilla-formats/examples/dump_file.rs | 18 + crates/benilla-formats/src/anim_data.rs | 195 + crates/benilla-formats/src/area_poi.rs | 188 + crates/benilla-formats/src/area_sound.rs | 381 + crates/benilla-formats/src/area_table.rs | 180 + .../src/bank_bag_slot_prices.rs | 131 + .../src/bin/benilla-extract/m2dump.rs | 626 ++ .../src/bin/benilla-extract/main.rs | 390 + .../src/bin/benilla-extract/scan.rs | 1139 +++ .../src/bin/benilla-extract/spellvis.rs | 70 + crates/benilla-formats/src/bin/mpqx.rs | 45 + crates/benilla-formats/src/chain.rs | 139 + .../src/characters/customization.rs | 742 ++ .../benilla-formats/src/characters/geosets.rs | 423 + crates/benilla-formats/src/characters/mod.rs | 28 + .../src/characters/sections.rs | 651 ++ crates/benilla-formats/src/creature_sound.rs | 213 + crates/benilla-formats/src/creature_types.rs | 100 + crates/benilla-formats/src/creatures.rs | 415 + crates/benilla-formats/src/dbc.rs | 85 + crates/benilla-formats/src/durability.rs | 185 + crates/benilla-formats/src/elevators.rs | 278 + crates/benilla-formats/src/emotes.rs | 247 + .../src/environmental_damage.rs | 105 + crates/benilla-formats/src/factions.rs | 636 ++ crates/benilla-formats/src/footsteps.rs | 192 + crates/benilla-formats/src/gameobjects.rs | 116 + crates/benilla-formats/src/ground_effects.rs | 487 + crates/benilla-formats/src/item_sounds.rs | 139 + crates/benilla-formats/src/items.rs | 353 + crates/benilla-formats/src/itemsets.rs | 139 + crates/benilla-formats/src/itemsubclass.rs | 216 + crates/benilla-formats/src/lib.rs | 570 ++ crates/benilla-formats/src/light.rs | 611 ++ .../benilla-formats/src/light/atmosphere.rs | 184 + crates/benilla-formats/src/light/tables.rs | 156 + crates/benilla-formats/src/liquid.rs | 324 + crates/benilla-formats/src/loading_screen.rs | 116 + crates/benilla-formats/src/lock.rs | 199 + crates/benilla-formats/src/lock_type.rs | 148 + crates/benilla-formats/src/maps.rs | 120 + .../benilla-formats/src/minimap_translate.rs | 150 + crates/benilla-formats/src/models/anim.rs | 843 ++ .../src/models/anim_summary.rs | 311 + crates/benilla-formats/src/models/bounds.rs | 119 + .../benilla-formats/src/models/collision.rs | 193 + crates/benilla-formats/src/models/key_anim.rs | 167 + .../benilla-formats/src/models/m2_batches.rs | 709 ++ crates/benilla-formats/src/models/mat_anim.rs | 300 + crates/benilla-formats/src/models/mod.rs | 140 + crates/benilla-formats/src/models/records.rs | 449 + crates/benilla-formats/src/models/tex_anim.rs | 143 + crates/benilla-formats/src/models/types.rs | 412 + .../benilla-formats/src/models/wmo/group.rs | 580 ++ crates/benilla-formats/src/models/wmo/mod.rs | 76 + crates/benilla-formats/src/models/wmo/root.rs | 445 + crates/benilla-formats/src/npc_greeting.rs | 168 + crates/benilla-formats/src/particles.rs | 858 ++ crates/benilla-formats/src/quest_headers.rs | 81 + crates/benilla-formats/src/ribbons.rs | 347 + crates/benilla-formats/src/sheathe.rs | 123 + crates/benilla-formats/src/skill_lines.rs | 630 ++ crates/benilla-formats/src/sound_entries.rs | 218 + crates/benilla-formats/src/sound_provider.rs | 173 + crates/benilla-formats/src/sound_water.rs | 100 + crates/benilla-formats/src/spell_focus.rs | 95 + crates/benilla-formats/src/spell_visual.rs | 713 ++ .../benilla-formats/src/spells/cast_times.rs | 128 + .../src/spells/catalog_tests.rs | 661 ++ crates/benilla-formats/src/spells/display.rs | 451 + crates/benilla-formats/src/spells/duration.rs | 152 + crates/benilla-formats/src/spells/forms.rs | 92 + crates/benilla-formats/src/spells/mod.rs | 642 ++ crates/benilla-formats/src/spells/radius.rs | 88 + crates/benilla-formats/src/spells/ranges.rs | 120 + crates/benilla-formats/src/spells/tokens.rs | 399 + crates/benilla-formats/src/stationery.rs | 73 + crates/benilla-formats/src/talents.rs | 329 + crates/benilla-formats/src/taxi.rs | 189 + crates/benilla-formats/src/taxi_nodes.rs | 154 + crates/benilla-formats/src/taxi_path.rs | 124 + crates/benilla-formats/src/terrain.rs | 770 ++ .../benilla-formats/src/transport_period.rs | 221 + crates/benilla-formats/src/transports/mod.rs | 826 ++ .../benilla-formats/src/transports/tests.rs | 273 + crates/benilla-formats/src/unit_blood.rs | 195 + crates/benilla-formats/src/value_track.rs | 454 + crates/benilla-formats/src/wdl.rs | 368 + crates/benilla-formats/src/weapon_impact.rs | 141 + crates/benilla-formats/src/wmo_area.rs | 244 + crates/benilla-formats/src/world_map_area.rs | 195 + .../src/world_map_continent.rs | 194 + .../benilla-formats/src/world_map_overlay.rs | 171 + crates/benilla-formats/src/zone_map.rs | 66 + crates/benilla-formats/tests/billboards.rs | 86 + .../benilla-formats/tests/blob_shadow_box.rs | 59 + crates/benilla-formats/tests/blp_alpha.rs | 33 + crates/benilla-formats/tests/chain.rs | 963 ++ crates/benilla-formats/tests/ground_quad.rs | 59 + crates/benilla-formats/tests/item_icons.rs | 47 + .../benilla-formats/tests/m2_anim_events.rs | 60 + crates/benilla-formats/tests/m2_billboard.rs | 79 + crates/benilla-formats/tests/m2_color.rs | 60 + crates/benilla-formats/tests/m2_emissive.rs | 35 + .../tests/m2_global_seq_constant.rs | 65 + crates/benilla-formats/tests/m2_idle_anim.rs | 94 + crates/benilla-formats/tests/m2_lights.rs | 109 + .../tests/m2_playable_animation_lookup.rs | 46 + .../tests/m2_portrait_camera.rs | 80 + .../tests/m2_sequence_bounds.rs | 46 + crates/benilla-formats/tests/particles.rs | 258 + crates/benilla-formats/tests/ribbons.rs | 74 + .../tests/selection_ring_radius.rs | 58 + crates/benilla-formats/tests/spell_catalog.rs | 169 + .../tests/transport_speed_profile.rs | 120 + crates/benilla-formats/tests/wmo_fogs.rs | 54 + crates/benilla-formats/tests/wmo_lights.rs | 48 + crates/benilla-formats/tests/wmo_liquid.rs | 58 + crates/benilla-m2/Cargo.toml | 9 + crates/benilla-m2/examples/dump_attach.rs | 38 + crates/benilla-m2/examples/dump_skin.rs | 70 + crates/benilla-m2/examples/dump_verts.rs | 39 + crates/benilla-m2/src/error.rs | 22 + crates/benilla-m2/src/lib.rs | 487 + crates/benilla-m2/src/model.rs | 289 + crates/benilla-m2/src/skin.rs | 137 + crates/benilla-m2/src/tests.rs | 367 + crates/benilla-m2/src/track.rs | 107 + crates/benilla-mpq/Cargo.toml | 16 + crates/benilla-mpq/src/crypto.rs | 141 + crates/benilla-mpq/src/lib.rs | 662 ++ crates/benilla-protocol/Cargo.toml | 12 + .../examples/cooldown_probe.rs | 224 + .../examples/faction_probe.rs | 81 + .../examples/horde_chat_probe.rs | 144 + .../benilla-protocol/examples/make_account.rs | 35 + .../benilla-protocol/examples/park_probe.rs | 66 + .../benilla-protocol/examples/seed_classes.rs | 82 + crates/benilla-protocol/src/auth.rs | 245 + .../benilla-protocol/src/bin/benilla-auth.rs | 46 + .../src/bin/benilla-world/main.rs | 547 ++ .../src/bin/benilla-world/probes/attack.rs | 72 + .../src/bin/benilla-world/probes/aura.rs | 249 + .../src/bin/benilla-world/probes/charge.rs | 145 + .../src/bin/benilla-world/probes/death.rs | 205 + .../benilla-world/probes/equip_pack_slot.rs | 100 + .../bin/benilla-world/probes/giverstatus.rs | 82 + .../src/bin/benilla-world/probes/loot.rs | 386 + .../src/bin/benilla-world/probes/mod.rs | 94 + .../bin/benilla-world/probes/query_names.rs | 70 + .../src/bin/benilla-world/probes/quest.rs | 281 + .../src/bin/benilla-world/probes/questlog.rs | 278 + .../src/bin/benilla-world/probes/speed.rs | 69 + .../src/bin/benilla-world/probes/spells.rs | 215 + .../src/bin/benilla-world/probes/spirit.rs | 244 + .../benilla-world/probes/swap_pack_slots.rs | 93 + .../bin/benilla-world/probes/use_pack_slot.rs | 72 + .../src/bin/benilla-world/probes/vendor.rs | 157 + .../src/bin/benilla-world/world.rs | 531 ++ crates/benilla-protocol/src/events.rs | 903 ++ crates/benilla-protocol/src/events/decode.rs | 833 ++ crates/benilla-protocol/src/guid.rs | 185 + crates/benilla-protocol/src/lib.rs | 156 + crates/benilla-protocol/src/messages/bank.rs | 69 + .../benilla-protocol/src/messages/channel.rs | 183 + crates/benilla-protocol/src/messages/chat.rs | 193 + .../benilla-protocol/src/messages/client.rs | 399 + crates/benilla-protocol/src/messages/death.rs | 285 + .../src/messages/gameobject.rs | 161 + .../benilla-protocol/src/messages/gossip.rs | 130 + crates/benilla-protocol/src/messages/group.rs | 512 + crates/benilla-protocol/src/messages/items.rs | 615 ++ crates/benilla-protocol/src/messages/loot.rs | 802 ++ crates/benilla-protocol/src/messages/mail.rs | 316 + crates/benilla-protocol/src/messages/mod.rs | 181 + .../src/messages/monster_move.rs | 299 + .../benilla-protocol/src/messages/movement.rs | 326 + .../benilla-protocol/src/messages/opcode.rs | 610 ++ .../src/messages/opcode_names.rs | 851 ++ .../benilla-protocol/src/messages/packet.rs | 1094 +++ crates/benilla-protocol/src/messages/parse.rs | 875 ++ .../src/messages/quest/giver.rs | 683 ++ .../src/messages/quest/log.rs | 505 + .../src/messages/quest/mod.rs | 33 + .../benilla-protocol/src/messages/roster.rs | 140 + .../benilla-protocol/src/messages/skills.rs | 13 + .../benilla-protocol/src/messages/spells.rs | 1061 +++ crates/benilla-protocol/src/messages/taxi.rs | 140 + crates/benilla-protocol/src/messages/trade.rs | 480 + .../benilla-protocol/src/messages/trainer.rs | 129 + .../src/messages/update_object/fields/mod.rs | 521 ++ .../messages/update_object/fields/player.rs | 422 + .../messages/update_object/fields/tests.rs | 404 + .../src/messages/update_object/fields/unit.rs | 336 + .../src/messages/update_object/mod.rs | 142 + .../src/messages/update_object/movement.rs | 166 + .../benilla-protocol/src/messages/vendor.rs | 153 + crates/benilla-protocol/src/wire.rs | 163 + crates/benilla-protocol/src/world/mod.rs | 86 + crates/benilla-protocol/src/world/movement.rs | 68 + crates/benilla-protocol/src/world/reader.rs | 73 + crates/benilla-protocol/src/world/session.rs | 721 ++ .../src/world/writer/channel.rs | 149 + .../src/world/writer/group.rs | 157 + .../benilla-protocol/src/world/writer/mail.rs | 126 + .../benilla-protocol/src/world/writer/mod.rs | 1042 +++ .../src/world/writer/trade.rs | 87 + crates/benilla-protocol/tests/auth.rs | 95 + crates/benilla-protocol/tests/bank.rs | 146 + crates/benilla-protocol/tests/char_enum.rs | 127 + crates/benilla-protocol/tests/chat.rs | 376 + crates/benilla-protocol/tests/client.rs | 580 ++ crates/benilla-protocol/tests/common/mod.rs | 13 + .../benilla-protocol/tests/gossip_vendor.rs | 426 + crates/benilla-protocol/tests/group.rs | 677 ++ crates/benilla-protocol/tests/handshake.rs | 104 + crates/benilla-protocol/tests/items.rs | 458 + crates/benilla-protocol/tests/mail.rs | 362 + crates/benilla-protocol/tests/movement.rs | 438 + .../benilla-protocol/tests/simple_packets.rs | 414 + crates/benilla-protocol/tests/spells.rs | 842 ++ crates/benilla-protocol/tests/taxi.rs | 210 + crates/benilla-protocol/tests/trainer.rs | 242 + .../benilla-protocol/tests/update_object.rs | 448 + crates/benilla-srp/Cargo.toml | 11 + .../benilla-srp/examples/provision_probe.rs | 90 + crates/benilla-srp/src/lib.rs | 516 + crates/benilla-srp/src/vanilla_header.rs | 223 + crates/benilla-ui/Cargo.toml | 13 + crates/benilla-ui/src/framexml.rs | 658 ++ crates/benilla-ui/src/layout.rs | 1359 +++ crates/benilla-ui/src/lib.rs | 24 + crates/benilla-ui/src/loader/backdrop.rs | 68 + crates/benilla-ui/src/loader/geometry.rs | 128 + crates/benilla-ui/src/loader/mod.rs | 424 + crates/benilla-ui/src/loader/regions.rs | 267 + crates/benilla-ui/src/loader/scripts.rs | 127 + crates/benilla-ui/src/loader/tests.rs | 969 ++ crates/benilla-ui/src/loader/widgets.rs | 343 + crates/benilla-ui/src/order.rs | 524 ++ crates/benilla-ui/src/script/action.rs | 417 + crates/benilla-ui/src/script/aura.rs | 500 + crates/benilla-ui/src/script/backdrop.rs | 364 + crates/benilla-ui/src/script/bank.rs | 287 + crates/benilla-ui/src/script/button.rs | 503 + crates/benilla-ui/src/script/char_stats.rs | 1097 +++ crates/benilla-ui/src/script/clip.rs | 77 + crates/benilla-ui/src/script/container.rs | 976 ++ crates/benilla-ui/src/script/cooldown.rs | 71 + crates/benilla-ui/src/script/craft.rs | 691 ++ crates/benilla-ui/src/script/cursor.rs | 634 ++ crates/benilla-ui/src/script/cursor/bar.rs | 334 + crates/benilla-ui/src/script/cursor/doll.rs | 617 ++ crates/benilla-ui/src/script/cursor/drag.rs | 421 + crates/benilla-ui/src/script/death.rs | 151 + crates/benilla-ui/src/script/editbox.rs | 704 ++ .../benilla-ui/src/script/editbox/interact.rs | 182 + .../benilla-ui/src/script/editbox/methods.rs | 221 + crates/benilla-ui/src/script/editbox/seam.rs | 217 + crates/benilla-ui/src/script/editbox/tests.rs | 909 ++ crates/benilla-ui/src/script/event.rs | 187 + crates/benilla-ui/src/script/extract.rs | 271 + crates/benilla-ui/src/script/gossip.rs | 328 + crates/benilla-ui/src/script/item_stats.rs | 451 + crates/benilla-ui/src/script/item_text.rs | 214 + crates/benilla-ui/src/script/layout.rs | 556 ++ crates/benilla-ui/src/script/loot.rs | 293 + crates/benilla-ui/src/script/loot_roll.rs | 328 + crates/benilla-ui/src/script/mail.rs | 798 ++ crates/benilla-ui/src/script/merchant.rs | 811 ++ crates/benilla-ui/src/script/messageframe.rs | 618 ++ crates/benilla-ui/src/script/minimap.rs | 73 + crates/benilla-ui/src/script/mod.rs | 605 ++ crates/benilla-ui/src/script/model.rs | 747 ++ crates/benilla-ui/src/script/object.rs | 384 + .../src/script/object/events_regions.rs | 189 + .../src/script/object/frame_state.rs | 420 + .../src/script/object/layout_methods.rs | 357 + crates/benilla-ui/src/script/party.rs | 587 ++ crates/benilla-ui/src/script/pointer.rs | 437 + crates/benilla-ui/src/script/quest.rs | 523 ++ crates/benilla-ui/src/script/quest_log.rs | 764 ++ crates/benilla-ui/src/script/region.rs | 803 ++ crates/benilla-ui/src/script/scrollframe.rs | 192 + crates/benilla-ui/src/script/shapeshift.rs | 251 + crates/benilla-ui/src/script/skills.rs | 652 ++ crates/benilla-ui/src/script/slider.rs | 389 + crates/benilla-ui/src/script/sound.rs | 136 + crates/benilla-ui/src/script/spellbook.rs | 508 + crates/benilla-ui/src/script/statusbar.rs | 265 + crates/benilla-ui/src/script/stdlib.rs | 277 + crates/benilla-ui/src/script/talent.rs | 276 + crates/benilla-ui/src/script/taxi.rs | 254 + crates/benilla-ui/src/script/tests/anchors.rs | 109 + .../benilla-ui/src/script/tests/backdrop.rs | 67 + crates/benilla-ui/src/script/tests/button.rs | 396 + crates/benilla-ui/src/script/tests/common.rs | 7 + .../benilla-ui/src/script/tests/cooldown.rs | 230 + .../benilla-ui/src/script/tests/end_to_end.rs | 78 + crates/benilla-ui/src/script/tests/events.rs | 114 + .../benilla-ui/src/script/tests/frame_api.rs | 88 + crates/benilla-ui/src/script/tests/input.rs | 207 + crates/benilla-ui/src/script/tests/measure.rs | 170 + crates/benilla-ui/src/script/tests/minimap.rs | 128 + crates/benilla-ui/src/script/tests/mod.rs | 32 + .../src/script/tests/object_model.rs | 51 + crates/benilla-ui/src/script/tests/regions.rs | 452 + .../src/script/tests/scrollframe.rs | 441 + crates/benilla-ui/src/script/tests/slider.rs | 285 + .../benilla-ui/src/script/tests/statusbar.rs | 198 + crates/benilla-ui/src/script/tests/stdlib.rs | 128 + crates/benilla-ui/src/script/tests/talent.rs | 212 + crates/benilla-ui/src/script/tests/taxi.rs | 79 + .../src/script/tests/texcoord_font.rs | 207 + crates/benilla-ui/src/script/tests/tooltip.rs | 424 + .../src/script/tests/tooltip_item/bindings.rs | 260 + .../src/script/tests/tooltip_item/law.rs | 512 + .../script/tests/tooltip_item/mechanics.rs | 368 + .../src/script/tests/tooltip_item/mod.rs | 80 + .../src/script/tests/tooltip_item/set.rs | 163 + .../src/script/tests/tooltip_spell.rs | 315 + .../src/script/tests/tooltip_unit.rs | 388 + .../benilla-ui/src/script/tests/visibility.rs | 66 + .../benilla-ui/src/script/tests/worldmap.rs | 346 + crates/benilla-ui/src/script/tick.rs | 115 + crates/benilla-ui/src/script/tooltip/mod.rs | 649 ++ crates/benilla-ui/src/script/tooltip/verbs.rs | 269 + .../benilla-ui/src/script/tooltip_item/mod.rs | 666 ++ .../src/script/tooltip_item/names.rs | 157 + .../src/script/tooltip_item/render.rs | 454 + crates/benilla-ui/src/script/tooltip_spell.rs | 436 + crates/benilla-ui/src/script/tooltip_unit.rs | 537 ++ crates/benilla-ui/src/script/trade.rs | 594 ++ .../benilla-ui/src/script/tradeskill/api.rs | 438 + .../benilla-ui/src/script/tradeskill/mod.rs | 318 + .../benilla-ui/src/script/tradeskill/tests.rs | 816 ++ .../benilla-ui/src/script/tradeskill/view.rs | 325 + crates/benilla-ui/src/script/trainer.rs | 992 ++ crates/benilla-ui/src/script/types.rs | 564 ++ crates/benilla-ui/src/script/unit/bindings.rs | 499 + crates/benilla-ui/src/script/unit/mod.rs | 287 + crates/benilla-ui/src/script/unit/tests.rs | 520 + crates/benilla-ui/src/script/worldmap.rs | 590 ++ crates/benilla-ui/src/toc.rs | 186 + crates/benilla-ui/src/widget/kinds/editbox.rs | 440 + .../src/widget/kinds/messageframe.rs | 233 + crates/benilla-ui/src/widget/kinds/mod.rs | 535 ++ crates/benilla-ui/src/widget/mod.rs | 550 ++ crates/benilla-ui/src/widget/propagation.rs | 299 + crates/benilla-ui/src/widget/tests.rs | 274 + crates/benilla-visual/Cargo.toml | 12 + crates/benilla-visual/src/lib.rs | 190 + crates/benilla-visual/src/main.rs | 247 + crates/benilla-wdt/Cargo.toml | 6 + crates/benilla-wdt/src/lib.rs | 285 + crates/benilla-wmo/Cargo.toml | 9 + crates/benilla-wmo/src/lib.rs | 508 + crates/benilla/Cargo.toml | 72 + crates/benilla/assets/shaders/celestial.wgsl | 140 + crates/benilla/assets/shaders/cloud.wgsl | 44 + crates/benilla/assets/shaders/ffx_glow.wgsl | 99 + crates/benilla/assets/shaders/liquid.wgsl | 170 + crates/benilla/assets/shaders/sky.wgsl | 132 + crates/benilla/assets/shaders/star.wgsl | 37 + crates/benilla/assets/shaders/terrain.wgsl | 322 + crates/benilla/assets/shaders/ui_add.wgsl | 30 + crates/benilla/assets/shaders/ui_gamma.wgsl | 28 + .../benilla/assets/shaders/ui_node_gamma.wgsl | 269 + crates/benilla/assets/shaders/ui_quad.wgsl | 81 + .../assets/shaders/ui_slice_gamma.wgsl | 152 + crates/benilla/assets/shaders/wdl.wgsl | 62 + crates/benilla/assets/shaders/wow_model.wgsl | 713 ++ .../benilla/assets/shaders/wow_particle.wgsl | 108 + crates/benilla/assets/ui/ActionBar.xml | 732 ++ crates/benilla/assets/ui/BagFrame.xml | 1275 +++ crates/benilla/assets/ui/BankFrame.xml | 1147 +++ crates/benilla/assets/ui/BuffFrame.xml | 410 + crates/benilla/assets/ui/CastingBar.xml | 239 + crates/benilla/assets/ui/CharacterFrame.xml | 1771 ++++ crates/benilla/assets/ui/ChatFrame.xml | 554 ++ crates/benilla/assets/ui/CombatText.xml | 608 ++ crates/benilla/assets/ui/Cooldown.xml | 21 + crates/benilla/assets/ui/CraftFrame.xml | 931 ++ crates/benilla/assets/ui/DeathFrame.xml | 265 + crates/benilla/assets/ui/DurabilityFrame.xml | 303 + crates/benilla/assets/ui/ErrorsFrame.xml | 118 + crates/benilla/assets/ui/Fonts.xml | 290 + crates/benilla/assets/ui/GameTooltip.xml | 398 + crates/benilla/assets/ui/GossipFrame.xml | 401 + crates/benilla/assets/ui/GroupLootFrame.xml | 478 + crates/benilla/assets/ui/ItemRef.xml | 136 + crates/benilla/assets/ui/ItemTextFrame.xml | 337 + crates/benilla/assets/ui/LootFrame.xml | 391 + crates/benilla/assets/ui/MailFrame.xml | 1535 +++ crates/benilla/assets/ui/MerchantFrame.xml | 1275 +++ crates/benilla/assets/ui/MinimapCluster.xml | 441 + crates/benilla/assets/ui/MultiBars.xml | 156 + crates/benilla/assets/ui/PartyFrame.xml | 353 + crates/benilla/assets/ui/QuestFrame.xml | 1350 +++ crates/benilla/assets/ui/QuestLogFrame.xml | 1760 ++++ crates/benilla/assets/ui/ScrollTemplates.xml | 178 + crates/benilla/assets/ui/SkillFrame.xml | 627 ++ crates/benilla/assets/ui/SpellBookFrame.xml | 657 ++ crates/benilla/assets/ui/StackSplit.xml | 226 + crates/benilla/assets/ui/StanceBar.xml | 206 + crates/benilla/assets/ui/TalentFrame.xml | 1191 +++ crates/benilla/assets/ui/TaxiFrame.xml | 502 + crates/benilla/assets/ui/TradeFrame.xml | 557 ++ crates/benilla/assets/ui/TradeSkillFrame.xml | 1286 +++ crates/benilla/assets/ui/TrainerFrame.xml | 786 ++ crates/benilla/assets/ui/UIDropDownMenu.xml | 980 ++ crates/benilla/assets/ui/UIParent.xml | 220 + crates/benilla/assets/ui/UiPanels.xml | 1137 +++ crates/benilla/assets/ui/UnitFrames.xml | 958 ++ crates/benilla/assets/ui/UnitPopup.xml | 553 ++ crates/benilla/assets/ui/WorldMapFrame.xml | 630 ++ crates/benilla/assets/ui/ZoneText.xml | 241 + crates/benilla/src/area.rs | 432 + crates/benilla/src/assets/images.rs | 287 + crates/benilla/src/assets/mod.rs | 424 + crates/benilla/src/billboard.rs | 910 ++ crates/benilla/src/blob_shadow.rs | 458 + crates/benilla/src/bowstring.rs | 81 + crates/benilla/src/capture/fixtures.rs | 1151 +++ crates/benilla/src/capture/mod.rs | 567 ++ crates/benilla/src/capture/probe_bank.rs | 578 ++ .../benilla/src/capture/probe_castcancel.rs | 150 + .../benilla/src/capture/probe_charcreate.rs | 178 + crates/benilla/src/capture/probe_crossing.rs | 157 + crates/benilla/src/capture/probe_mail.rs | 797 ++ crates/benilla/src/capture/probe_melee.rs | 50 + crates/benilla/src/capture/probe_partner.rs | 37 + crates/benilla/src/capture/probe_taxi.rs | 463 + crates/benilla/src/capture/probes.rs | 717 ++ crates/benilla/src/capture/scenarios.rs | 380 + crates/benilla/src/char_create/mod.rs | 663 ++ crates/benilla/src/char_create/panels.rs | 264 + crates/benilla/src/char_create/parts.rs | 92 + crates/benilla/src/char_create/refresh.rs | 418 + crates/benilla/src/char_create/screen.rs | 646 ++ crates/benilla/src/char_select/dialog.rs | 483 + crates/benilla/src/char_select/input.rs | 148 + crates/benilla/src/char_select/mod.rs | 420 + crates/benilla/src/char_select/refresh.rs | 197 + crates/benilla/src/char_select/screen.rs | 601 ++ crates/benilla/src/chat_bubble.rs | 651 ++ crates/benilla/src/clouds/kernel.rs | 685 ++ crates/benilla/src/clouds/layer.rs | 252 + crates/benilla/src/clouds/mod.rs | 144 + crates/benilla/src/clouds/tables.rs | 66 + crates/benilla/src/clutter.rs | 431 + crates/benilla/src/collision.rs | 84 + crates/benilla/src/combat_text/law.rs | 487 + crates/benilla/src/combat_text/mod.rs | 482 + crates/benilla/src/cooldowns.rs | 828 ++ crates/benilla/src/creature_anim.rs | 804 ++ crates/benilla/src/creature_anim/blood.rs | 146 + crates/benilla/src/creature_anim/driver.rs | 1304 +++ .../benilla/src/creature_anim/driver/grip.rs | 37 + .../benilla/src/creature_anim/driver/play.rs | 261 + .../benilla/src/creature_anim/driver/tests.rs | 1131 +++ .../benilla/src/creature_anim/driver/wound.rs | 163 + .../benilla/src/creature_anim/emote_anim.rs | 138 + .../benilla/src/creature_anim/env_damage.rs | 100 + crates/benilla/src/creature_anim/events.rs | 159 + .../benilla/src/creature_anim/global_seq.rs | 173 + crates/benilla/src/creature_anim/impact.rs | 433 + crates/benilla/src/creature_anim/lod.rs | 424 + crates/benilla/src/creature_anim/select.rs | 890 ++ .../benilla/src/creature_anim/select/tests.rs | 797 ++ crates/benilla/src/creature_anim/sheath.rs | 146 + .../benilla/src/creature_anim/spell_visual.rs | 985 ++ .../src/creature_anim/spell_visual/tests.rs | 690 ++ crates/benilla/src/creature_anim/twist.rs | 197 + crates/benilla/src/cursor.rs | 513 + crates/benilla/src/dbg_trace.rs | 44 + crates/benilla/src/death.rs | 410 + crates/benilla/src/debug_panel/mod.rs | 638 ++ crates/benilla/src/debug_panel/state.rs | 112 + crates/benilla/src/debug_panel/visibility.rs | 175 + crates/benilla/src/debug_panel/world.rs | 114 + crates/benilla/src/decal.rs | 213 + crates/benilla/src/doodad_anim.rs | 588 ++ crates/benilla/src/entities.rs | 626 ++ .../benilla/src/entities/attach/char_skin.rs | 462 + .../src/entities/attach/glue_preview.rs | 449 + crates/benilla/src/entities/attach/mod.rs | 974 ++ crates/benilla/src/entities/carried_light.rs | 157 + crates/benilla/src/entities/conform.rs | 228 + crates/benilla/src/entities/display.rs | 658 ++ crates/benilla/src/entities/equipment.rs | 1004 ++ crates/benilla/src/entities/missile.rs | 876 ++ crates/benilla/src/entities/mount.rs | 73 + crates/benilla/src/entities/spell_fx.rs | 826 ++ crates/benilla/src/entities/wmo_props.rs | 380 + crates/benilla/src/entity_shade.rs | 287 + crates/benilla/src/ffx_glow.rs | 387 + crates/benilla/src/glue/add_material.rs | 52 + crates/benilla/src/glue/art.rs | 444 + crates/benilla/src/glue/backdrop.rs | 302 + crates/benilla/src/glue/mod.rs | 175 + crates/benilla/src/glue/widgets.rs | 583 ++ crates/benilla/src/glue_strings.rs | 119 + crates/benilla/src/go_anim.rs | 366 + crates/benilla/src/go_templates.rs | 174 + crates/benilla/src/ground_fx.rs | 253 + crates/benilla/src/interact.rs | 434 + crates/benilla/src/interact/journal.rs | 291 + crates/benilla/src/interior.rs | 643 ++ crates/benilla/src/items.rs | 307 + crates/benilla/src/lighting/apply.rs | 147 + crates/benilla/src/lighting/daynight.rs | 593 ++ crates/benilla/src/lighting/global_light.rs | 485 + crates/benilla/src/lighting/mod.rs | 330 + crates/benilla/src/lighting/prop_probes.rs | 313 + crates/benilla/src/lighting/resolve.rs | 484 + crates/benilla/src/lighting/sh.rs | 155 + crates/benilla/src/liquid.rs | 667 ++ crates/benilla/src/loading_screen.rs | 348 + crates/benilla/src/login/mod.rs | 756 ++ crates/benilla/src/login/screen.rs | 719 ++ crates/benilla/src/main.rs | 661 ++ crates/benilla/src/map_proj.rs | 252 + crates/benilla/src/mesh_tag.rs | 227 + crates/benilla/src/minimap/blips/dots.rs | 544 ++ crates/benilla/src/minimap/blips/mod.rs | 491 + crates/benilla/src/minimap/interior.rs | 343 + crates/benilla/src/minimap/mod.rs | 759 ++ crates/benilla/src/model_fade.rs | 671 ++ crates/benilla/src/model_render.rs | 377 + crates/benilla/src/modkeys.rs | 92 + crates/benilla/src/nameplates.rs | 696 ++ crates/benilla/src/names.rs | 246 + crates/benilla/src/net.rs | 1091 +++ crates/benilla/src/net/apply.rs | 1315 +++ crates/benilla/src/net/apply/chat.rs | 60 + crates/benilla/src/net/apply/combat.rs | 142 + crates/benilla/src/net/apply/combat_log.rs | 585 ++ crates/benilla/src/net/apply/group.rs | 13 + crates/benilla/src/net/apply/loot.rs | 158 + crates/benilla/src/net/apply/mail.rs | 120 + crates/benilla/src/net/apply/npc.rs | 248 + crates/benilla/src/net/apply/objects.rs | 448 + crates/benilla/src/net/apply/quests.rs | 149 + crates/benilla/src/net/apply/session.rs | 343 + crates/benilla/src/net/apply/spells.rs | 922 ++ crates/benilla/src/net/apply/trade.rs | 64 + crates/benilla/src/net/io.rs | 807 ++ crates/benilla/src/net/motion.rs | 68 + crates/benilla/src/net/motion/facing.rs | 142 + crates/benilla/src/net/motion/remote.rs | 544 ++ crates/benilla/src/net/motion/spline.rs | 478 + crates/benilla/src/net/motion/tests.rs | 513 + crates/benilla/src/npc_text.rs | 108 + crates/benilla/src/particles.rs | 1037 ++ crates/benilla/src/particles/material.rs | 91 + crates/benilla/src/particles/model.rs | 167 + crates/benilla/src/particles/quads.rs | 268 + crates/benilla/src/particles/sim.rs | 855 ++ crates/benilla/src/pending_item_ops.rs | 235 + crates/benilla/src/perf.rs | 408 + crates/benilla/src/player.rs | 1172 +++ crates/benilla/src/player/arc.rs | 281 + crates/benilla/src/player/camera.rs | 626 ++ crates/benilla/src/player/gait.rs | 77 + crates/benilla/src/player/move_trace.rs | 64 + crates/benilla/src/player/movement_net.rs | 527 ++ crates/benilla/src/player/mover.rs | 542 ++ crates/benilla/src/player/server_ride.rs | 251 + crates/benilla/src/player/setup.rs | 145 + crates/benilla/src/player/state.rs | 467 + crates/benilla/src/player/swim.rs | 435 + crates/benilla/src/player/wire_in.rs | 264 + crates/benilla/src/portrait/booth.rs | 272 + crates/benilla/src/portrait/framing.rs | 333 + crates/benilla/src/portrait/glue_booth.rs | 1007 ++ crates/benilla/src/portrait/mod.rs | 1017 ++ crates/benilla/src/portrait/test_bake.rs | 202 + crates/benilla/src/quest_markers.rs | 651 ++ crates/benilla/src/raid_marks.rs | 322 + crates/benilla/src/ribbons.rs | 384 + crates/benilla/src/schedule.rs | 51 + crates/benilla/src/sky.rs | 281 + crates/benilla/src/sky_order.rs | 100 + crates/benilla/src/smart_rect.rs | 401 + crates/benilla/src/sound/anim_events.rs | 128 + crates/benilla/src/sound/combat.rs | 311 + crates/benilla/src/sound/creature.rs | 385 + crates/benilla/src/sound/emote.rs | 138 + crates/benilla/src/sound/footsteps.rs | 138 + crates/benilla/src/sound/gameobject.rs | 109 + crates/benilla/src/sound/glue.rs | 119 + crates/benilla/src/sound/greeting.rs | 421 + crates/benilla/src/sound/interior.rs | 105 + crates/benilla/src/sound/kit.rs | 621 ++ crates/benilla/src/sound/liquid_loop.rs | 329 + crates/benilla/src/sound/math.rs | 168 + crates/benilla/src/sound/missile.rs | 68 + crates/benilla/src/sound/mixer.rs | 451 + crates/benilla/src/sound/mod.rs | 259 + crates/benilla/src/sound/money.rs | 76 + crates/benilla/src/sound/mount.rs | 77 + crates/benilla/src/sound/reverb.rs | 129 + crates/benilla/src/sound/sheathe.rs | 114 + crates/benilla/src/sound/spell.rs | 100 + crates/benilla/src/sound/ui.rs | 388 + crates/benilla/src/sound/water.rs | 98 + crates/benilla/src/sound/weather.rs | 39 + crates/benilla/src/sound/zone.rs | 645 ++ crates/benilla/src/sun/follow.rs | 702 ++ crates/benilla/src/sun/materials.rs | 76 + crates/benilla/src/sun/mesh.rs | 125 + crates/benilla/src/sun/mod.rs | 129 + crates/benilla/src/sun/setup.rs | 293 + crates/benilla/src/target/click.rs | 894 ++ crates/benilla/src/target/cursor_mode.rs | 589 ++ crates/benilla/src/target/flash.rs | 152 + crates/benilla/src/target/highlight.rs | 80 + crates/benilla/src/target/hover.rs | 547 ++ crates/benilla/src/target/mod.rs | 231 + crates/benilla/src/target/ring.rs | 631 ++ crates/benilla/src/target/scan.rs | 897 ++ crates/benilla/src/terrain.rs | 424 + crates/benilla/src/terrain_stream.rs | 603 ++ crates/benilla/src/terrain_stream/collider.rs | 283 + crates/benilla/src/terrain_stream/queries.rs | 139 + .../src/terrain_stream/spawn/assemble.rs | 313 + crates/benilla/src/terrain_stream/spawn/fx.rs | 194 + .../benilla/src/terrain_stream/spawn/mod.rs | 546 ++ crates/benilla/src/thread_qos.rs | 112 + crates/benilla/src/transport.rs | 453 + crates/benilla/src/ui_action/cast_fail.rs | 418 + crates/benilla/src/ui_action/cast_target.rs | 427 + crates/benilla/src/ui_action/errors.rs | 436 + crates/benilla/src/ui_action/mod.rs | 1042 +++ crates/benilla/src/ui_action/state.rs | 534 ++ crates/benilla/src/ui_action/usable.rs | 351 + crates/benilla/src/ui_aura.rs | 616 ++ crates/benilla/src/ui_bank.rs | 255 + crates/benilla/src/ui_cast.rs | 1004 ++ crates/benilla/src/ui_char.rs | 713 ++ crates/benilla/src/ui_chat.rs | 53 + crates/benilla/src/ui_chat/edit.rs | 515 + crates/benilla/src/ui_chat/event.rs | 260 + crates/benilla/src/ui_chat/feed.rs | 462 + crates/benilla/src/ui_chat/frames.rs | 278 + crates/benilla/src/ui_chat/input.rs | 719 ++ crates/benilla/src/ui_chat/tests.rs | 481 + crates/benilla/src/ui_craft.rs | 383 + crates/benilla/src/ui_gamma.rs | 210 + crates/benilla/src/ui_gossip.rs | 318 + crates/benilla/src/ui_item_text.rs | 171 + crates/benilla/src/ui_items/drain.rs | 393 + crates/benilla/src/ui_items/equip_error.rs | 136 + crates/benilla/src/ui_items/feed.rs | 788 ++ crates/benilla/src/ui_items/mod.rs | 536 ++ crates/benilla/src/ui_loot.rs | 743 ++ crates/benilla/src/ui_loot_roll.rs | 882 ++ crates/benilla/src/ui_mail.rs | 741 ++ crates/benilla/src/ui_merchant.rs | 703 ++ crates/benilla/src/ui_party/feed.rs | 603 ++ crates/benilla/src/ui_party/mod.rs | 572 ++ crates/benilla/src/ui_pass.rs | 990 ++ crates/benilla/src/ui_quest.rs | 540 ++ crates/benilla/src/ui_quest_log.rs | 1113 +++ .../benilla/src/ui_script/action_bar_tests.rs | 657 ++ crates/benilla/src/ui_script/bag_tests.rs | 1136 +++ crates/benilla/src/ui_script/bank_tests.rs | 420 + crates/benilla/src/ui_script/buff_tests.rs | 324 + crates/benilla/src/ui_script/cast_tests.rs | 438 + .../benilla/src/ui_script/character_tests.rs | 848 ++ crates/benilla/src/ui_script/chat_tests.rs | 229 + .../src/ui_script/combat_text_tests.rs | 283 + crates/benilla/src/ui_script/death_tests.rs | 347 + .../src/ui_script/delete_item_tests.rs | 215 + .../benilla/src/ui_script/durability_tests.rs | 413 + crates/benilla/src/ui_script/errors_tests.rs | 156 + crates/benilla/src/ui_script/escape_tests.rs | 332 + crates/benilla/src/ui_script/extract/mod.rs | 924 ++ crates/benilla/src/ui_script/extract/text.rs | 303 + .../benilla/src/ui_script/group_loot_tests.rs | 506 + crates/benilla/src/ui_script/input.rs | 362 + crates/benilla/src/ui_script/loot_tests.rs | 457 + .../benilla/src/ui_script/merchant_tests.rs | 1202 +++ crates/benilla/src/ui_script/minimap_tests.rs | 127 + crates/benilla/src/ui_script/mod.rs | 760 ++ .../src/ui_script/multibar_stance_tests.rs | 431 + crates/benilla/src/ui_script/panel_tests.rs | 654 ++ crates/benilla/src/ui_script/quest_tests.rs | 563 ++ .../benilla/src/ui_script/questlog_tests.rs | 987 ++ .../src/ui_script/shipped_xml_tests.rs | 36 + .../benilla/src/ui_script/spellbook_tests.rs | 264 + .../src/ui_script/static_popup_tests.rs | 207 + .../src/ui_script/target_aura_tests.rs | 275 + crates/benilla/src/ui_script/taxi_tests.rs | 191 + crates/benilla/src/ui_script/textkeys.rs | 351 + .../src/ui_script/tooltip_anchor_tests.rs | 189 + .../src/ui_script/tooltip_compare_tests.rs | 321 + crates/benilla/src/ui_script/trainer_tests.rs | 490 + .../benilla/src/ui_script/unit_frame_tests.rs | 761 ++ .../benilla/src/ui_script/unit_popup_tests.rs | 268 + .../benilla/src/ui_script/zone_text_tests.rs | 327 + crates/benilla/src/ui_session.rs | 207 + crates/benilla/src/ui_shapeshift.rs | 242 + crates/benilla/src/ui_spellbook.rs | 471 + crates/benilla/src/ui_talent.rs | 350 + crates/benilla/src/ui_taxi/mod.rs | 471 + crates/benilla/src/ui_taxi/routing.rs | 553 ++ crates/benilla/src/ui_text/atlas.rs | 916 ++ crates/benilla/src/ui_text/layout/mod.rs | 1202 +++ crates/benilla/src/ui_text/layout/overflow.rs | 235 + crates/benilla/src/ui_text/layout/wrap.rs | 423 + crates/benilla/src/ui_text/markup.rs | 258 + crates/benilla/src/ui_text/mod.rs | 134 + crates/benilla/src/ui_tooltip.rs | 511 + crates/benilla/src/ui_trade.rs | 799 ++ crates/benilla/src/ui_tradeskill.rs | 554 ++ crates/benilla/src/ui_trainer.rs | 614 ++ crates/benilla/src/ui_unit.rs | 554 ++ crates/benilla/src/ui_world_map.rs | 343 + crates/benilla/src/view.rs | 26 + crates/benilla/src/vplates.rs | 984 ++ crates/benilla/src/vplates/border.rs | 113 + crates/benilla/src/water_fx/mod.rs | 704 ++ crates/benilla/src/water_fx/params.rs | 255 + crates/benilla/src/water_fx/view.rs | 174 + crates/benilla/src/wdl.rs | 236 + crates/benilla/src/weather/mod.rs | 482 + crates/benilla/src/weather/precip/mist.rs | 361 + crates/benilla/src/weather/precip/mod.rs | 638 ++ crates/benilla/src/weather/precip/pool.rs | 505 + crates/benilla/src/weather/precip/render.rs | 159 + crates/benilla/src/weather/precip/wind.rs | 189 + .../src/wmo_portal/audit/light_probe.rs | 515 + crates/benilla/src/wmo_portal/audit/mod.rs | 801 ++ crates/benilla/src/wmo_portal/fog.rs | 173 + crates/benilla/src/wmo_portal/interior.rs | 504 + crates/benilla/src/wmo_portal/mod.rs | 760 ++ crates/benilla/src/wmo_portal/probe.rs | 142 + crates/benilla/src/wmo_portal/seed.rs | 801 ++ crates/benilla/src/world_map.rs | 62 + crates/benilla/tests/item_text_frame.rs | 186 + crates/benilla/tests/mail_frame.rs | 449 + crates/benilla/tests/talent_frame.rs | 237 + crates/benilla/tests/trade_frame.rs | 356 + crates/benilla/tests/tradeskill_frame.rs | 225 + rust-toolchain.toml | 3 + 778 files changed, 295134 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE-APACHE create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 crates/benilla-adt/Cargo.toml create mode 100644 crates/benilla-adt/src/combined_alpha.rs create mode 100644 crates/benilla-adt/src/lib.rs create mode 100644 crates/benilla-assets/Cargo.toml create mode 100644 crates/benilla-assets/src/adt.rs create mode 100644 crates/benilla-assets/src/blp.rs create mode 100644 crates/benilla-assets/src/coords.rs create mode 100644 crates/benilla-assets/src/lib.rs create mode 100644 crates/benilla-assets/src/m2.rs create mode 100644 crates/benilla-assets/src/minimap_grid.rs create mode 100644 crates/benilla-assets/src/model.rs create mode 100644 crates/benilla-assets/src/model/anims.rs create mode 100644 crates/benilla-assets/src/terrain.rs create mode 100644 crates/benilla-assets/src/wdt.rs create mode 100644 crates/benilla-assets/src/wmo.rs create mode 100644 crates/benilla-blp/Cargo.toml create mode 100644 crates/benilla-blp/src/bin/blpx.rs create mode 100644 crates/benilla-blp/src/lib.rs create mode 100644 crates/benilla-bytes/Cargo.toml create mode 100644 crates/benilla-bytes/src/lib.rs create mode 100644 crates/benilla-dbc/Cargo.toml create mode 100644 crates/benilla-dbc/src/lib.rs create mode 100644 crates/benilla-formats/Cargo.toml create mode 100644 crates/benilla-formats/examples/dump_anim_names.rs create mode 100644 crates/benilla-formats/examples/dump_bone_keys.rs create mode 100644 crates/benilla-formats/examples/dump_file.rs create mode 100644 crates/benilla-formats/src/anim_data.rs create mode 100644 crates/benilla-formats/src/area_poi.rs create mode 100644 crates/benilla-formats/src/area_sound.rs create mode 100644 crates/benilla-formats/src/area_table.rs create mode 100644 crates/benilla-formats/src/bank_bag_slot_prices.rs create mode 100644 crates/benilla-formats/src/bin/benilla-extract/m2dump.rs create mode 100644 crates/benilla-formats/src/bin/benilla-extract/main.rs create mode 100644 crates/benilla-formats/src/bin/benilla-extract/scan.rs create mode 100644 crates/benilla-formats/src/bin/benilla-extract/spellvis.rs create mode 100644 crates/benilla-formats/src/bin/mpqx.rs create mode 100644 crates/benilla-formats/src/chain.rs create mode 100644 crates/benilla-formats/src/characters/customization.rs create mode 100644 crates/benilla-formats/src/characters/geosets.rs create mode 100644 crates/benilla-formats/src/characters/mod.rs create mode 100644 crates/benilla-formats/src/characters/sections.rs create mode 100644 crates/benilla-formats/src/creature_sound.rs create mode 100644 crates/benilla-formats/src/creature_types.rs create mode 100644 crates/benilla-formats/src/creatures.rs create mode 100644 crates/benilla-formats/src/dbc.rs create mode 100644 crates/benilla-formats/src/durability.rs create mode 100644 crates/benilla-formats/src/elevators.rs create mode 100644 crates/benilla-formats/src/emotes.rs create mode 100644 crates/benilla-formats/src/environmental_damage.rs create mode 100644 crates/benilla-formats/src/factions.rs create mode 100644 crates/benilla-formats/src/footsteps.rs create mode 100644 crates/benilla-formats/src/gameobjects.rs create mode 100644 crates/benilla-formats/src/ground_effects.rs create mode 100644 crates/benilla-formats/src/item_sounds.rs create mode 100644 crates/benilla-formats/src/items.rs create mode 100644 crates/benilla-formats/src/itemsets.rs create mode 100644 crates/benilla-formats/src/itemsubclass.rs create mode 100644 crates/benilla-formats/src/lib.rs create mode 100644 crates/benilla-formats/src/light.rs create mode 100644 crates/benilla-formats/src/light/atmosphere.rs create mode 100644 crates/benilla-formats/src/light/tables.rs create mode 100644 crates/benilla-formats/src/liquid.rs create mode 100644 crates/benilla-formats/src/loading_screen.rs create mode 100644 crates/benilla-formats/src/lock.rs create mode 100644 crates/benilla-formats/src/lock_type.rs create mode 100644 crates/benilla-formats/src/maps.rs create mode 100644 crates/benilla-formats/src/minimap_translate.rs create mode 100644 crates/benilla-formats/src/models/anim.rs create mode 100644 crates/benilla-formats/src/models/anim_summary.rs create mode 100644 crates/benilla-formats/src/models/bounds.rs create mode 100644 crates/benilla-formats/src/models/collision.rs create mode 100644 crates/benilla-formats/src/models/key_anim.rs create mode 100644 crates/benilla-formats/src/models/m2_batches.rs create mode 100644 crates/benilla-formats/src/models/mat_anim.rs create mode 100644 crates/benilla-formats/src/models/mod.rs create mode 100644 crates/benilla-formats/src/models/records.rs create mode 100644 crates/benilla-formats/src/models/tex_anim.rs create mode 100644 crates/benilla-formats/src/models/types.rs create mode 100644 crates/benilla-formats/src/models/wmo/group.rs create mode 100644 crates/benilla-formats/src/models/wmo/mod.rs create mode 100644 crates/benilla-formats/src/models/wmo/root.rs create mode 100644 crates/benilla-formats/src/npc_greeting.rs create mode 100644 crates/benilla-formats/src/particles.rs create mode 100644 crates/benilla-formats/src/quest_headers.rs create mode 100644 crates/benilla-formats/src/ribbons.rs create mode 100644 crates/benilla-formats/src/sheathe.rs create mode 100644 crates/benilla-formats/src/skill_lines.rs create mode 100644 crates/benilla-formats/src/sound_entries.rs create mode 100644 crates/benilla-formats/src/sound_provider.rs create mode 100644 crates/benilla-formats/src/sound_water.rs create mode 100644 crates/benilla-formats/src/spell_focus.rs create mode 100644 crates/benilla-formats/src/spell_visual.rs create mode 100644 crates/benilla-formats/src/spells/cast_times.rs create mode 100644 crates/benilla-formats/src/spells/catalog_tests.rs create mode 100644 crates/benilla-formats/src/spells/display.rs create mode 100644 crates/benilla-formats/src/spells/duration.rs create mode 100644 crates/benilla-formats/src/spells/forms.rs create mode 100644 crates/benilla-formats/src/spells/mod.rs create mode 100644 crates/benilla-formats/src/spells/radius.rs create mode 100644 crates/benilla-formats/src/spells/ranges.rs create mode 100644 crates/benilla-formats/src/spells/tokens.rs create mode 100644 crates/benilla-formats/src/stationery.rs create mode 100644 crates/benilla-formats/src/talents.rs create mode 100644 crates/benilla-formats/src/taxi.rs create mode 100644 crates/benilla-formats/src/taxi_nodes.rs create mode 100644 crates/benilla-formats/src/taxi_path.rs create mode 100644 crates/benilla-formats/src/terrain.rs create mode 100644 crates/benilla-formats/src/transport_period.rs create mode 100644 crates/benilla-formats/src/transports/mod.rs create mode 100644 crates/benilla-formats/src/transports/tests.rs create mode 100644 crates/benilla-formats/src/unit_blood.rs create mode 100644 crates/benilla-formats/src/value_track.rs create mode 100644 crates/benilla-formats/src/wdl.rs create mode 100644 crates/benilla-formats/src/weapon_impact.rs create mode 100644 crates/benilla-formats/src/wmo_area.rs create mode 100644 crates/benilla-formats/src/world_map_area.rs create mode 100644 crates/benilla-formats/src/world_map_continent.rs create mode 100644 crates/benilla-formats/src/world_map_overlay.rs create mode 100644 crates/benilla-formats/src/zone_map.rs create mode 100644 crates/benilla-formats/tests/billboards.rs create mode 100644 crates/benilla-formats/tests/blob_shadow_box.rs create mode 100644 crates/benilla-formats/tests/blp_alpha.rs create mode 100644 crates/benilla-formats/tests/chain.rs create mode 100644 crates/benilla-formats/tests/ground_quad.rs create mode 100644 crates/benilla-formats/tests/item_icons.rs create mode 100644 crates/benilla-formats/tests/m2_anim_events.rs create mode 100644 crates/benilla-formats/tests/m2_billboard.rs create mode 100644 crates/benilla-formats/tests/m2_color.rs create mode 100644 crates/benilla-formats/tests/m2_emissive.rs create mode 100644 crates/benilla-formats/tests/m2_global_seq_constant.rs create mode 100644 crates/benilla-formats/tests/m2_idle_anim.rs create mode 100644 crates/benilla-formats/tests/m2_lights.rs create mode 100644 crates/benilla-formats/tests/m2_playable_animation_lookup.rs create mode 100644 crates/benilla-formats/tests/m2_portrait_camera.rs create mode 100644 crates/benilla-formats/tests/m2_sequence_bounds.rs create mode 100644 crates/benilla-formats/tests/particles.rs create mode 100644 crates/benilla-formats/tests/ribbons.rs create mode 100644 crates/benilla-formats/tests/selection_ring_radius.rs create mode 100644 crates/benilla-formats/tests/spell_catalog.rs create mode 100644 crates/benilla-formats/tests/transport_speed_profile.rs create mode 100644 crates/benilla-formats/tests/wmo_fogs.rs create mode 100644 crates/benilla-formats/tests/wmo_lights.rs create mode 100644 crates/benilla-formats/tests/wmo_liquid.rs create mode 100644 crates/benilla-m2/Cargo.toml create mode 100644 crates/benilla-m2/examples/dump_attach.rs create mode 100644 crates/benilla-m2/examples/dump_skin.rs create mode 100644 crates/benilla-m2/examples/dump_verts.rs create mode 100644 crates/benilla-m2/src/error.rs create mode 100644 crates/benilla-m2/src/lib.rs create mode 100644 crates/benilla-m2/src/model.rs create mode 100644 crates/benilla-m2/src/skin.rs create mode 100644 crates/benilla-m2/src/tests.rs create mode 100644 crates/benilla-m2/src/track.rs create mode 100644 crates/benilla-mpq/Cargo.toml create mode 100644 crates/benilla-mpq/src/crypto.rs create mode 100644 crates/benilla-mpq/src/lib.rs create mode 100644 crates/benilla-protocol/Cargo.toml create mode 100644 crates/benilla-protocol/examples/cooldown_probe.rs create mode 100644 crates/benilla-protocol/examples/faction_probe.rs create mode 100644 crates/benilla-protocol/examples/horde_chat_probe.rs create mode 100644 crates/benilla-protocol/examples/make_account.rs create mode 100644 crates/benilla-protocol/examples/park_probe.rs create mode 100644 crates/benilla-protocol/examples/seed_classes.rs create mode 100644 crates/benilla-protocol/src/auth.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-auth.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/main.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/death.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs create mode 100644 crates/benilla-protocol/src/bin/benilla-world/world.rs create mode 100644 crates/benilla-protocol/src/events.rs create mode 100644 crates/benilla-protocol/src/events/decode.rs create mode 100644 crates/benilla-protocol/src/guid.rs create mode 100644 crates/benilla-protocol/src/lib.rs create mode 100644 crates/benilla-protocol/src/messages/bank.rs create mode 100644 crates/benilla-protocol/src/messages/channel.rs create mode 100644 crates/benilla-protocol/src/messages/chat.rs create mode 100644 crates/benilla-protocol/src/messages/client.rs create mode 100644 crates/benilla-protocol/src/messages/death.rs create mode 100644 crates/benilla-protocol/src/messages/gameobject.rs create mode 100644 crates/benilla-protocol/src/messages/gossip.rs create mode 100644 crates/benilla-protocol/src/messages/group.rs create mode 100644 crates/benilla-protocol/src/messages/items.rs create mode 100644 crates/benilla-protocol/src/messages/loot.rs create mode 100644 crates/benilla-protocol/src/messages/mail.rs create mode 100644 crates/benilla-protocol/src/messages/mod.rs create mode 100644 crates/benilla-protocol/src/messages/monster_move.rs create mode 100644 crates/benilla-protocol/src/messages/movement.rs create mode 100644 crates/benilla-protocol/src/messages/opcode.rs create mode 100644 crates/benilla-protocol/src/messages/opcode_names.rs create mode 100644 crates/benilla-protocol/src/messages/packet.rs create mode 100644 crates/benilla-protocol/src/messages/parse.rs create mode 100644 crates/benilla-protocol/src/messages/quest/giver.rs create mode 100644 crates/benilla-protocol/src/messages/quest/log.rs create mode 100644 crates/benilla-protocol/src/messages/quest/mod.rs create mode 100644 crates/benilla-protocol/src/messages/roster.rs create mode 100644 crates/benilla-protocol/src/messages/skills.rs create mode 100644 crates/benilla-protocol/src/messages/spells.rs create mode 100644 crates/benilla-protocol/src/messages/taxi.rs create mode 100644 crates/benilla-protocol/src/messages/trade.rs create mode 100644 crates/benilla-protocol/src/messages/trainer.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/fields/mod.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/fields/player.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/fields/tests.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/fields/unit.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/mod.rs create mode 100644 crates/benilla-protocol/src/messages/update_object/movement.rs create mode 100644 crates/benilla-protocol/src/messages/vendor.rs create mode 100644 crates/benilla-protocol/src/wire.rs create mode 100644 crates/benilla-protocol/src/world/mod.rs create mode 100644 crates/benilla-protocol/src/world/movement.rs create mode 100644 crates/benilla-protocol/src/world/reader.rs create mode 100644 crates/benilla-protocol/src/world/session.rs create mode 100644 crates/benilla-protocol/src/world/writer/channel.rs create mode 100644 crates/benilla-protocol/src/world/writer/group.rs create mode 100644 crates/benilla-protocol/src/world/writer/mail.rs create mode 100644 crates/benilla-protocol/src/world/writer/mod.rs create mode 100644 crates/benilla-protocol/src/world/writer/trade.rs create mode 100644 crates/benilla-protocol/tests/auth.rs create mode 100644 crates/benilla-protocol/tests/bank.rs create mode 100644 crates/benilla-protocol/tests/char_enum.rs create mode 100644 crates/benilla-protocol/tests/chat.rs create mode 100644 crates/benilla-protocol/tests/client.rs create mode 100644 crates/benilla-protocol/tests/common/mod.rs create mode 100644 crates/benilla-protocol/tests/gossip_vendor.rs create mode 100644 crates/benilla-protocol/tests/group.rs create mode 100644 crates/benilla-protocol/tests/handshake.rs create mode 100644 crates/benilla-protocol/tests/items.rs create mode 100644 crates/benilla-protocol/tests/mail.rs create mode 100644 crates/benilla-protocol/tests/movement.rs create mode 100644 crates/benilla-protocol/tests/simple_packets.rs create mode 100644 crates/benilla-protocol/tests/spells.rs create mode 100644 crates/benilla-protocol/tests/taxi.rs create mode 100644 crates/benilla-protocol/tests/trainer.rs create mode 100644 crates/benilla-protocol/tests/update_object.rs create mode 100644 crates/benilla-srp/Cargo.toml create mode 100644 crates/benilla-srp/examples/provision_probe.rs create mode 100644 crates/benilla-srp/src/lib.rs create mode 100644 crates/benilla-srp/src/vanilla_header.rs create mode 100644 crates/benilla-ui/Cargo.toml create mode 100644 crates/benilla-ui/src/framexml.rs create mode 100644 crates/benilla-ui/src/layout.rs create mode 100644 crates/benilla-ui/src/lib.rs create mode 100644 crates/benilla-ui/src/loader/backdrop.rs create mode 100644 crates/benilla-ui/src/loader/geometry.rs create mode 100644 crates/benilla-ui/src/loader/mod.rs create mode 100644 crates/benilla-ui/src/loader/regions.rs create mode 100644 crates/benilla-ui/src/loader/scripts.rs create mode 100644 crates/benilla-ui/src/loader/tests.rs create mode 100644 crates/benilla-ui/src/loader/widgets.rs create mode 100644 crates/benilla-ui/src/order.rs create mode 100644 crates/benilla-ui/src/script/action.rs create mode 100644 crates/benilla-ui/src/script/aura.rs create mode 100644 crates/benilla-ui/src/script/backdrop.rs create mode 100644 crates/benilla-ui/src/script/bank.rs create mode 100644 crates/benilla-ui/src/script/button.rs create mode 100644 crates/benilla-ui/src/script/char_stats.rs create mode 100644 crates/benilla-ui/src/script/clip.rs create mode 100644 crates/benilla-ui/src/script/container.rs create mode 100644 crates/benilla-ui/src/script/cooldown.rs create mode 100644 crates/benilla-ui/src/script/craft.rs create mode 100644 crates/benilla-ui/src/script/cursor.rs create mode 100644 crates/benilla-ui/src/script/cursor/bar.rs create mode 100644 crates/benilla-ui/src/script/cursor/doll.rs create mode 100644 crates/benilla-ui/src/script/cursor/drag.rs create mode 100644 crates/benilla-ui/src/script/death.rs create mode 100644 crates/benilla-ui/src/script/editbox.rs create mode 100644 crates/benilla-ui/src/script/editbox/interact.rs create mode 100644 crates/benilla-ui/src/script/editbox/methods.rs create mode 100644 crates/benilla-ui/src/script/editbox/seam.rs create mode 100644 crates/benilla-ui/src/script/editbox/tests.rs create mode 100644 crates/benilla-ui/src/script/event.rs create mode 100644 crates/benilla-ui/src/script/extract.rs create mode 100644 crates/benilla-ui/src/script/gossip.rs create mode 100644 crates/benilla-ui/src/script/item_stats.rs create mode 100644 crates/benilla-ui/src/script/item_text.rs create mode 100644 crates/benilla-ui/src/script/layout.rs create mode 100644 crates/benilla-ui/src/script/loot.rs create mode 100644 crates/benilla-ui/src/script/loot_roll.rs create mode 100644 crates/benilla-ui/src/script/mail.rs create mode 100644 crates/benilla-ui/src/script/merchant.rs create mode 100644 crates/benilla-ui/src/script/messageframe.rs create mode 100644 crates/benilla-ui/src/script/minimap.rs create mode 100644 crates/benilla-ui/src/script/mod.rs create mode 100644 crates/benilla-ui/src/script/model.rs create mode 100644 crates/benilla-ui/src/script/object.rs create mode 100644 crates/benilla-ui/src/script/object/events_regions.rs create mode 100644 crates/benilla-ui/src/script/object/frame_state.rs create mode 100644 crates/benilla-ui/src/script/object/layout_methods.rs create mode 100644 crates/benilla-ui/src/script/party.rs create mode 100644 crates/benilla-ui/src/script/pointer.rs create mode 100644 crates/benilla-ui/src/script/quest.rs create mode 100644 crates/benilla-ui/src/script/quest_log.rs create mode 100644 crates/benilla-ui/src/script/region.rs create mode 100644 crates/benilla-ui/src/script/scrollframe.rs create mode 100644 crates/benilla-ui/src/script/shapeshift.rs create mode 100644 crates/benilla-ui/src/script/skills.rs create mode 100644 crates/benilla-ui/src/script/slider.rs create mode 100644 crates/benilla-ui/src/script/sound.rs create mode 100644 crates/benilla-ui/src/script/spellbook.rs create mode 100644 crates/benilla-ui/src/script/statusbar.rs create mode 100644 crates/benilla-ui/src/script/stdlib.rs create mode 100644 crates/benilla-ui/src/script/talent.rs create mode 100644 crates/benilla-ui/src/script/taxi.rs create mode 100644 crates/benilla-ui/src/script/tests/anchors.rs create mode 100644 crates/benilla-ui/src/script/tests/backdrop.rs create mode 100644 crates/benilla-ui/src/script/tests/button.rs create mode 100644 crates/benilla-ui/src/script/tests/common.rs create mode 100644 crates/benilla-ui/src/script/tests/cooldown.rs create mode 100644 crates/benilla-ui/src/script/tests/end_to_end.rs create mode 100644 crates/benilla-ui/src/script/tests/events.rs create mode 100644 crates/benilla-ui/src/script/tests/frame_api.rs create mode 100644 crates/benilla-ui/src/script/tests/input.rs create mode 100644 crates/benilla-ui/src/script/tests/measure.rs create mode 100644 crates/benilla-ui/src/script/tests/minimap.rs create mode 100644 crates/benilla-ui/src/script/tests/mod.rs create mode 100644 crates/benilla-ui/src/script/tests/object_model.rs create mode 100644 crates/benilla-ui/src/script/tests/regions.rs create mode 100644 crates/benilla-ui/src/script/tests/scrollframe.rs create mode 100644 crates/benilla-ui/src/script/tests/slider.rs create mode 100644 crates/benilla-ui/src/script/tests/statusbar.rs create mode 100644 crates/benilla-ui/src/script/tests/stdlib.rs create mode 100644 crates/benilla-ui/src/script/tests/talent.rs create mode 100644 crates/benilla-ui/src/script/tests/taxi.rs create mode 100644 crates/benilla-ui/src/script/tests/texcoord_font.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_item/law.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_item/mechanics.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_item/mod.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_item/set.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_spell.rs create mode 100644 crates/benilla-ui/src/script/tests/tooltip_unit.rs create mode 100644 crates/benilla-ui/src/script/tests/visibility.rs create mode 100644 crates/benilla-ui/src/script/tests/worldmap.rs create mode 100644 crates/benilla-ui/src/script/tick.rs create mode 100644 crates/benilla-ui/src/script/tooltip/mod.rs create mode 100644 crates/benilla-ui/src/script/tooltip/verbs.rs create mode 100644 crates/benilla-ui/src/script/tooltip_item/mod.rs create mode 100644 crates/benilla-ui/src/script/tooltip_item/names.rs create mode 100644 crates/benilla-ui/src/script/tooltip_item/render.rs create mode 100644 crates/benilla-ui/src/script/tooltip_spell.rs create mode 100644 crates/benilla-ui/src/script/tooltip_unit.rs create mode 100644 crates/benilla-ui/src/script/trade.rs create mode 100644 crates/benilla-ui/src/script/tradeskill/api.rs create mode 100644 crates/benilla-ui/src/script/tradeskill/mod.rs create mode 100644 crates/benilla-ui/src/script/tradeskill/tests.rs create mode 100644 crates/benilla-ui/src/script/tradeskill/view.rs create mode 100644 crates/benilla-ui/src/script/trainer.rs create mode 100644 crates/benilla-ui/src/script/types.rs create mode 100644 crates/benilla-ui/src/script/unit/bindings.rs create mode 100644 crates/benilla-ui/src/script/unit/mod.rs create mode 100644 crates/benilla-ui/src/script/unit/tests.rs create mode 100644 crates/benilla-ui/src/script/worldmap.rs create mode 100644 crates/benilla-ui/src/toc.rs create mode 100644 crates/benilla-ui/src/widget/kinds/editbox.rs create mode 100644 crates/benilla-ui/src/widget/kinds/messageframe.rs create mode 100644 crates/benilla-ui/src/widget/kinds/mod.rs create mode 100644 crates/benilla-ui/src/widget/mod.rs create mode 100644 crates/benilla-ui/src/widget/propagation.rs create mode 100644 crates/benilla-ui/src/widget/tests.rs create mode 100644 crates/benilla-visual/Cargo.toml create mode 100644 crates/benilla-visual/src/lib.rs create mode 100644 crates/benilla-visual/src/main.rs create mode 100644 crates/benilla-wdt/Cargo.toml create mode 100644 crates/benilla-wdt/src/lib.rs create mode 100644 crates/benilla-wmo/Cargo.toml create mode 100644 crates/benilla-wmo/src/lib.rs create mode 100644 crates/benilla/Cargo.toml create mode 100644 crates/benilla/assets/shaders/celestial.wgsl create mode 100644 crates/benilla/assets/shaders/cloud.wgsl create mode 100644 crates/benilla/assets/shaders/ffx_glow.wgsl create mode 100644 crates/benilla/assets/shaders/liquid.wgsl create mode 100644 crates/benilla/assets/shaders/sky.wgsl create mode 100644 crates/benilla/assets/shaders/star.wgsl create mode 100644 crates/benilla/assets/shaders/terrain.wgsl create mode 100644 crates/benilla/assets/shaders/ui_add.wgsl create mode 100644 crates/benilla/assets/shaders/ui_gamma.wgsl create mode 100644 crates/benilla/assets/shaders/ui_node_gamma.wgsl create mode 100644 crates/benilla/assets/shaders/ui_quad.wgsl create mode 100644 crates/benilla/assets/shaders/ui_slice_gamma.wgsl create mode 100644 crates/benilla/assets/shaders/wdl.wgsl create mode 100644 crates/benilla/assets/shaders/wow_model.wgsl create mode 100644 crates/benilla/assets/shaders/wow_particle.wgsl create mode 100644 crates/benilla/assets/ui/ActionBar.xml create mode 100644 crates/benilla/assets/ui/BagFrame.xml create mode 100644 crates/benilla/assets/ui/BankFrame.xml create mode 100644 crates/benilla/assets/ui/BuffFrame.xml create mode 100644 crates/benilla/assets/ui/CastingBar.xml create mode 100644 crates/benilla/assets/ui/CharacterFrame.xml create mode 100644 crates/benilla/assets/ui/ChatFrame.xml create mode 100644 crates/benilla/assets/ui/CombatText.xml create mode 100644 crates/benilla/assets/ui/Cooldown.xml create mode 100644 crates/benilla/assets/ui/CraftFrame.xml create mode 100644 crates/benilla/assets/ui/DeathFrame.xml create mode 100644 crates/benilla/assets/ui/DurabilityFrame.xml create mode 100644 crates/benilla/assets/ui/ErrorsFrame.xml create mode 100644 crates/benilla/assets/ui/Fonts.xml create mode 100644 crates/benilla/assets/ui/GameTooltip.xml create mode 100644 crates/benilla/assets/ui/GossipFrame.xml create mode 100644 crates/benilla/assets/ui/GroupLootFrame.xml create mode 100644 crates/benilla/assets/ui/ItemRef.xml create mode 100644 crates/benilla/assets/ui/ItemTextFrame.xml create mode 100644 crates/benilla/assets/ui/LootFrame.xml create mode 100644 crates/benilla/assets/ui/MailFrame.xml create mode 100644 crates/benilla/assets/ui/MerchantFrame.xml create mode 100644 crates/benilla/assets/ui/MinimapCluster.xml create mode 100644 crates/benilla/assets/ui/MultiBars.xml create mode 100644 crates/benilla/assets/ui/PartyFrame.xml create mode 100644 crates/benilla/assets/ui/QuestFrame.xml create mode 100644 crates/benilla/assets/ui/QuestLogFrame.xml create mode 100644 crates/benilla/assets/ui/ScrollTemplates.xml create mode 100644 crates/benilla/assets/ui/SkillFrame.xml create mode 100644 crates/benilla/assets/ui/SpellBookFrame.xml create mode 100644 crates/benilla/assets/ui/StackSplit.xml create mode 100644 crates/benilla/assets/ui/StanceBar.xml create mode 100644 crates/benilla/assets/ui/TalentFrame.xml create mode 100644 crates/benilla/assets/ui/TaxiFrame.xml create mode 100644 crates/benilla/assets/ui/TradeFrame.xml create mode 100644 crates/benilla/assets/ui/TradeSkillFrame.xml create mode 100644 crates/benilla/assets/ui/TrainerFrame.xml create mode 100644 crates/benilla/assets/ui/UIDropDownMenu.xml create mode 100644 crates/benilla/assets/ui/UIParent.xml create mode 100644 crates/benilla/assets/ui/UiPanels.xml create mode 100644 crates/benilla/assets/ui/UnitFrames.xml create mode 100644 crates/benilla/assets/ui/UnitPopup.xml create mode 100644 crates/benilla/assets/ui/WorldMapFrame.xml create mode 100644 crates/benilla/assets/ui/ZoneText.xml create mode 100644 crates/benilla/src/area.rs create mode 100644 crates/benilla/src/assets/images.rs create mode 100644 crates/benilla/src/assets/mod.rs create mode 100644 crates/benilla/src/billboard.rs create mode 100644 crates/benilla/src/blob_shadow.rs create mode 100644 crates/benilla/src/bowstring.rs create mode 100644 crates/benilla/src/capture/fixtures.rs create mode 100644 crates/benilla/src/capture/mod.rs create mode 100644 crates/benilla/src/capture/probe_bank.rs create mode 100644 crates/benilla/src/capture/probe_castcancel.rs create mode 100644 crates/benilla/src/capture/probe_charcreate.rs create mode 100644 crates/benilla/src/capture/probe_crossing.rs create mode 100644 crates/benilla/src/capture/probe_mail.rs create mode 100644 crates/benilla/src/capture/probe_melee.rs create mode 100644 crates/benilla/src/capture/probe_partner.rs create mode 100644 crates/benilla/src/capture/probe_taxi.rs create mode 100644 crates/benilla/src/capture/probes.rs create mode 100644 crates/benilla/src/capture/scenarios.rs create mode 100644 crates/benilla/src/char_create/mod.rs create mode 100644 crates/benilla/src/char_create/panels.rs create mode 100644 crates/benilla/src/char_create/parts.rs create mode 100644 crates/benilla/src/char_create/refresh.rs create mode 100644 crates/benilla/src/char_create/screen.rs create mode 100644 crates/benilla/src/char_select/dialog.rs create mode 100644 crates/benilla/src/char_select/input.rs create mode 100644 crates/benilla/src/char_select/mod.rs create mode 100644 crates/benilla/src/char_select/refresh.rs create mode 100644 crates/benilla/src/char_select/screen.rs create mode 100644 crates/benilla/src/chat_bubble.rs create mode 100644 crates/benilla/src/clouds/kernel.rs create mode 100644 crates/benilla/src/clouds/layer.rs create mode 100644 crates/benilla/src/clouds/mod.rs create mode 100644 crates/benilla/src/clouds/tables.rs create mode 100644 crates/benilla/src/clutter.rs create mode 100644 crates/benilla/src/collision.rs create mode 100644 crates/benilla/src/combat_text/law.rs create mode 100644 crates/benilla/src/combat_text/mod.rs create mode 100644 crates/benilla/src/cooldowns.rs create mode 100644 crates/benilla/src/creature_anim.rs create mode 100644 crates/benilla/src/creature_anim/blood.rs create mode 100644 crates/benilla/src/creature_anim/driver.rs create mode 100644 crates/benilla/src/creature_anim/driver/grip.rs create mode 100644 crates/benilla/src/creature_anim/driver/play.rs create mode 100644 crates/benilla/src/creature_anim/driver/tests.rs create mode 100644 crates/benilla/src/creature_anim/driver/wound.rs create mode 100644 crates/benilla/src/creature_anim/emote_anim.rs create mode 100644 crates/benilla/src/creature_anim/env_damage.rs create mode 100644 crates/benilla/src/creature_anim/events.rs create mode 100644 crates/benilla/src/creature_anim/global_seq.rs create mode 100644 crates/benilla/src/creature_anim/impact.rs create mode 100644 crates/benilla/src/creature_anim/lod.rs create mode 100644 crates/benilla/src/creature_anim/select.rs create mode 100644 crates/benilla/src/creature_anim/select/tests.rs create mode 100644 crates/benilla/src/creature_anim/sheath.rs create mode 100644 crates/benilla/src/creature_anim/spell_visual.rs create mode 100644 crates/benilla/src/creature_anim/spell_visual/tests.rs create mode 100644 crates/benilla/src/creature_anim/twist.rs create mode 100644 crates/benilla/src/cursor.rs create mode 100644 crates/benilla/src/dbg_trace.rs create mode 100644 crates/benilla/src/death.rs create mode 100644 crates/benilla/src/debug_panel/mod.rs create mode 100644 crates/benilla/src/debug_panel/state.rs create mode 100644 crates/benilla/src/debug_panel/visibility.rs create mode 100644 crates/benilla/src/debug_panel/world.rs create mode 100644 crates/benilla/src/decal.rs create mode 100644 crates/benilla/src/doodad_anim.rs create mode 100644 crates/benilla/src/entities.rs create mode 100644 crates/benilla/src/entities/attach/char_skin.rs create mode 100644 crates/benilla/src/entities/attach/glue_preview.rs create mode 100644 crates/benilla/src/entities/attach/mod.rs create mode 100644 crates/benilla/src/entities/carried_light.rs create mode 100644 crates/benilla/src/entities/conform.rs create mode 100644 crates/benilla/src/entities/display.rs create mode 100644 crates/benilla/src/entities/equipment.rs create mode 100644 crates/benilla/src/entities/missile.rs create mode 100644 crates/benilla/src/entities/mount.rs create mode 100644 crates/benilla/src/entities/spell_fx.rs create mode 100644 crates/benilla/src/entities/wmo_props.rs create mode 100644 crates/benilla/src/entity_shade.rs create mode 100644 crates/benilla/src/ffx_glow.rs create mode 100644 crates/benilla/src/glue/add_material.rs create mode 100644 crates/benilla/src/glue/art.rs create mode 100644 crates/benilla/src/glue/backdrop.rs create mode 100644 crates/benilla/src/glue/mod.rs create mode 100644 crates/benilla/src/glue/widgets.rs create mode 100644 crates/benilla/src/glue_strings.rs create mode 100644 crates/benilla/src/go_anim.rs create mode 100644 crates/benilla/src/go_templates.rs create mode 100644 crates/benilla/src/ground_fx.rs create mode 100644 crates/benilla/src/interact.rs create mode 100644 crates/benilla/src/interact/journal.rs create mode 100644 crates/benilla/src/interior.rs create mode 100644 crates/benilla/src/items.rs create mode 100644 crates/benilla/src/lighting/apply.rs create mode 100644 crates/benilla/src/lighting/daynight.rs create mode 100644 crates/benilla/src/lighting/global_light.rs create mode 100644 crates/benilla/src/lighting/mod.rs create mode 100644 crates/benilla/src/lighting/prop_probes.rs create mode 100644 crates/benilla/src/lighting/resolve.rs create mode 100644 crates/benilla/src/lighting/sh.rs create mode 100644 crates/benilla/src/liquid.rs create mode 100644 crates/benilla/src/loading_screen.rs create mode 100644 crates/benilla/src/login/mod.rs create mode 100644 crates/benilla/src/login/screen.rs create mode 100644 crates/benilla/src/main.rs create mode 100644 crates/benilla/src/map_proj.rs create mode 100644 crates/benilla/src/mesh_tag.rs create mode 100644 crates/benilla/src/minimap/blips/dots.rs create mode 100644 crates/benilla/src/minimap/blips/mod.rs create mode 100644 crates/benilla/src/minimap/interior.rs create mode 100644 crates/benilla/src/minimap/mod.rs create mode 100644 crates/benilla/src/model_fade.rs create mode 100644 crates/benilla/src/model_render.rs create mode 100644 crates/benilla/src/modkeys.rs create mode 100644 crates/benilla/src/nameplates.rs create mode 100644 crates/benilla/src/names.rs create mode 100644 crates/benilla/src/net.rs create mode 100644 crates/benilla/src/net/apply.rs create mode 100644 crates/benilla/src/net/apply/chat.rs create mode 100644 crates/benilla/src/net/apply/combat.rs create mode 100644 crates/benilla/src/net/apply/combat_log.rs create mode 100644 crates/benilla/src/net/apply/group.rs create mode 100644 crates/benilla/src/net/apply/loot.rs create mode 100644 crates/benilla/src/net/apply/mail.rs create mode 100644 crates/benilla/src/net/apply/npc.rs create mode 100644 crates/benilla/src/net/apply/objects.rs create mode 100644 crates/benilla/src/net/apply/quests.rs create mode 100644 crates/benilla/src/net/apply/session.rs create mode 100644 crates/benilla/src/net/apply/spells.rs create mode 100644 crates/benilla/src/net/apply/trade.rs create mode 100644 crates/benilla/src/net/io.rs create mode 100644 crates/benilla/src/net/motion.rs create mode 100644 crates/benilla/src/net/motion/facing.rs create mode 100644 crates/benilla/src/net/motion/remote.rs create mode 100644 crates/benilla/src/net/motion/spline.rs create mode 100644 crates/benilla/src/net/motion/tests.rs create mode 100644 crates/benilla/src/npc_text.rs create mode 100644 crates/benilla/src/particles.rs create mode 100644 crates/benilla/src/particles/material.rs create mode 100644 crates/benilla/src/particles/model.rs create mode 100644 crates/benilla/src/particles/quads.rs create mode 100644 crates/benilla/src/particles/sim.rs create mode 100644 crates/benilla/src/pending_item_ops.rs create mode 100644 crates/benilla/src/perf.rs create mode 100644 crates/benilla/src/player.rs create mode 100644 crates/benilla/src/player/arc.rs create mode 100644 crates/benilla/src/player/camera.rs create mode 100644 crates/benilla/src/player/gait.rs create mode 100644 crates/benilla/src/player/move_trace.rs create mode 100644 crates/benilla/src/player/movement_net.rs create mode 100644 crates/benilla/src/player/mover.rs create mode 100644 crates/benilla/src/player/server_ride.rs create mode 100644 crates/benilla/src/player/setup.rs create mode 100644 crates/benilla/src/player/state.rs create mode 100644 crates/benilla/src/player/swim.rs create mode 100644 crates/benilla/src/player/wire_in.rs create mode 100644 crates/benilla/src/portrait/booth.rs create mode 100644 crates/benilla/src/portrait/framing.rs create mode 100644 crates/benilla/src/portrait/glue_booth.rs create mode 100644 crates/benilla/src/portrait/mod.rs create mode 100644 crates/benilla/src/portrait/test_bake.rs create mode 100644 crates/benilla/src/quest_markers.rs create mode 100644 crates/benilla/src/raid_marks.rs create mode 100644 crates/benilla/src/ribbons.rs create mode 100644 crates/benilla/src/schedule.rs create mode 100644 crates/benilla/src/sky.rs create mode 100644 crates/benilla/src/sky_order.rs create mode 100644 crates/benilla/src/smart_rect.rs create mode 100644 crates/benilla/src/sound/anim_events.rs create mode 100644 crates/benilla/src/sound/combat.rs create mode 100644 crates/benilla/src/sound/creature.rs create mode 100644 crates/benilla/src/sound/emote.rs create mode 100644 crates/benilla/src/sound/footsteps.rs create mode 100644 crates/benilla/src/sound/gameobject.rs create mode 100644 crates/benilla/src/sound/glue.rs create mode 100644 crates/benilla/src/sound/greeting.rs create mode 100644 crates/benilla/src/sound/interior.rs create mode 100644 crates/benilla/src/sound/kit.rs create mode 100644 crates/benilla/src/sound/liquid_loop.rs create mode 100644 crates/benilla/src/sound/math.rs create mode 100644 crates/benilla/src/sound/missile.rs create mode 100644 crates/benilla/src/sound/mixer.rs create mode 100644 crates/benilla/src/sound/mod.rs create mode 100644 crates/benilla/src/sound/money.rs create mode 100644 crates/benilla/src/sound/mount.rs create mode 100644 crates/benilla/src/sound/reverb.rs create mode 100644 crates/benilla/src/sound/sheathe.rs create mode 100644 crates/benilla/src/sound/spell.rs create mode 100644 crates/benilla/src/sound/ui.rs create mode 100644 crates/benilla/src/sound/water.rs create mode 100644 crates/benilla/src/sound/weather.rs create mode 100644 crates/benilla/src/sound/zone.rs create mode 100644 crates/benilla/src/sun/follow.rs create mode 100644 crates/benilla/src/sun/materials.rs create mode 100644 crates/benilla/src/sun/mesh.rs create mode 100644 crates/benilla/src/sun/mod.rs create mode 100644 crates/benilla/src/sun/setup.rs create mode 100644 crates/benilla/src/target/click.rs create mode 100644 crates/benilla/src/target/cursor_mode.rs create mode 100644 crates/benilla/src/target/flash.rs create mode 100644 crates/benilla/src/target/highlight.rs create mode 100644 crates/benilla/src/target/hover.rs create mode 100644 crates/benilla/src/target/mod.rs create mode 100644 crates/benilla/src/target/ring.rs create mode 100644 crates/benilla/src/target/scan.rs create mode 100644 crates/benilla/src/terrain.rs create mode 100644 crates/benilla/src/terrain_stream.rs create mode 100644 crates/benilla/src/terrain_stream/collider.rs create mode 100644 crates/benilla/src/terrain_stream/queries.rs create mode 100644 crates/benilla/src/terrain_stream/spawn/assemble.rs create mode 100644 crates/benilla/src/terrain_stream/spawn/fx.rs create mode 100644 crates/benilla/src/terrain_stream/spawn/mod.rs create mode 100644 crates/benilla/src/thread_qos.rs create mode 100644 crates/benilla/src/transport.rs create mode 100644 crates/benilla/src/ui_action/cast_fail.rs create mode 100644 crates/benilla/src/ui_action/cast_target.rs create mode 100644 crates/benilla/src/ui_action/errors.rs create mode 100644 crates/benilla/src/ui_action/mod.rs create mode 100644 crates/benilla/src/ui_action/state.rs create mode 100644 crates/benilla/src/ui_action/usable.rs create mode 100644 crates/benilla/src/ui_aura.rs create mode 100644 crates/benilla/src/ui_bank.rs create mode 100644 crates/benilla/src/ui_cast.rs create mode 100644 crates/benilla/src/ui_char.rs create mode 100644 crates/benilla/src/ui_chat.rs create mode 100644 crates/benilla/src/ui_chat/edit.rs create mode 100644 crates/benilla/src/ui_chat/event.rs create mode 100644 crates/benilla/src/ui_chat/feed.rs create mode 100644 crates/benilla/src/ui_chat/frames.rs create mode 100644 crates/benilla/src/ui_chat/input.rs create mode 100644 crates/benilla/src/ui_chat/tests.rs create mode 100644 crates/benilla/src/ui_craft.rs create mode 100644 crates/benilla/src/ui_gamma.rs create mode 100644 crates/benilla/src/ui_gossip.rs create mode 100644 crates/benilla/src/ui_item_text.rs create mode 100644 crates/benilla/src/ui_items/drain.rs create mode 100644 crates/benilla/src/ui_items/equip_error.rs create mode 100644 crates/benilla/src/ui_items/feed.rs create mode 100644 crates/benilla/src/ui_items/mod.rs create mode 100644 crates/benilla/src/ui_loot.rs create mode 100644 crates/benilla/src/ui_loot_roll.rs create mode 100644 crates/benilla/src/ui_mail.rs create mode 100644 crates/benilla/src/ui_merchant.rs create mode 100644 crates/benilla/src/ui_party/feed.rs create mode 100644 crates/benilla/src/ui_party/mod.rs create mode 100644 crates/benilla/src/ui_pass.rs create mode 100644 crates/benilla/src/ui_quest.rs create mode 100644 crates/benilla/src/ui_quest_log.rs create mode 100644 crates/benilla/src/ui_script/action_bar_tests.rs create mode 100644 crates/benilla/src/ui_script/bag_tests.rs create mode 100644 crates/benilla/src/ui_script/bank_tests.rs create mode 100644 crates/benilla/src/ui_script/buff_tests.rs create mode 100644 crates/benilla/src/ui_script/cast_tests.rs create mode 100644 crates/benilla/src/ui_script/character_tests.rs create mode 100644 crates/benilla/src/ui_script/chat_tests.rs create mode 100644 crates/benilla/src/ui_script/combat_text_tests.rs create mode 100644 crates/benilla/src/ui_script/death_tests.rs create mode 100644 crates/benilla/src/ui_script/delete_item_tests.rs create mode 100644 crates/benilla/src/ui_script/durability_tests.rs create mode 100644 crates/benilla/src/ui_script/errors_tests.rs create mode 100644 crates/benilla/src/ui_script/escape_tests.rs create mode 100644 crates/benilla/src/ui_script/extract/mod.rs create mode 100644 crates/benilla/src/ui_script/extract/text.rs create mode 100644 crates/benilla/src/ui_script/group_loot_tests.rs create mode 100644 crates/benilla/src/ui_script/input.rs create mode 100644 crates/benilla/src/ui_script/loot_tests.rs create mode 100644 crates/benilla/src/ui_script/merchant_tests.rs create mode 100644 crates/benilla/src/ui_script/minimap_tests.rs create mode 100644 crates/benilla/src/ui_script/mod.rs create mode 100644 crates/benilla/src/ui_script/multibar_stance_tests.rs create mode 100644 crates/benilla/src/ui_script/panel_tests.rs create mode 100644 crates/benilla/src/ui_script/quest_tests.rs create mode 100644 crates/benilla/src/ui_script/questlog_tests.rs create mode 100644 crates/benilla/src/ui_script/shipped_xml_tests.rs create mode 100644 crates/benilla/src/ui_script/spellbook_tests.rs create mode 100644 crates/benilla/src/ui_script/static_popup_tests.rs create mode 100644 crates/benilla/src/ui_script/target_aura_tests.rs create mode 100644 crates/benilla/src/ui_script/taxi_tests.rs create mode 100644 crates/benilla/src/ui_script/textkeys.rs create mode 100644 crates/benilla/src/ui_script/tooltip_anchor_tests.rs create mode 100644 crates/benilla/src/ui_script/tooltip_compare_tests.rs create mode 100644 crates/benilla/src/ui_script/trainer_tests.rs create mode 100644 crates/benilla/src/ui_script/unit_frame_tests.rs create mode 100644 crates/benilla/src/ui_script/unit_popup_tests.rs create mode 100644 crates/benilla/src/ui_script/zone_text_tests.rs create mode 100644 crates/benilla/src/ui_session.rs create mode 100644 crates/benilla/src/ui_shapeshift.rs create mode 100644 crates/benilla/src/ui_spellbook.rs create mode 100644 crates/benilla/src/ui_talent.rs create mode 100644 crates/benilla/src/ui_taxi/mod.rs create mode 100644 crates/benilla/src/ui_taxi/routing.rs create mode 100644 crates/benilla/src/ui_text/atlas.rs create mode 100644 crates/benilla/src/ui_text/layout/mod.rs create mode 100644 crates/benilla/src/ui_text/layout/overflow.rs create mode 100644 crates/benilla/src/ui_text/layout/wrap.rs create mode 100644 crates/benilla/src/ui_text/markup.rs create mode 100644 crates/benilla/src/ui_text/mod.rs create mode 100644 crates/benilla/src/ui_tooltip.rs create mode 100644 crates/benilla/src/ui_trade.rs create mode 100644 crates/benilla/src/ui_tradeskill.rs create mode 100644 crates/benilla/src/ui_trainer.rs create mode 100644 crates/benilla/src/ui_unit.rs create mode 100644 crates/benilla/src/ui_world_map.rs create mode 100644 crates/benilla/src/view.rs create mode 100644 crates/benilla/src/vplates.rs create mode 100644 crates/benilla/src/vplates/border.rs create mode 100644 crates/benilla/src/water_fx/mod.rs create mode 100644 crates/benilla/src/water_fx/params.rs create mode 100644 crates/benilla/src/water_fx/view.rs create mode 100644 crates/benilla/src/wdl.rs create mode 100644 crates/benilla/src/weather/mod.rs create mode 100644 crates/benilla/src/weather/precip/mist.rs create mode 100644 crates/benilla/src/weather/precip/mod.rs create mode 100644 crates/benilla/src/weather/precip/pool.rs create mode 100644 crates/benilla/src/weather/precip/render.rs create mode 100644 crates/benilla/src/weather/precip/wind.rs create mode 100644 crates/benilla/src/wmo_portal/audit/light_probe.rs create mode 100644 crates/benilla/src/wmo_portal/audit/mod.rs create mode 100644 crates/benilla/src/wmo_portal/fog.rs create mode 100644 crates/benilla/src/wmo_portal/interior.rs create mode 100644 crates/benilla/src/wmo_portal/mod.rs create mode 100644 crates/benilla/src/wmo_portal/probe.rs create mode 100644 crates/benilla/src/wmo_portal/seed.rs create mode 100644 crates/benilla/src/world_map.rs create mode 100644 crates/benilla/tests/item_text_frame.rs create mode 100644 crates/benilla/tests/mail_frame.rs create mode 100644 crates/benilla/tests/talent_frame.rs create mode 100644 crates/benilla/tests/trade_frame.rs create mode 100644 crates/benilla/tests/tradeskill_frame.rs create mode 100644 rust-toolchain.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..706cf583 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Rust build artifacts +/target/ +**/*.rs.bk +# Cargo.lock IS committed (this workspace produces binaries). + +# --- Blizzard-owned content: NEVER commit --- +# A legally-installed 1.12.1 client, read at runtime; not redistributed. +/WoW +# Cached / extracted assets derived from an install. +/extracted/ +/out/ +# Real client files used as test fixtures (document expected paths, do not commit). +**/tests/fixtures/ + +# Profiling output (`--features bevy/trace_chrome`). +trace-*.json + +# OS / editor cruft +.DS_Store +*.swp +/.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..38e67fb3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8323 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" + +[[package]] +name = "accesskit_consumer" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +dependencies = [ + "accesskit", + "hashbrown 0.15.5", +] + +[[package]] +name = "accesskit_macos" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_windows" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.15.5", + "static_assertions", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.11.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.11.1", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "num_enum", + "simd_cesu8", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arbitrary-chunks" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad8689a486416c401ea15715a4694de30054248ec627edbf31f49cb64ee4086" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-arena" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e8ed45f88ed32e6827a96b62d8fd4086d72defc754c5c6bd08470c1aaf648e" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomicow" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301801c08259e328a1c7da556608c0c22687708831b22024dbd3a57ea741e6de" +dependencies = [ + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avian3d" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0751cde05c2be789fad86cd19219daf8a8907e0c6c2175f2c8144706493f010" +dependencies = [ + "approx", + "avian_derive", + "bevy", + "bevy_heavy", + "bevy_math", + "bevy_transform_interpolation", + "bitflags 2.11.1", + "derive_more", + "disqualified", + "glam_matrix_extras", + "itertools 0.14.0", + "obvhs", + "parry3d", + "parry3d-f64", + "smallvec", + "thiserror 2.0.18", + "thread_local", +] + +[[package]] +name = "avian_derive" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95522267606c85f77ba40b55735583618f7a48e873be71a934f71dd5519ce86" +dependencies = [ + "proc-macro-error3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "benilla" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "avian3d", + "benilla-assets", + "benilla-formats", + "benilla-protocol", + "benilla-ui", + "bevy", + "bevy_egui", + "bytemuck", + "cosmic-text", + "crossbeam-channel", + "fontdb", + "image", + "kira", + "mint", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "benilla-adt" +version = "0.1.0" +dependencies = [ + "benilla-bytes", +] + +[[package]] +name = "benilla-assets" +version = "0.1.0" +dependencies = [ + "anyhow", + "benilla-formats", + "bevy", + "image", + "serde", +] + +[[package]] +name = "benilla-blp" +version = "0.1.0" +dependencies = [ + "benilla-bytes", + "texpresso", +] + +[[package]] +name = "benilla-bytes" +version = "0.1.0" + +[[package]] +name = "benilla-dbc" +version = "0.1.0" +dependencies = [ + "benilla-bytes", +] + +[[package]] +name = "benilla-formats" +version = "0.1.0" +dependencies = [ + "anyhow", + "benilla-adt", + "benilla-blp", + "benilla-bytes", + "benilla-dbc", + "benilla-m2", + "benilla-mpq", + "benilla-wdt", + "benilla-wmo", + "clap", + "image", +] + +[[package]] +name = "benilla-m2" +version = "0.1.0" +dependencies = [ + "benilla-bytes", +] + +[[package]] +name = "benilla-mpq" +version = "0.1.0" +dependencies = [ + "benilla-bytes", + "flate2", +] + +[[package]] +name = "benilla-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "benilla-srp", + "clap", + "flate2", +] + +[[package]] +name = "benilla-srp" +version = "0.1.0" +dependencies = [ + "num-bigint", + "rand 0.8.6", + "sha1", +] + +[[package]] +name = "benilla-ui" +version = "0.1.0" +dependencies = [ + "mlua", + "roxmltree", +] + +[[package]] +name = "benilla-visual" +version = "0.1.0" +dependencies = [ + "anyhow", + "image", +] + +[[package]] +name = "benilla-wdt" +version = "0.1.0" + +[[package]] +name = "benilla-wmo" +version = "0.1.0" +dependencies = [ + "benilla-bytes", +] + +[[package]] +name = "bevy" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" +dependencies = [ + "bevy_internal", + "tracing", +] + +[[package]] +name = "bevy_a11y" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e887b25c84f384ffe3278a17cf0e4b405eaa3c8fbc3db24d05d560a11780676d" +dependencies = [ + "accesskit", + "bevy_app", + "bevy_derive", + "bevy_ecs", + "bevy_reflect", +] + +[[package]] +name = "bevy_android" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c58de772ac1148884112e8a456c4f127a94b95a0e42ab5b160b7a11895a241" +dependencies = [ + "android-activity", +] + +[[package]] +name = "bevy_animation" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be5bf5b285f0d3fab983b4505e62e195e06930a29007ffc95bdabde834e163a2" +dependencies = [ + "bevy_animation_macros", + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", + "blake3", + "derive_more", + "downcast-rs 2.0.2", + "either", + "petgraph", + "ron", + "serde", + "smallvec", + "thiserror 2.0.18", + "thread_local", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_animation_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf35516d0e7ac9ec25df533be1bf8cbaa20596a8e65f36838a3f7803a267d6d" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn", +] + +[[package]] +name = "bevy_anti_alias" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726cc494eb7d6a84ce6291c23636fd451fa4846604dc059fa93febca4e60a928" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_app" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def9f41aa5bf9b9dec8beda307a332798609cffb9d44f71005e0cfb45164f2f6" +dependencies = [ + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "cfg-if", + "console_error_panic_hook", + "ctrlc", + "downcast-rs 2.0.2", + "log", + "thiserror 2.0.18", + "tracing", + "variadics_please", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bevy_asset" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f86fed15972b9fb1a3f7b092cf0390e67131caaedab15a2707c043e3a3c886" +dependencies = [ + "async-broadcast", + "async-channel", + "async-fs", + "async-io", + "async-lock", + "atomicow", + "bevy_android", + "bevy_app", + "bevy_asset_macros", + "bevy_diagnostic", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.11.1", + "blake3", + "crossbeam-channel", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "either", + "futures-io", + "futures-lite", + "futures-util", + "js-sys", + "ron", + "serde", + "stackfuture", + "thiserror 2.0.18", + "tracing", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "bevy_asset_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cb8d948365b06561b43b7d709282e62a6abb756baac5d8e295206d5e156168" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_audio" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d68da32468ce7f4bb2863b71326acfaaa88e9aef8da8306257cd487d40cede4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_math", + "bevy_reflect", + "bevy_transform", + "coreaudio-sys", + "cpal 0.15.3", + "rodio", + "tracing", +] + +[[package]] +name = "bevy_camera" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ed9eed054e14341852236d06a7244597b1ace39ff9ae023fbd188ffde88619" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "downcast-rs 2.0.2", + "serde", + "smallvec", + "thiserror 2.0.18", + "wgpu-types", +] + +[[package]] +name = "bevy_color" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb41e8310a85811d14a4e75cfc2d6c07ac70661d6a4883509fc960f622970a8" +dependencies = [ + "bevy_math", + "bevy_reflect", + "bytemuck", + "derive_more", + "encase", + "serde", + "thiserror 2.0.18", + "wgpu-types", +] + +[[package]] +name = "bevy_core_pipeline" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d0810e85c2436e50c67448d48a83bf0bb1b5849899619ae2c7ea817221e9172" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.11.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "318ee0532c3da93749859d18f89a889c638fbc56aabac4d866583df7b951d103" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn", +] + +[[package]] +name = "bevy_dev_tools" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f1464a3f5ef5c23d917987714ee89881f9f791e9ff97ecf6600ee846b9569e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_state", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_window", + "tracing", +] + +[[package]] +name = "bevy_diagnostic" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8543a0f7afd56d3499ba80ffab6ef0bad12f93c2d2ca9aa7b1f1b8816c3980" +dependencies = [ + "atomic-waker", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_tasks", + "bevy_time", + "const-fnv1a-hash", + "log", + "serde", + "sysinfo", +] + +[[package]] +name = "bevy_ecs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.11.1", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.18", + "tracing", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908baf585e2ea16bd53ef0da57b69580478af0059d2dbdb4369991ac9794b618" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_egui" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d0a7e4806f3f242326d2c6157531c36d710f3bf320ebc0a1678e44635ed0eac" +dependencies = [ + "arboard", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_time", + "bevy_transform", + "bevy_ui_render", + "bevy_utils", + "bevy_window", + "bevy_winit", + "bytemuck", + "crossbeam-channel", + "egui", + "encase", + "getrandom 0.3.4", + "image", + "itertools 0.14.0", + "js-sys", + "thread_local", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webbrowser", + "wgpu-types", + "winit", +] + +[[package]] +name = "bevy_encase_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fee46eeddcbc00a805ae00ffa973f224671fc5cf0fe1a796963804faeade90" +dependencies = [ + "bevy_macro_utils", + "encase_derive_impl", +] + +[[package]] +name = "bevy_feathers" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb29be8f8443c5cc44e1c4710bbe02877e73703c60228ca043f20529a5496c6" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_text", + "bevy_ui", + "bevy_ui_render", + "bevy_ui_widgets", + "bevy_window", + "smol_str", +] + +[[package]] +name = "bevy_gilrs" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "611827ab0ce43b88c0a695e6603901b5f34687efecaf526c861456c9d8e6fedb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_platform", + "bevy_time", + "gilrs", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_gizmos" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aaff0dd5f405c83d290c5cd591835f1ae8009894947ab19dadcb323062bd7e7" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_gizmos_macros", + "bevy_light", + "bevy_math", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_utils", +] + +[[package]] +name = "bevy_gizmos_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6960ea308d7e94adcac5c712553ff86614bba6b663511f3f3812f6bec028b51e" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn", +] + +[[package]] +name = "bevy_gizmos_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a8d18c089102de4c5e9326023ad96ba618a6961029f8102a33640b966883237" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_core_pipeline", + "bevy_ecs", + "bevy_gizmos", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_render", + "bevy_shader", + "bevy_sprite_render", + "bevy_transform", + "bevy_utils", + "bytemuck", + "tracing", +] + +[[package]] +name = "bevy_gltf" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f37fb52655d0439656ca0a1db027d46926e463c81d893d4b1639668e5d7f1c1" +dependencies = [ + "async-lock", + "base64", + "bevy_animation", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_tasks", + "bevy_transform", + "fixedbitset", + "gltf", + "itertools 0.14.0", + "percent-encoding", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_heavy" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc496d1d43b890896cf561d8ce3dcf7b7b8e4c03c4e837a49a83a530abccbc5e" +dependencies = [ + "bevy_math", + "bevy_reflect", + "glam_matrix_extras", +] + +[[package]] +name = "bevy_image" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a71daf9b2afdd032c2b1122d1d501f99126218cb3e9983b3604ec381daa35f22" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "bitflags 2.11.1", + "bytemuck", + "futures-lite", + "guillotiere", + "half", + "image", + "ktx2", + "rectangle-pack", + "ruzstd", + "serde", + "thiserror 2.0.18", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_input" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc8ffbd02df34dfc52faf420a5263985973765e228043adf542fd0d790a6b21" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "derive_more", + "log", + "smol_str", + "thiserror 2.0.18", +] + +[[package]] +name = "bevy_input_focus" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d48a5bceccb9157549a39ab3de4017f5368b65db6471605e9a3f1c19d91bbc" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_window", + "log", + "thiserror 2.0.18", +] + +[[package]] +name = "bevy_internal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a11df62e49897def470471551c02f13c6fb488e55dddb5ab7ef098132e07754" +dependencies = [ + "bevy_a11y", + "bevy_android", + "bevy_animation", + "bevy_anti_alias", + "bevy_app", + "bevy_asset", + "bevy_audio", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_dev_tools", + "bevy_diagnostic", + "bevy_ecs", + "bevy_feathers", + "bevy_gilrs", + "bevy_gizmos", + "bevy_gizmos_render", + "bevy_gltf", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_pbr", + "bevy_picking", + "bevy_platform", + "bevy_post_process", + "bevy_ptr", + "bevy_reflect", + "bevy_render", + "bevy_scene", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_state", + "bevy_tasks", + "bevy_text", + "bevy_time", + "bevy_transform", + "bevy_ui", + "bevy_ui_render", + "bevy_utils", + "bevy_window", + "bevy_winit", +] + +[[package]] +name = "bevy_light" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9d2ac64390a9baacb3c0fa0f5456ac1553959d5a387874c102a09aab8b92cc" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "tracing", +] + +[[package]] +name = "bevy_log" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2aac1187f83a1ab2eae887564f7fb14b4abb3fbe8b2267a6426663463923120" +dependencies = [ + "android_log-sys", + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_utils", + "tracing", + "tracing-error", + "tracing-log", + "tracing-oslog", + "tracing-subscriber", + "tracing-tracy", + "tracing-wasm", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b147843b81a7ec548876ff97fa7bfdc646ef2567cb465566259237b39664438" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "bevy_math" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e931fa969f89c83498b22c97432383afe90e90fd1a5e04fa07be8da4d3bcac84" +dependencies = [ + "approx", + "arrayvec", + "bevy_reflect", + "derive_more", + "glam 0.30.10", + "itertools 0.14.0", + "libm", + "rand 0.9.4", + "rand_distr", + "serde", + "thiserror 2.0.18", + "variadics_please", +] + +[[package]] +name = "bevy_mesh" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288f590c8173d4cca3cae5f2ba579accd5ed1a35dd3fab338f427eb39d55f05e" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mikktspace", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bitflags 2.11.1", + "bytemuck", + "derive_more", + "hexasphere", + "thiserror 2.0.18", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_mikktspace" +version = "0.17.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef8e4b7e61dfe7719bb03c884dc270cd46a82efb40f93e9933b990c5c190c59" + +[[package]] +name = "bevy_pbr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ab6944ffc6fd71604c0fbca68cc3e2a3654edfcdbfd232f9d8b88e3d20fdc0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_image", + "bevy_light", + "bevy_log", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bitflags 2.11.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "offset-allocator", + "smallvec", + "static_assertions", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_picking" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d524dbc8f2c9e73f7ab70c148c8f7886f3c24b8aa8c252a38ba68ed06cbf10" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_input", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_time", + "bevy_transform", + "bevy_window", + "crossbeam-channel", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_platform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b" +dependencies = [ + "critical-section", + "foldhash 0.2.0", + "futures-channel", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "bevy_post_process" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f77a4e894aea992e3d6938f1d5898a1cdbb87dba6eebfb95cb4038d0a2600e9" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.11.1", + "nonmax", + "radsort", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "bevy_ptr" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a9329e8dc4e01ced480eeec4902e6d7cb56e56ec37f6fbc4323e5c937290a7" + +[[package]] +name = "bevy_reflect" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1dfeb67a9fe4f59003a84f5f99ba6302141c70e926601cbc6abfd4a1eea9ca9" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash 0.2.0", + "glam 0.30.10", + "indexmap", + "inventory", + "petgraph", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.18", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475f68c93e9cd5f17e9167635c8533a4f388f12d38245a202359e4c2721d87ba" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn", + "uuid", +] + +[[package]] +name = "bevy_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243523e33fe5dfcebc4240b1eb2fc16e855c5d4c0ea6a8393910740956770f44" +dependencies = [ + "async-channel", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_diagnostic", + "bevy_ecs", + "bevy_encase_derive", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render_macros", + "bevy_shader", + "bevy_tasks", + "bevy_time", + "bevy_transform", + "bevy_utils", + "bevy_window", + "bitflags 2.11.1", + "bytemuck", + "derive_more", + "downcast-rs 2.0.2", + "encase", + "fixedbitset", + "glam 0.30.10", + "image", + "indexmap", + "js-sys", + "naga", + "nonmax", + "offset-allocator", + "profiling", + "send_wrapper", + "smallvec", + "thiserror 2.0.18", + "tracing", + "tracy-client", + "variadics_please", + "wasm-bindgen", + "web-sys", + "wgpu", +] + +[[package]] +name = "bevy_render_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b6325e9c495a71270446784611e8d7f446f927eac8506c4c099fd10cb4c3ed" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_scene" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cc1047d85ec8048261b63ef675c12f1e6b5782dc0b422fbcee0c140d026bd4" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_derive", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_transform", + "bevy_utils", + "derive_more", + "ron", + "serde", + "thiserror 2.0.18", + "uuid", +] + +[[package]] +name = "bevy_shader" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eea95f0273c32be13d6a0b799a93bc256ad7830759ede595c404d5234302da2" +dependencies = [ + "bevy_asset", + "bevy_platform", + "bevy_reflect", + "naga", + "naga_oil", + "serde", + "thiserror 2.0.18", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ec5bc0cbdee551b610a46f41d30374bbe42b8951ffc676253c6243ab2b9395" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_picking", + "bevy_reflect", + "bevy_text", + "bevy_transform", + "bevy_window", + "radsort", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_sprite_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b82cb08905e7ddcea2694a95f757ae7f1fd01e6a7304076bad595d2158e4bfe0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bitflags 2.11.1", + "bytemuck", + "derive_more", + "fixedbitset", + "nonmax", + "tracing", +] + +[[package]] +name = "bevy_state" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae0682968e97d29c1eccc8c6bb6283f2678d362779bc03f1bb990967059473b" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bevy_state_macros", + "bevy_utils", + "log", + "variadics_please", +] + +[[package]] +name = "bevy_state_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73d32f90f9cfcef5a44401db7ce206770daaa1707b0fb95eb7a96a6933f54f1b" +dependencies = [ + "bevy_macro_utils", + "quote", + "syn", +] + +[[package]] +name = "bevy_tasks" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384eb04d80aa38664d69988fd30cbbe03e937ecb65c66aa6abe60ce0bca826aa" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless 0.9.3", + "pin-project", +] + +[[package]] +name = "bevy_text" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc5233291dfc22e584de2535f2e37ae9766d37cb5a01652de2133ba202dcb9b" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_utils", + "cosmic-text", + "serde", + "smallvec", + "sys-locale", + "thiserror 2.0.18", + "tracing", + "wgpu-types", +] + +[[package]] +name = "bevy_time" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5ef9af4e523195e561074cf60fbfad0f4cb8d1db504855fee3c4ce8896c7244" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "crossbeam-channel", + "log", + "serde", +] + +[[package]] +name = "bevy_transform" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3bb3de7842fef699344beb03f22bdbff16599d788fe0f47fbb3b1e6fa320eb" +dependencies = [ + "bevy_app", + "bevy_ecs", + "bevy_log", + "bevy_math", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "derive_more", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "bevy_transform_interpolation" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88545c8b0fa8f3502b9a439c71fa6b596ee9e808bfb16f27a51c8c6f7405a657" +dependencies = [ + "bevy", +] + +[[package]] +name = "bevy_ui" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1691a411014085e0d35f8bb8208e5f973edd7ace061a4b1c41c83de21579dc70" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_math", + "bevy_picking", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "smallvec", + "taffy", + "thiserror 2.0.18", + "tracing", + "uuid", +] + +[[package]] +name = "bevy_ui_render" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2c35402d8a052f512e3fec1f36b26e83eee713fcca57f965c244ee795e1fcb0" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_core_pipeline", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_math", + "bevy_mesh", + "bevy_platform", + "bevy_reflect", + "bevy_render", + "bevy_shader", + "bevy_sprite", + "bevy_sprite_render", + "bevy_text", + "bevy_transform", + "bevy_ui", + "bevy_utils", + "bytemuck", + "derive_more", + "tracing", +] + +[[package]] +name = "bevy_ui_widgets" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a63cb818b0de41bdb14990e0ce1aaaa347f871750ab280f80c427e83d72712" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_camera", + "bevy_ecs", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_picking", + "bevy_reflect", + "bevy_ui", +] + +[[package]] +name = "bevy_utils" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2111910cd7a4b1e6ce07eaaeb6f68a2c0ea0ca609ed0d0d506e3eb161101435b" +dependencies = [ + "bevy_platform", + "disqualified", + "thread_local", +] + +[[package]] +name = "bevy_window" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df06e6993a0896bae2fe7644ae6def29a1a92b45dfb1bcebbd92af782be3638" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "log", + "raw-window-handle", + "serde", +] + +[[package]] +name = "bevy_winit" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2de1c13d32ab8528435b58eca7ab874a1068184c6d6f266ee11433ae99d4069" +dependencies = [ + "accesskit", + "accesskit_winit", + "approx", + "bevy_a11y", + "bevy_android", + "bevy_app", + "bevy_asset", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_tasks", + "bevy_window", + "bytemuck", + "cfg-if", + "js-sys", + "tracing", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winit", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "bytemuck", + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-pseudorand" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2097358495d244a0643746f4d13eedba4608137008cf9dec54e53a3b700115a6" +dependencies = [ + "chiapos-chacha8", + "nanorand", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chiapos-chacha8" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f8be573a85f6c2bc1b8e43834c07e32f95e489b914bf856c0549c3c269cd0a" +dependencies = [ + "rayon", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-fnv1a-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "const_soft_float" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ca1caa64ef4ed453e68bb3db612e51cf1b2f5b871337f0fcab1c8f87cc3dff" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "constgebra" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aaf9b65849a68662ac6c0810c8893a765c960b907dd7cfab9c4a50bf764fbc" +dependencies = [ + "const_soft_float", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cosmic-text" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cadaea21e24c49c0c82116f2b465ae6a49d63c90e428b0f8d9ae1f638ac91f" +dependencies = [ + "bitflags 2.11.1", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 1.1.0", + "self_cell", + "skrifa 0.39.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa 0.9.1", + "core-foundation-sys", + "coreaudio-rs 0.11.3", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.4.3", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa 0.10.0", + "coreaudio-rs 0.13.0", + "dasp_sample", + "jni 0.21.1", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "ahash", + "bitflags 2.11.1", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encase" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e3e0ff2ee0b7aa97428308dd9e1e42369cb22f5fb8dc1c55546637443a60f1e" +dependencies = [ + "const_panic", + "encase_derive", + "thiserror 2.0.18", +] + +[[package]] +name = "encase_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4d90c5d7d527c6cb8a3b114efd26a6304d9ab772656e73d8f4e32b1f3d601a2" +dependencies = [ + "encase_derive_impl", +] + +[[package]] +name = "encase_derive_impl" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bad72d8308f7a382de2391ec978ddd736e0103846b965d7e2a63a75768af30" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "extended" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gilrs" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462" +dependencies = [ + "fnv", + "gilrs-core", + "log", + "uuid", + "vec_map", +] + +[[package]] +name = "gilrs-core" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04" +dependencies = [ + "inotify", + "js-sys", + "libc", + "libudev-sys", + "log", + "nix 0.30.1", + "objc2-core-foundation", + "objc2-io-kit", + "uuid", + "vec_map", + "wasm-bindgen", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" +dependencies = [ + "approx", + "bytemuck", + "encase", + "libm", + "rand 0.9.4", + "serde_core", +] + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "glam" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" +dependencies = [ + "mint", +] + +[[package]] +name = "glam_matrix_extras" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4664468a60479272b880a8bfc00ad2915229b93d2b2d585556fb33f9ba80e72" +dependencies = [ + "bevy_reflect", + "glam 0.30.10", +] + +[[package]] +name = "glamx" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375b4fa374a343fef990a18a6e0413d54bd990c6d7b8c7ada2d3c884275edea3" +dependencies = [ + "approx", + "glam 0.30.10", + "num-traits", + "simba", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "byteorder", + "gltf-json", + "lazy_static", + "serde_json", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "gpu-allocator" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +dependencies = [ + "log", + "presser", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "guillotiere" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "core_maths", + "read-fonts 0.36.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexasphere" +version = "16.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a164ceff4500f2a72b1d21beaa8aa8ad83aec2b641844c659b190cb3ea2e0b" +dependencies = [ + "constgebra", + "glam 0.30.10", + "tinyvec", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kira" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "065632c16b39f4c00bc5514de0698474d1b6c36120743a154c78ca27ffc1ffe1" +dependencies = [ + "atomic-arena", + "cpal 0.17.1", + "glam 0.33.2", + "mint", + "pastey 0.2.3", + "rtrb", + "send_wrapper", + "symphonia", + "triple_buffer", +] + +[[package]] +name = "ktx2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7f53bdf698e7aa7ec916411bbdc8078135da11b66db5182675b2227f6c0d07" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "lewton" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030" +dependencies = [ + "byteorder", + "ogg", + "tinyvec", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libudev-sys" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lua-src" +version = "550.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.6+707c12b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00c15a6f673ff72ddcc22394663290f870fb224c1bfce55734a75c414150e605" +dependencies = [ + "bitflags 2.11.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + +[[package]] +name = "mlua" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" +dependencies = [ + "bstr", + "either", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash 2.1.2", + "rustversion", +] + +[[package]] +name = "mlua-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "pkg-config", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "naga_oil" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310c347db1b30e69581f3b84dc9a5c311ed583f67851b39b77953cb7a066c97f" +dependencies = [ + "codespan-reporting", + "data-encoding", + "indexmap", + "naga", + "regex", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "tracing", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729eb334247daa1803e0a094d0a5c55711b85571179f5ec6e53eccfdf7008958" + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.11.1", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "bitflags 2.11.1", + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni 0.21.1", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "obvhs" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a714277d5f74beb21a12cb8fb1d4774df5c637c2a0278ff3ebd812f42f7c1e" +dependencies = [ + "bytemuck", + "glam 0.31.1", + "half", + "log", + "rdst", + "static_assertions", +] + +[[package]] +name = "offset-allocator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e234d535da3521eb95106f40f0b73483d80bfb3aacf27c40d7e2b72f1a3e00a2" +dependencies = [ + "log", + "nonmax", +] + +[[package]] +name = "ogg" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e" +dependencies = [ + "byteorder", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "orbclient" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a570f6bca41d29acb2139229a7c873ec99bc9a313bd10804081d89bfac8ff329" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parry3d" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8598fd0fa548d469b6ca4f9e90b446434fb879c12494a91e41c341d8f0f558d8" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.11.1", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash 0.2.0", + "glamx", + "hashbrown 0.16.1", + "log", + "num-derive", + "num-traits", + "ordered-float", + "rayon", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "static_assertions", + "thiserror 2.0.18", +] + +[[package]] +name = "parry3d-f64" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f7e4d41f17de279308ca679ae0e30e7eef68e36088dd5a34e7d7923461cf0d" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.11.1", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash 0.2.0", + "glamx", + "hashbrown 0.16.1", + "log", + "num-derive", + "num-traits", + "ordered-float", + "rayon", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 2.0.18", +] + +[[package]] +name = "partition" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947f833aaa585cf12b8ec7c0476c98784c49f33b861376ffc84ed92adebf2aba" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", + "serde_derive", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", + "tracing", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radsort" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdst" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7970b4e577b76a96d5e56b5f6662b66d1a4e1f5bb026ee118fc31b373c2752" +dependencies = [ + "arbitrary-chunks", + "block-pseudorand", + "criterion", + "partition", + "tikv-jemallocator", + "voracious_radix_sort", +] + +[[package]] +name = "read-fonts" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + +[[package]] +name = "rectangle-pack" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d463f2884048e7153449a55166f91028d5b0ea53c79377099ce4e8cf0cf9bb" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rodio" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" +dependencies = [ + "cpal 0.15.3", + "lewton", +] + +[[package]] +name = "ron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +dependencies = [ + "bitflags 2.11.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "smallvec", +] + +[[package]] +name = "rtrb" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ruzstd" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9eb0b904a04d09bd68c65d946617b8ff733009999050f3b851c32fb3cfb60e" +dependencies = [ + "bytemuck", + "read-fonts 0.36.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +dependencies = [ + "skrifa 0.40.0", + "yazi", + "zeno", +] + +[[package]] +name = "symphonia" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039" +dependencies = [ + "lazy_static", + "symphonia-bundle-mp3", + "symphonia-codec-adpcm", + "symphonia-codec-pcm", + "symphonia-core", + "symphonia-format-riff", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-bundle-mp3" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed" +dependencies = [ + "lazy_static", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-codec-adpcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-codec-pcm" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95" +dependencies = [ + "log", + "symphonia-core", +] + +[[package]] +name = "symphonia-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af" +dependencies = [ + "arrayvec", + "bitflags 1.3.2", + "bytemuck", + "lazy_static", + "log", +] + +[[package]] +name = "symphonia-format-riff" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f" +dependencies = [ + "extended", + "log", + "symphonia-core", + "symphonia-metadata", +] + +[[package]] +name = "symphonia-metadata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16" +dependencies = [ + "encoding_rs", + "lazy_static", + "log", + "symphonia-core", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "texpresso" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71" +dependencies = [ + "libm", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tikv-jemalloc-sys" +version = "0.5.4+5.3.0-patched" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9402443cb8fd499b6f327e40565234ff34dbda27460c5b47db0db77443dd85d1" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965fe0c26be5c56c94e38ba547249074803efd52adfb66de62107d95aab3eaca" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-oslog" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950" +dependencies = [ + "cc", + "cfg-if", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tracing-tracy" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eaa1852afa96e0fe9e44caa53dc0bd2d9d05e0f2611ce09f97f8677af56e4ba" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracy-client", +] + +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + +[[package]] +name = "tracy-client" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f6fc3baeac5d86ab90c772e9e30620fc653bf1864295029921a15ef478e6a5" +dependencies = [ + "loom", + "once_cell", + "tracy-client-sys", +] + +[[package]] +name = "tracy-client-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" +dependencies = [ + "cc", + "windows-targets 0.52.6", +] + +[[package]] +name = "triple_buffer" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e875ab7068a75b74f419da453927e05527c36f0001b3c7aad3ce443640683487" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "voracious_radix_sort" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446e7ffcb6c27a71d05af7e51ef2ee5b71c48424b122a832f2439651e1914899" +dependencies = [ + "rayon", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-apple", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0772ae958e9be0c729561d5e3fd9a19679bcdfb945b8b1a1969d9bfe8056d233" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1027dcf3b027a877e44819df7ceb0e2e98578830f8cd34cd6c3c7c2a7a50b7" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.1", + "block", + "bytemuck", + "cfg-if", + "cfg_aliases", + "core-graphics-types 0.2.0", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "metal", + "naga", + "ndk-sys 0.6.0+11769913", + "objc", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "windows 0.58.0", + "windows-core 0.58.0", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "serde", + "thiserror 2.0.18", + "web-sys", +] + +[[package]] +name = "which" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d7cd18d4acb58fb3cdfe9ea54e6cd96a4e7d4cc45c56338b236e82dad47248" +dependencies = [ + "libc", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2 0.5.1", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk 0.9.0", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..66a817ca --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["sam "] + +# Shared pins live here; promote a dep the moment two crates pin the same version (the policy +# since day one, enacted at the 0530 debt sweep). Per-crate feature additions stay in the crate. +# `image` deliberately stays per-crate: its default-features/feature sets differ per consumer, +# and a workspace default-features choice can't be subtracted from downstream. +[workspace.dependencies] +anyhow = "1.0.102" +bevy = "0.18.1" +clap = { version = "4.6.1", features = ["derive"] } + +# No `[patch.crates-io]`: the WoW format + protocol stack is fully in-repo (decision 0021), so there +# are no warcraft-rs / wow_messages crates (or forks of them) anywhere in the tree. + +# Optimize *dependencies* even in dev builds. avian/parry collision math (and wgpu/bevy render) is +# unusably slow at opt-level 0 — a plain `cargo run` crawls to <1 fps once physics is doing real work. +# opt-level 3 on deps (the standard Bevy fix) makes debug builds playable while our own crates stay +# lightly optimized for fast incremental rebuilds. One-time cost: deps recompile optimized the first time. +[profile.dev.package."*"] +opt-level = 3 + +# Our own crates at opt-level 1, not 0: Bevy's generic hot paths (material specialization scans, +# query par_for_each closures) monomorphize INTO our crate, so at opt 0 they run at debug codegen +# speed over every resident entity — measured at Stormwind as the difference between ~45 fps and a +# pinned 60 (decision 0362). Warm incremental rebuild cost measured at ~zero. NB: FPS probe numbers +# in decisions ≤ 0361 were taken at opt 0 and are not comparable with post-0362 probes. +[profile.dev] +opt-level = 1 + +# No debuginfo trimming: `debug = "line-tables-only"` was A/B-measured (decision 0192) at zero +# recurring win on this stack (macOS unpacked split-debuginfo; sccache; fast ld) — not worth +# invalidating every warm target and the sccache cache for. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 00000000..7910980e --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Sam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..c9f1327e --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# benilla + +[![Discord](https://img.shields.io/badge/chat-Discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/8r5kUgu9ts) +[![YouTube](https://img.shields.io/badge/devlog-YouTube-FF0000?logo=youtube)](https://www.youtube.com/playlist?list=PLdCnpZNKxyb8) +[![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) + +A from-scratch **World of Warcraft 1.12.1 client** in **Rust + [Bevy](https://bevy.org)**, speaking +the vanilla protocol to a [vmangos](https://github.com/vmangos/core) server. Every file format and +the network protocol are implemented from scratch — no original client code, no third-party WoW +crates, and no bundled game assets: benilla reads its data at runtime from your own 1.12.1 install. + +## What works + +- **Formats & assets** — in-repo readers for the full asset stack (MPQ patch chain, BLP, DBC, + ADT/WDT/WDL, M2, WMO), wired into Bevy as an asset source, plus extraction CLI tools. +- **World** — streamed terrain with the distant horizon, portal-culled WMOs with interior lighting, + doodads and ground clutter, swimmable liquids, sky and weather, and the client's own day/night + lighting, fog, glow, and gamma passes. +- **Models & animation** — GPU-skinned M2s with the full animation controller (cross-fades, + one-shots, fidgets), a near feature-complete particle system, ribbons, and animated gameobjects + (doors, chests, mailboxes, lifts). +- **Characters** — customization end to end, the armor-region texture composite, held weapons and + sheathing, and mounts. +- **Movement & travel** — a WoW-feel controller (run/strafe/jump/swim/step-up), networked movement + in both directions, a follow-camera with collision, ridden boats and zeppelins, and taxi flights. +- **Networking** — SRP6 auth through world-session crypto, the object-descriptor mirror into the + ECS, and live wire coverage for movement, chat, spells and auras, party, mail, trade, vendors, + bank, trainers, quests, loot with group rolls, and death. +- **UI** — a from-scratch FrameXML + Lua UI engine driving the built-in interface: login and + character screens, the full HUD, the classic windows (bags, spellbook, talents, quests, mail, + merchant, trade, bank, world map, …), chat, and tooltips. +- **Combat** — melee with the faithful swing law, ranged and Auto Shot, casting with GCD, cooldowns + and the cancel ladder, and the spell-visual pipeline (kits, missiles, impacts, ground rings). +- **Audio** — music, ambience, and SFX under the client's own selection and crossfade rules, with + interior/underwater transitions and zone reverb. + +## Not built yet + +Guilds, the auction house, hunter/warlock pets, duels and inspect, the social panel +(friends/who), PvP systems, the macros/options/key-binding screens, and third-party addon +loading — the Lua/FrameXML engine currently runs only the built-in UI. + +## Running it + +You need a **1.12.1 (build 5875) client install** for game data, a +[vmangos](https://github.com/vmangos/core) server to connect to, and stable Rust. + +```sh +WOW_DATA=/path/to/WoW/Data cargo run --release -p benilla +``` + +The server defaults to `localhost` — point `WOW_HOST` at any IP or hostname, with an optional +auth port: `WOW_HOST=play.example.com:3724`. A bare host uses port 13724 (this project's own dev +deploy); a stock vmangos `realmd` listens on 3724, so you'll usually want the explicit port. +Credentials go in at the login screen, or set `WOW_USER` / `WOW_PASS` to skip it. + +## Contributing + +benilla is a solo project and isn't taking pull requests for now. Bug reports and questions are +welcome — open an issue, or bring them to Discord. Comments throughout the code cite numbered +decision records from a private development log; the citations won't resolve here, but the +reasoning they carry is kept in the comments themselves. + +## Acknowledgements + +The [wowemulation-dev](https://github.com/wowemulation-dev) community — and +[warcraft-rs](https://github.com/wowemulation-dev/warcraft-rs) in particular — was an early +inspiration and a source of guidance on the client's file formats. benilla's readers ended up +written from scratch, but their preservation work made starting far less daunting. And none of +this runs without [vmangos](https://github.com/vmangos/core) on the other end of the socket. + +## Legal + +benilla is an independent, fan-made project, not affiliated with or endorsed by Blizzard +Entertainment. It contains no Blizzard code or assets and never will; you must provide your own +legally-obtained 1.12.1 client for game data. World of Warcraft is a trademark of Blizzard +Entertainment, Inc. + +## License + +Licensed under either of the [Apache License 2.0](LICENSE-APACHE) or the [MIT +license](LICENSE-MIT), at your option. diff --git a/crates/benilla-adt/Cargo.toml b/crates/benilla-adt/Cargo.toml new file mode 100644 index 00000000..698998c3 --- /dev/null +++ b/crates/benilla-adt/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "benilla-adt" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "ADT terrain-tile reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021" + +[dependencies] +benilla-bytes = { path = "../benilla-bytes" } diff --git a/crates/benilla-adt/src/combined_alpha.rs b/crates/benilla-adt/src/combined_alpha.rs new file mode 100644 index 00000000..b2c51a9c --- /dev/null +++ b/crates/benilla-adt/src/combined_alpha.rs @@ -0,0 +1,173 @@ +//! MCAL → one 64×64 RGBA alpha map (R/G/B = the opacity of texture layers 1/2/3; A = 255). Ported +//! from wow-adt's `CombinedAlphaMap` so the output is byte-identical: layers are ingested in MCLY +//! order (skipping the opaque base), each decoded into the next channel; encodings are 4-bit packed +//! (×16), 8-bit raw, or Blizzard RLE (token bit 7 = fill, else copy; low 7 bits = count). `fix_alpha` +//! reconstructs a 64×64 plane from 63×63 source by duplicating the previous pixel at the last row/col. + +use crate::McnkChunk; + +/// Assembles the per-layer MCAL alpha maps into one 64×64 RGBA buffer (`y`-major, then `x`, then +/// channel R,G,B,A). +pub struct CombinedAlphaMap { + map: Vec, // 64*64*4, [y][x][rgba] + x: usize, + y: usize, + layer: usize, + has_big_alpha: bool, + fix_alpha: bool, +} + +const W: usize = 64; + +impl CombinedAlphaMap { + /// Construct and ingest `chunk`'s alpha layers. `has_big_alpha`/`fix_alpha` select the uncompressed + /// encoding width and the 63→64 edge fix (vanilla: `false`, `true`). + pub fn new(chunk: &McnkChunk, has_big_alpha: bool, fix_alpha: bool) -> Self { + let mut map = vec![0u8; W * W * 4]; + for px in map.chunks_exact_mut(4) { + px[3] = 255; // A unused, set opaque for tool visibility (matches wow-adt) + } + let mut s = Self { + map, + x: 0, + y: 0, + layer: 0, + has_big_alpha, + fix_alpha, + }; + s.ingest_chunk_layers(chunk); + s + } + + /// The combined RGBA bytes (64×64×4). + pub fn as_slice(&self) -> &[u8] { + &self.map + } + + fn ingest_chunk_layers(&mut self, chunk: &McnkChunk) { + let (Some(mcly), Some(mcal)) = (&chunk.layers, &chunk.alpha) else { + return; + }; + for layer in mcly.layers.iter().skip(1) { + let offset = layer.offset_in_mcal as usize; + if layer.flags.alpha_map_compressed() { + self.ingest_compressed(&mcal.data, offset); + } else if self.has_big_alpha { + self.ingest_big(&mcal.data, offset); + } else { + self.ingest_small(&mcal.data, offset); + } + } + } + + fn ingest_big(&mut self, raw: &[u8], offset: usize) { + const LAYER: usize = W * W; + if offset + LAYER <= raw.len() { + for &a in &raw[offset..offset + LAYER] { + if !self.set_next(a) { + break; + } + } + } + self.next_layer(); + } + + fn ingest_small(&mut self, raw: &[u8], offset: usize) { + const PACKED: usize = W * W / 2; + if offset + PACKED <= raw.len() { + for &p in &raw[offset..offset + PACKED] { + if !self.set_next((p & 0x0F) * 16) { + break; + } + if !self.set_next(((p >> 4) & 0x0F) * 16) { + break; + } + } + } + self.next_layer(); + } + + fn ingest_compressed(&mut self, raw: &[u8], mut offset: usize) { + const TARGET: usize = W * W; + let mut out = Vec::with_capacity(TARGET); + while out.len() < TARGET { + if offset >= raw.len() { + break; + } + let token = raw[offset]; + offset += 1; + let count = (token & 0x7F) as usize; + if count == 0 { + continue; + } + if token & 0x80 != 0 { + // fill + if offset >= raw.len() { + break; + } + let v = raw[offset]; + offset += 1; + for _ in 0..count { + if out.len() >= TARGET { + break; + } + out.push(v); + } + } else { + // copy + for _ in 0..count { + if offset >= raw.len() || out.len() >= TARGET { + break; + } + out.push(raw[offset]); + offset += 1; + } + } + } + out.resize(TARGET, 0); + for &a in &out { + if !self.set_next(a) { + break; + } + } + self.next_layer(); + } + + fn get(&self, x: usize, y: usize, layer: usize) -> u8 { + if x < W && y < W && layer < 4 { + self.map[(y * W + x) * 4 + layer] + } else { + 0 + } + } + + fn set(&mut self, x: usize, y: usize, layer: usize, a: u8) { + if x < W && y < W && layer < 4 { + self.map[(y * W + x) * 4 + layer] = a; + } + } + + fn set_next(&mut self, mut a: u8) -> bool { + if self.fix_alpha { + if self.x == 63 { + a = self.get(self.x - 1, self.y, self.layer); + } + if self.y == 63 { + a = self.get(self.x, self.y - 1, self.layer); + } + } + self.set(self.x, self.y, self.layer, a); + self.x += 1; + if self.x >= W { + self.x = 0; + self.y += 1; + } + self.y < W + } + + fn next_layer(&mut self) { + self.layer += 1; + self.x = 0; + self.y = 0; + } +} diff --git a/crates/benilla-adt/src/lib.rs b/crates/benilla-adt/src/lib.rs new file mode 100644 index 00000000..afdcc663 --- /dev/null +++ b/crates/benilla-adt/src/lib.rs @@ -0,0 +1,668 @@ +//! An ADT terrain-tile reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-adt` +//! (decision 0021). +//! +//! Vanilla ADTs are monolithic, chunked (`IFF`-style: a 4-char magic — stored **reversed** on disk — +//! plus a `u32` size, then payload). The root file holds `MTEX` (textures), `MMDX`/`MMID` (M2 paths), +//! `MWMO`/`MWID` (WMO paths), `MDDF`/`MODF` (placements), and 256 `MCNK` terrain chunks. Each `MCNK` is +//! a 128-byte header followed by sub-chunks (`MCVT` heights, `MCNR` normals, `MCLY` layers, `MCAL` +//! alpha, `MCSH` shadow, `MCLQ` liquid), which we locate by **scanning** the chunk payload for their +//! magics (robust, and identical to the header-offset data for vanilla). +//! +//! The shape mirrors what the renderer's terrain/liquid mesher consumes; quirks the consumer +//! compensates for (the MCNR `[x,z,y]` byte order, the split predominant-texture/noEffectDoodad +//! grids) are preserved deliberately. Proven against `wow-adt` over real ADTs during the decision-0021 +//! migration (oracle test in git history); the `benilla-formats` terrain golden tests pin the meshing +//! end-to-end on every run. +//! +//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a +//! truncated chunk/record is a typed [`Error::Truncated`], never a panic. + +use std::io::Cursor; + +use benilla_bytes::{chunks, ByteExt}; + +/// Liquid surface type, from the MCNK flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LiquidType { + Water, + Ocean, + Magma, + Slime, +} + +impl LiquidType { + /// Match wow-adt: ocean (0x08) → magma (0x10) → slime (0x20) → water (the default, incl. 0x04 + /// river). + fn from_mcnk_flags(flags: u32) -> Self { + if flags & 0x08 != 0 { + Self::Ocean + } else if flags & 0x10 != 0 { + Self::Magma + } else if flags & 0x20 != 0 { + Self::Slime + } else { + Self::Water + } + } +} + +/// A terrain vertex normal — stored on disk as `x, z, y` signed bytes. +#[derive(Debug, Clone, Copy)] +pub struct VertexNormal { + pub x: i8, + pub z: i8, + pub y: i8, +} + +impl VertexNormal { + /// `[x/127, y/127, z/127]` — i.e. on-disk `[b0, b2, b1]` / 127 (kept bit-identical to wow-adt; the + /// consumer cancels the z/y swap). + pub fn to_normalized(&self) -> [f32; 3] { + [ + f32::from(self.x) / 127.0, + f32::from(self.y) / 127.0, + f32::from(self.z) / 127.0, + ] + } +} + +/// MCVT — 145 heights (9×9 outer + 8×8 inner, stride-17 interleave). +#[derive(Debug, Clone)] +pub struct McvtChunk { + pub heights: Vec, +} + +/// MCNR — one normal per MCVT vertex. +#[derive(Debug, Clone)] +pub struct McnrChunk { + pub normals: Vec, +} + +/// MCLY per-layer flags (we only need the alpha-compression bit). +#[derive(Debug, Clone, Copy)] +pub struct MclyFlags { + pub value: u32, +} + +impl MclyFlags { + /// Bit 9 (0x200): the layer's MCAL alpha map is RLE-compressed. + pub fn alpha_map_compressed(&self) -> bool { + self.value & 0x200 != 0 + } +} + +/// MCLY — one texture layer (16 bytes on disk). +#[derive(Debug, Clone)] +pub struct MclyLayer { + pub texture_id: u32, + pub flags: MclyFlags, + pub offset_in_mcal: u32, + pub effect_id: u32, +} + +/// MCLY — the chunk's texture layers (0–4; layer 0 is the opaque base). +#[derive(Debug, Clone)] +pub struct MclyChunk { + pub layers: Vec, +} + +/// MCAL — raw alpha-map bytes (indexed per layer via [`MclyLayer::offset_in_mcal`]). +#[derive(Debug, Clone)] +pub struct McalChunk { + pub data: Vec, +} + +/// MCSH — a 64×64 1-bit baked shadow map (512 bytes). +#[derive(Debug, Clone)] +pub struct McshChunk { + pub shadow_map: Vec, +} + +impl McshChunk { + /// Whether texel `(x, y)` (column, row; both 0..63) is shadowed. + pub fn is_shadowed(&self, x: usize, y: usize) -> bool { + if x >= 64 || y >= 64 { + return false; + } + let byte = self.shadow_map.get(y * 8 + x / 8).copied().unwrap_or(0); + (byte >> (x % 8)) & 1 != 0 + } +} + +/// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height). +#[derive(Debug, Clone, Copy)] +pub struct LiquidVertex { + pub union_data: [u8; 4], + pub height: f32, +} + +impl LiquidVertex { + /// The depth byte (union byte 0) — drives the water depth-swatch on the render side. + pub fn depth_byte(&self) -> u8 { + self.union_data[0] + } +} + +/// MCLQ — legacy per-MCNK liquid (9×9 height grid + 8×8 cell flags). +#[derive(Debug, Clone)] +pub struct MclqChunk { + pub min_height: f32, + pub max_height: f32, + pub vertices: Vec, + pub tile_flags: [u8; 64], + pub liquid_type: LiquidType, +} + +/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/ +/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region, +/// which the consumer reconstructs — kept identical on purpose. +#[derive(Debug, Clone)] +pub struct McnkHeader { + pub flags: u32, + pub index_x: u32, + pub index_y: u32, + /// `AreaTable.dbc` id of this chunk (header +0x34) — the zone/subzone the player is standing + /// in; drives zone music/ambience/reverb selection (decision 0070). + pub area_id: u32, + pub holes_low_res: u16, + pub pred_tex: [u8; 8], + pub no_effect_doodad: [u8; 8], + pub unknown_8bytes: [u8; 8], + pub position: [f32; 3], +} + +/// One MCNK terrain chunk. +#[derive(Debug, Clone)] +pub struct McnkChunk { + pub header: McnkHeader, + pub heights: Option, + pub normals: Option, + pub layers: Option, + pub alpha: Option, + pub shadow: Option, + pub liquid: Option, +} + +/// MDDF — a placed M2 doodad (36 bytes). +#[derive(Debug, Clone)] +pub struct DoodadPlacement { + pub name_id: u32, + pub unique_id: u32, + pub position: [f32; 3], + pub rotation: [f32; 3], + pub scale: u16, + pub flags: u16, +} + +/// MODF — a placed WMO (64 bytes). +#[derive(Debug, Clone)] +pub struct WmoPlacement { + pub name_id: u32, + pub unique_id: u32, + pub position: [f32; 3], + pub rotation: [f32; 3], + pub flags: u16, + pub doodad_set: u16, + /// `WMOAreaTable.NameSetID` selector — which naming/audio variant of the building this + /// placement is (Northshire Abbey vs Tyr's Hand Abbey are one WMO, different name sets). + pub name_set: u16, +} + +/// A parsed vanilla (monolithic) root ADT. +#[derive(Debug, Clone)] +pub struct RootAdt { + pub textures: Vec, + pub models: Vec, + pub wmos: Vec, + pub doodad_placements: Vec, + pub wmo_placements: Vec, + pub mcnk_chunks: Vec, +} + +/// Either a vanilla monolithic root ADT (all we produce) — kept as an enum to mirror the consumer's +/// `ParsedAdt::Root(..)` match. +#[derive(Debug, Clone)] +pub enum ParsedAdt { + Root(Box), +} + +/// ADT parse error. +#[derive(Debug)] +pub enum Error { + Truncated(&'static str), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Truncated(w) => write!(f, "truncated ADT: {w}"), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// Split a NUL-separated string blob into a `Vec` of start offsets paired with strings (used to +/// resolve MMDX/MWMO via MMID/MWID offsets). +fn cstring_at(blob: &[u8], offset: usize) -> String { + let end = blob[offset.min(blob.len())..] + .iter() + .position(|&c| c == 0) + .map(|p| offset + p) + .unwrap_or(blob.len()); + String::from_utf8_lossy(&blob[offset.min(blob.len())..end]).into_owned() +} + +/// Parse a vanilla monolithic ADT. (`cursor` position is ignored; the file starts at the first chunk.) +pub fn parse_adt(cursor: &mut Cursor<&[u8]>) -> Result { + let b: &[u8] = cursor.get_ref(); + + let mut textures = Vec::new(); + let mut mmdx = Vec::new(); + let mut mmid: Vec = Vec::new(); + let mut mwmo = Vec::new(); + let mut mwid: Vec = Vec::new(); + let mut doodad_placements = Vec::new(); + let mut wmo_placements = Vec::new(); + let mut mcnk_chunks = Vec::new(); + + for (magic, data) in chunks(b) { + match &magic { + b"XETM" => textures = split_strings(data), // MTEX + b"XDMM" => mmdx = data.to_vec(), // MMDX (blob) + b"DIMM" => mmid = read_u32_list(data, "MMID")?, // MMID (offsets) + b"OMWM" => mwmo = data.to_vec(), // MWMO (blob) + b"DIWM" => mwid = read_u32_list(data, "MWID")?, // MWID (offsets) + b"FDDM" => doodad_placements = read_mddf(data)?, // MDDF + b"FDOM" => wmo_placements = read_modf(data)?, // MODF + b"KNCM" => mcnk_chunks.push(read_mcnk(data)?), // MCNK + _ => {} + } + } + + // Resolve model/WMO paths via the offset tables (the order MDDF/MODF `name_id` indexes). + let models = mmid + .iter() + .map(|&o| cstring_at(&mmdx, o as usize)) + .collect(); + let wmos = mwid + .iter() + .map(|&o| cstring_at(&mwmo, o as usize)) + .collect(); + + Ok(ParsedAdt::Root(Box::new(RootAdt { + textures, + models, + wmos, + doodad_placements, + wmo_placements, + mcnk_chunks, + }))) +} + +/// Split a NUL-separated, NUL-terminated string blob, dropping empties. +fn split_strings(blob: &[u8]) -> Vec { + blob.split(|&c| c == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect() +} + +/// `name` tags the truncation error with the chunk this record list came from (MMID/MWID). +fn read_u32_list(data: &[u8], name: &'static str) -> Result> { + data.chunks_exact(4) + .map(|c| c.u32_at(0).ok_or(Error::Truncated(name))) + .collect() +} + +fn read_mddf(data: &[u8]) -> Result> { + let mut out = Vec::new(); + for r in data.chunks_exact(36) { + out.push(DoodadPlacement { + name_id: r.u32_at(0).ok_or(Error::Truncated("MDDF"))?, + unique_id: r.u32_at(4).ok_or(Error::Truncated("MDDF"))?, + position: [ + r.f32_at(8).ok_or(Error::Truncated("MDDF"))?, + r.f32_at(12).ok_or(Error::Truncated("MDDF"))?, + r.f32_at(16).ok_or(Error::Truncated("MDDF"))?, + ], + rotation: [ + r.f32_at(20).ok_or(Error::Truncated("MDDF"))?, + r.f32_at(24).ok_or(Error::Truncated("MDDF"))?, + r.f32_at(28).ok_or(Error::Truncated("MDDF"))?, + ], + scale: r.u16_at(32).ok_or(Error::Truncated("MDDF"))?, + flags: r.u16_at(34).ok_or(Error::Truncated("MDDF"))?, + }); + } + Ok(out) +} + +fn read_modf(data: &[u8]) -> Result> { + let mut out = Vec::new(); + for r in data.chunks_exact(64) { + out.push(WmoPlacement { + name_id: r.u32_at(0).ok_or(Error::Truncated("MODF"))?, + unique_id: r.u32_at(4).ok_or(Error::Truncated("MODF"))?, + position: [ + r.f32_at(8).ok_or(Error::Truncated("MODF"))?, + r.f32_at(12).ok_or(Error::Truncated("MODF"))?, + r.f32_at(16).ok_or(Error::Truncated("MODF"))?, + ], + rotation: [ + r.f32_at(20).ok_or(Error::Truncated("MODF"))?, + r.f32_at(24).ok_or(Error::Truncated("MODF"))?, + r.f32_at(28).ok_or(Error::Truncated("MODF"))?, + ], + // 0x20..0x38 = bounding box (6 f32); then: + flags: r.u16_at(56).ok_or(Error::Truncated("MODF"))?, + doodad_set: r.u16_at(58).ok_or(Error::Truncated("MODF"))?, + name_set: r.u16_at(60).ok_or(Error::Truncated("MODF"))?, + // 0x3E scale u16 — unused. + }); + } + Ok(out) +} + +/// Parse one MCNK: a 128-byte header, then sub-chunks located by scanning the payload for their +/// (reversed) magics. +fn read_mcnk(data: &[u8]) -> Result { + if data.len() < 128 { + return Err(Error::Truncated("MCNK header")); + } + let h = &data[..128]; + let header = McnkHeader { + flags: h.u32_at(0x00).ok_or(Error::Truncated("MCNK header"))?, + index_x: h.u32_at(0x04).ok_or(Error::Truncated("MCNK header"))?, + index_y: h.u32_at(0x08).ok_or(Error::Truncated("MCNK header"))?, + area_id: h.u32_at(0x34).ok_or(Error::Truncated("MCNK header"))?, + holes_low_res: h.u16_at(0x3C).ok_or(Error::Truncated("MCNK header"))?, + pred_tex: h + .bytes_at(0x40, 8) + .ok_or(Error::Truncated("MCNK header"))? + .try_into() + .unwrap(), + no_effect_doodad: h + .bytes_at(0x48, 8) + .ok_or(Error::Truncated("MCNK header"))? + .try_into() + .unwrap(), + unknown_8bytes: h + .bytes_at(0x50, 8) + .ok_or(Error::Truncated("MCNK header"))? + .try_into() + .unwrap(), + position: [ + h.f32_at(0x68).ok_or(Error::Truncated("MCNK header"))?, + h.f32_at(0x6C).ok_or(Error::Truncated("MCNK header"))?, + h.f32_at(0x70).ok_or(Error::Truncated("MCNK header"))?, + ], + }; + + let mut chunk = McnkChunk { + header, + heights: None, + normals: None, + layers: None, + alpha: None, + shadow: None, + liquid: None, + }; + + // Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning + // breaks when sub-chunks aren't contiguous, e.g. AhnQiraj). MCVT/MCNR offsets live in the + // `multipurpose_field` (0x14/0x18); the rest are explicit header fields. The offset base differs + // across sources (relative to the MCNK magic vs the MCNK data), so we auto-detect per sub-chunk by + // validating the (reversed) magic at both candidate positions. Returns the payload `(start, size)`. + // `ofs` comes straight off a (possibly corrupt) file's header, so both the "minus 8" candidate + // and the 8-byte header read use checked/bounds-checked arithmetic (`checked_sub`/`bytes_at`/ + // `u32_at`) — a wrapped or out-of-range offset yields `None`, never a panic. + let locate = |ofs: u32, magic: &[u8; 4]| -> Option<(usize, usize)> { + if ofs == 0 { + return None; + } + let ofs = ofs as usize; + for hdr in [Some(ofs), ofs.checked_sub(8)].into_iter().flatten() { + if data.bytes_at(hdr, 4) == Some(magic.as_slice()) { + let size = data.u32_at(hdr + 4)?; + return Some((hdr + 8, size as usize)); + } + } + None + }; + // `start` always comes from a `locate` hit (bounds-proven above); `len` is attacker-controlled, + // so the end is saturating before clamping to the buffer. + let slice = |start: usize, len: usize| &data[start..start.saturating_add(len).min(data.len())]; + + if let Some((p, n)) = locate( + h.u32_at(0x14).ok_or(Error::Truncated("MCNK header"))?, + b"TVCM", + ) + .filter(|&(_, n)| n > 0) + { + let sub = slice(p, n); + let mut heights = Vec::new(); + for i in 0..145.min(sub.len() / 4) { + heights.push(sub.f32_at(i * 4).ok_or(Error::Truncated("MCVT"))?); + } + chunk.heights = Some(McvtChunk { heights }); + } + if let Some((p, n)) = locate( + h.u32_at(0x18).ok_or(Error::Truncated("MCNK header"))?, + b"RNCM", + ) + .filter(|&(_, n)| n > 0) + { + let sub = slice(p, n); + let mut normals = Vec::new(); + for i in 0..145.min(sub.len() / 3) { + normals.push(VertexNormal { + x: sub.u8_at(i * 3).ok_or(Error::Truncated("MCNR"))? as i8, + z: sub.u8_at(i * 3 + 1).ok_or(Error::Truncated("MCNR"))? as i8, + y: sub.u8_at(i * 3 + 2).ok_or(Error::Truncated("MCNR"))? as i8, + }); + } + chunk.normals = Some(McnrChunk { normals }); + } + if let Some((p, n)) = locate( + h.u32_at(0x1C).ok_or(Error::Truncated("MCNK header"))?, + b"YLCM", + ) + .filter(|&(_, n)| n > 0) + { + let mut layers = Vec::new(); + for l in slice(p, n).chunks_exact(16) { + layers.push(MclyLayer { + texture_id: l.u32_at(0).ok_or(Error::Truncated("MCLY"))?, + flags: MclyFlags { + value: l.u32_at(4).ok_or(Error::Truncated("MCLY"))?, + }, + offset_in_mcal: l.u32_at(8).ok_or(Error::Truncated("MCLY"))?, + effect_id: l.u32_at(12).ok_or(Error::Truncated("MCLY"))?, + }); + } + chunk.layers = Some(MclyChunk { layers }); + } + // MCAL's data length is the header's `size_alpha`, NOT the sub-chunk's declared size (they differ + // on some files, e.g. AhnQiraj — wow-adt reads `size_alpha`). + if let Some((p, _)) = locate( + h.u32_at(0x24).ok_or(Error::Truncated("MCNK header"))?, + b"LACM", + ) { + let size_alpha = h.u32_at(0x28).ok_or(Error::Truncated("MCNK header"))? as usize; + chunk.alpha = Some(McalChunk { + data: slice(p, size_alpha).to_vec(), + }); + } + if let Some((p, n)) = locate( + h.u32_at(0x2C).ok_or(Error::Truncated("MCNK header"))?, + b"HSCM", + ) + .filter(|&(_, n)| n > 0) + { + let sub = slice(p, n); + let mut shadow_map = vec![0u8; 512]; + let k = sub.len().min(512); + shadow_map[..k].copy_from_slice(&sub[..k]); + chunk.shadow = Some(McshChunk { shadow_map }); + } + // MCLQ, like MCAL, takes its length from the MCNK header (`size_liquid`): the MCLQ sub-chunk's + // own declared size is always 0. + if let Some((p, _)) = locate( + h.u32_at(0x60).ok_or(Error::Truncated("MCNK header"))?, + b"QLCM", + ) { + let size_liquid = h.u32_at(0x64).ok_or(Error::Truncated("MCNK header"))? as usize; + chunk.liquid = read_mclq(slice(p, size_liquid), chunk.header.flags); + } + + Ok(chunk) +} + +/// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid", +/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. +fn read_mclq(sub: &[u8], mcnk_flags: u32) -> Option { + const NEED: usize = 8 + 81 * 8 + 64; + if sub.len() < NEED { + return None; + } + let min_height = sub.f32_at(0)?; + let max_height = sub.f32_at(4)?; + let mut vertices = Vec::with_capacity(81); + for i in 0..81 { + let o = 8 + i * 8; + vertices.push(LiquidVertex { + union_data: sub.bytes_at(o, 4)?.try_into().ok()?, + height: sub.f32_at(o + 4)?, + }); + } + let tile_flags = sub.bytes_at(8 + 81 * 8, 64)?.try_into().ok()?; + Some(MclqChunk { + min_height, + max_height, + vertices, + tile_flags, + liquid_type: LiquidType::from_mcnk_flags(mcnk_flags), + }) +} + +mod combined_alpha; +pub use combined_alpha::CombinedAlphaMap; + +#[cfg(test)] +mod tests { + use super::*; + + /// Build one IFF chunk: reversed magic + `u32` size + payload. + fn chunk(magic: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(magic); + v.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + v.extend_from_slice(payload); + v + } + + /// A 128-byte MCNK header with a single MCVT (145 heights) sub-chunk placed right after it, + /// referenced via the "offset relative to the MCNK magic" convention (multipurpose field @0x14 + /// pointing at the MCVT chunk's own magic, 128 bytes in). + fn mcnk_with_mcvt() -> Vec { + let mut data = vec![0u8; 128]; + data[0x14..0x18].copy_from_slice(&128u32.to_le_bytes()); + let heights: Vec = (0..145u32).flat_map(|i| (i as f32).to_le_bytes()).collect(); + data.extend(chunk(b"TVCM", &heights)); + data + } + + fn parse(bytes: &[u8]) -> Result { + parse_adt(&mut Cursor::new(bytes)) + } + + #[test] + fn root_parses_a_minimal_well_formed_chunk_walk() { + let mtex = b"tex1.blp\0tex2.blp\0"; + let mmdx = b"model.m2\0"; + let mmid = 0u32.to_le_bytes(); + let mwmo = b"world.wmo\0"; + let mwid = 0u32.to_le_bytes(); + + let mut mddf = vec![0u8; 36]; + mddf[32..34].copy_from_slice(&7u16.to_le_bytes()); // scale + + let mut modf = vec![0u8; 64]; + modf[58..60].copy_from_slice(&3u16.to_le_bytes()); // doodad_set + + let mut b = chunk(b"XETM", mtex); + b.extend(chunk(b"XDMM", mmdx)); + b.extend(chunk(b"DIMM", &mmid)); + b.extend(chunk(b"OMWM", mwmo)); + b.extend(chunk(b"DIWM", &mwid)); + b.extend(chunk(b"FDDM", &mddf)); + b.extend(chunk(b"FDOM", &modf)); + b.extend(chunk(b"KNCM", &mcnk_with_mcvt())); + + let ParsedAdt::Root(root) = parse(&b).expect("parses"); + assert_eq!(root.textures, vec!["tex1.blp", "tex2.blp"]); + assert_eq!(root.models, vec!["model.m2"]); + assert_eq!(root.wmos, vec!["world.wmo"]); + assert_eq!(root.doodad_placements.len(), 1); + assert_eq!(root.doodad_placements[0].scale, 7); + assert_eq!(root.wmo_placements.len(), 1); + assert_eq!(root.wmo_placements[0].doodad_set, 3); + assert_eq!(root.mcnk_chunks.len(), 1); + let heights = &root.mcnk_chunks[0] + .heights + .as_ref() + .expect("MCVT parsed") + .heights; + assert_eq!(heights.len(), 145); + assert_eq!(heights[1], 1.0); + } + + #[test] + fn partial_trailing_mddf_record_is_dropped_not_a_panic() { + // A record shorter than the 36-byte MDDF stride: `chunks_exact` drops the short remainder + // (a pre-existing, unchanged leniency — every *complete* 36-byte record is still + // bounds-checked via `ByteExt`, so this can never panic or misread). + let b = chunk(b"FDDM", &[0u8; 10]); + let ParsedAdt::Root(root) = parse(&b).expect("parses"); + assert!(root.doodad_placements.is_empty()); + } + + #[test] + fn truncated_mcnk_header_is_an_error_not_a_panic() { + let b = chunk(b"KNCM", &[0u8; 40]); + assert!(matches!(parse(&b), Err(Error::Truncated("MCNK header")))); + } + + #[test] + fn corrupt_mcnk_subchunk_offset_skips_cleanly_never_panics() { + // ofs = 3: `checked_sub(8)` on the second candidate is `None`, and the first candidate (3) + // doesn't land on a valid magic — must yield `None`, not wrap/panic (the verified + // `ofs.wrapping_sub(8)` trap this replaces). + let mut data = vec![0u8; 128]; + data[0x14..0x18].copy_from_slice(&3u32.to_le_bytes()); + let b = chunk(b"KNCM", &data); + let ParsedAdt::Root(root) = parse(&b).expect("parses despite corrupt MCVT offset"); + assert!(root.mcnk_chunks[0].heights.is_none()); + + // ofs points past the end of the buffer entirely. + let mut data = vec![0u8; 128]; + data[0x14..0x18].copy_from_slice(&u32::MAX.to_le_bytes()); + let b = chunk(b"KNCM", &data); + let ParsedAdt::Root(root) = parse(&b).expect("parses despite out-of-range MCVT offset"); + assert!(root.mcnk_chunks[0].heights.is_none()); + } + + #[test] + fn short_mclq_is_treated_as_no_liquid_not_an_error() { + let mut data = vec![0u8; 128]; + data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); // MCLQ offset + data[0x64..0x68].copy_from_slice(&4u32.to_le_bytes()); // size_liquid — far short of NEED + data.extend(chunk(b"QLCM", &[0u8; 4])); + let b = chunk(b"KNCM", &data); + let ParsedAdt::Root(root) = parse(&b).expect("parses"); + assert!(root.mcnk_chunks[0].liquid.is_none()); + } +} diff --git a/crates/benilla-assets/Cargo.toml b/crates/benilla-assets/Cargo.toml new file mode 100644 index 00000000..acc15c4d --- /dev/null +++ b/crates/benilla-assets/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "benilla-assets" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Bevy AssetSource + AssetLoaders over the WoW 1.12.1 MPQ patch chain" + +[dependencies] +anyhow.workspace = true +bevy.workspace = true +benilla-formats = { path = "../benilla-formats" } +# Nearest-neighbour resize for off-Elwynn layer BLPs authored below LAYER_TEX_SIZE (texel +# replication in gamma byte space — no averaging, so the C2 mid-tone darkening can't creep in). +image = { version = "0.25", default-features = false } +serde = { version = "1", features = ["derive"] } diff --git a/crates/benilla-assets/src/adt.rs b/crates/benilla-assets/src/adt.rs new file mode 100644 index 00000000..cc0c19c5 --- /dev/null +++ b/crates/benilla-assets/src/adt.rs @@ -0,0 +1,252 @@ +//! ADT → terrain-tile asset loader. +//! +//! Decodes one vanilla ADT tile into an [`AdtTile`]: the **merged** splat-blended terrain `Mesh` +//! (all 256 MCNK chunks in one mesh, Bevy space) plus its three `texture_2d_array`s — ground layers, +//! per-chunk alpha (blend) maps, per-chunk MCSH shadow maps — and the raw doodad/WMO placement lists +//! (for the spawn system to load as `M2Model`/`WmoModel`). Per-chunk array indices ride the merged +//! mesh's vertex `COLOR` (4 layer indices) and `UV1` (alpha index `.x`, shadow index `.y`, `-1` = +//! none); the app's terrain material reads them. +//! +//! Layer textures are read via [`LoadContext::read_asset_bytes`] (raw bytes, dependency-tracked) and +//! packed by [`crate::terrain`] — the BLP's authored mips verbatim (the C2 fidelity invariant). +//! Materials, the collider, and the placement *models* are built by the app at spawn — this loader +//! produces decoded data only. + +use std::collections::HashMap; + +use benilla_formats::{ + adt_to_tile_mesh, blp_bytes_to_mip_chain, ChunkMesh, Doodad, WmoInstance, ALPHA_MAP_SIZE, + SHADOW_MAP_SIZE, +}; +use bevy::asset::io::Reader; +use bevy::asset::{Asset, AssetLoader, LoadContext, RenderAssetUsages}; +use bevy::mesh::{Indices, PrimitiveTopology}; +use bevy::prelude::*; +use bevy::reflect::TypePath; + +use crate::coords::wow_to_bevy; +use crate::terrain::{ + alpha_array_image, chain_to_layer, layer_array_image, shadow_array_image, solid_layer_chain, + LayerTexture, LAYER_TEX_SIZE, +}; + +/// A loaded ADT terrain tile: the merged splat mesh + its three texture arrays, plus the raw +/// doodad/WMO placements (which the spawn system resolves to `M2Model`/`WmoModel` handles). +#[derive(Asset, TypePath)] +pub struct AdtTile { + /// Merged terrain mesh (Bevy space). Per-chunk layer indices ride vertex `COLOR`; the alpha and + /// shadow array indices ride `UV1.x` / `UV1.y`. + pub mesh: Handle, + /// `texture_2d_array` of the tile's ground textures (the C2-faithful authored mip chains). + pub layer_array: Handle, + /// `texture_2d_array` of per-chunk alpha (blend-weight) maps. + pub alpha_array: Handle, + /// `texture_2d_array` of per-chunk MCSH baked shadow maps. + pub shadow_array: Handle, + /// M2 doodad placements (raw WoW coords). + pub doodads: Vec, + /// WMO building placements (raw WoW coords). + pub wmos: Vec, + /// The tile's decoded per-MCNK chunks — the source the merged `mesh` + arrays were built from, + /// kept resident so the app can derive what those forms drop: the MCLQ liquid surfaces (each + /// `ChunkMesh.liquid`) and the ground-clutter scatter (per-chunk layers/normals/MCSH + the + /// app-side ground-effect catalog). Bounded by the loaded-tile count; the heaviest field on the + /// asset, carried because clutter/liquid are app concerns (the loader stays catalog-independent). + pub chunks: Vec, +} + +/// Bevy [`AssetLoader`] decoding a vanilla `*.adt` tile → [`AdtTile`]. +#[derive(Default, TypePath)] +pub struct AdtLoader; + +impl AssetLoader for AdtLoader { + type Asset = AdtTile; + type Settings = (); + type Error = std::io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + ctx: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); + let tile = adt_to_tile_mesh(&bytes).map_err(to_io)?; + + // Layer array: a solid-green fallback at index 0, then each unique referenced layer texture. + let fallback = solid_layer_chain([107, 133, 82, 0]); + let layer_mips = fallback.mip_count; + let mut layer_buf = fallback.chain; + let mut layer_count = 1u32; + let mut layer_index: HashMap = HashMap::new(); + + let mut alpha_buf: Vec = Vec::new(); + let mut alpha_count = 0u32; + let mut shadow_buf: Vec = Vec::new(); + let mut shadow_count = 0u32; + + // Merged-mesh accumulators: COLOR carries each vertex's 4 layer indices, UV1 the alpha/shadow. + let mut m_pos: Vec<[f32; 3]> = Vec::new(); + let mut m_norm: Vec<[f32; 3]> = Vec::new(); + let mut m_uv: Vec<[f32; 2]> = Vec::new(); + let mut m_col: Vec<[f32; 4]> = Vec::new(); + let mut m_uv1: Vec<[f32; 2]> = Vec::new(); + let mut m_idx: Vec = Vec::new(); + + for chunk in &tile.chunks { + let base = m_pos.len() as u32; + let positions: Vec<[f32; 3]> = chunk + .positions + .iter() + .map(|p| wow_to_bevy(*p).to_array()) + .collect(); + // Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat. + if chunk.normals.len() == chunk.positions.len() { + m_norm.extend(chunk.normals.iter().map(|n| wow_to_bevy(*n).to_array())); + } else { + m_norm.extend(computed_normals(&positions, &chunk.indices)); + } + + // Resolve up to 4 layer textures to array indices (appending new ones). Missing slots + // reuse layer 0 (its alpha weight is 0, so it never shows). + let mut li = [0u32; 4]; + for (slot, name) in chunk.layer_textures.iter().take(4).enumerate() { + let key = normalize_path(name); + li[slot] = if let Some(&i) = layer_index.get(&key) { + i + } else if let Some(layer) = decode_layer(ctx, &key).await { + layer_buf.extend_from_slice(&layer.chain); + layer_index.insert(key, layer_count); + layer_count += 1; + layer_count - 1 + } else { + 0 + }; + } + for slot in chunk.layer_textures.len().min(4)..4 { + li[slot] = li[0]; + } + + let ai = match &chunk.alpha_map { + Some(rgba) => { + alpha_buf.extend_from_slice(rgba); + alpha_count += 1; + alpha_count - 1 + } + None => 0, + }; + let si: f32 = match &chunk.shadow { + Some(map) => { + shadow_buf.extend_from_slice(map); + shadow_count += 1; + (shadow_count - 1) as f32 + } + None => -1.0, + }; + + let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32]; + let uv1 = [ai as f32, si]; + for _ in 0..positions.len() { + m_col.push(col); + m_uv1.push(uv1); + } + m_uv.extend_from_slice(&chunk.uvs); + m_idx.extend(chunk.indices.iter().map(|i| base + i)); + m_pos.extend(positions); + } + + // A `texture_2d_array` needs ≥1 layer even when a tile carries no alpha / shadow maps. + if alpha_count == 0 { + alpha_buf = vec![0u8; (ALPHA_MAP_SIZE * ALPHA_MAP_SIZE * 4) as usize]; + alpha_count = 1; + } + if shadow_count == 0 { + shadow_buf = vec![0u8; (SHADOW_MAP_SIZE * SHADOW_MAP_SIZE) as usize]; + shadow_count = 1; + } + + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, m_pos); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, m_norm); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, m_uv); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, m_uv1); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, m_col); + mesh.insert_indices(Indices::U32(m_idx)); + + let layer_array = layer_array_image(LAYER_TEX_SIZE, layer_count, layer_mips, layer_buf); + let alpha_array = alpha_array_image(ALPHA_MAP_SIZE, alpha_count, alpha_buf); + let shadow_array = shadow_array_image(SHADOW_MAP_SIZE, shadow_count, shadow_buf); + + Ok(AdtTile { + mesh: ctx.add_labeled_asset("mesh".to_string(), mesh), + layer_array: ctx.add_labeled_asset("layer_array".to_string(), layer_array), + alpha_array: ctx.add_labeled_asset("alpha_array".to_string(), alpha_array), + shadow_array: ctx.add_labeled_asset("shadow_array".to_string(), shadow_array), + doodads: tile.doodads, + wmos: tile.wmos, + chunks: tile.chunks, + }) + } + + fn extensions(&self) -> &[&str] { + &["adt"] + } +} + +/// Decode a layer texture into the packed array form. Prefers the `_s` specular variant (sheen mask +/// in alpha); else falls back to the base BLP with alpha forced to 0 (matte — no sheen). `key` is a +/// normalized internal path. +async fn decode_layer(ctx: &mut LoadContext<'_>, key: &str) -> Option { + if let Some(spec) = key.strip_suffix(".blp").map(|stem| format!("{stem}_s.blp")) { + if let Ok(bytes) = ctx.read_asset_bytes(mpq_url(&spec)).await { + if let Ok(chain) = blp_bytes_to_mip_chain(&bytes) { + return Some(chain_to_layer(chain, LAYER_TEX_SIZE)); + } + } + } + let bytes = ctx.read_asset_bytes(mpq_url(key)).await.ok()?; + let chain = blp_bytes_to_mip_chain(&bytes).ok()?; + let mut layer = chain_to_layer(chain, LAYER_TEX_SIZE); + layer + .chain + .iter_mut() + .skip(3) + .step_by(4) + .for_each(|a| *a = 0); // matte: no sheen mask + Some(layer) +} + +/// An internal (`\`/lowercase) path → an `mpq://` URL. +fn mpq_url(key: &str) -> String { + format!("mpq://{}", key.replace('\\', "/")) +} + +/// Normalize an internal asset path so case/slash variants share one layer-array index. +fn normalize_path(path: &str) -> String { + path.replace('/', "\\").to_ascii_lowercase() +} + +/// Flat per-chunk normals for the rare chunk lacking authored MCNR (most carry it). +fn computed_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> { + let mut acc = vec![Vec3::ZERO; positions.len()]; + for tri in indices.chunks_exact(3) { + let [a, b, c] = [tri[0] as usize, tri[1] as usize, tri[2] as usize]; + let (va, vb, vc) = ( + Vec3::from(positions[a]), + Vec3::from(positions[b]), + Vec3::from(positions[c]), + ); + let n = (vb - va).cross(vc - va); + acc[a] += n; + acc[b] += n; + acc[c] += n; + } + acc.into_iter() + .map(|n| n.normalize_or_zero().to_array()) + .collect() +} diff --git a/crates/benilla-assets/src/blp.rs b/crates/benilla-assets/src/blp.rs new file mode 100644 index 00000000..cddaf251 --- /dev/null +++ b/crates/benilla-assets/src/blp.rs @@ -0,0 +1,187 @@ +//! BLP → Bevy [`Image`] loader. +//! +//! Decodes a WoW BLP texture into an `Image` in one of three variants (selected by loader settings): +//! - [`BlpVariant::WorldArt`] — tiling world/model albedo: `Rgba8Unorm` (the RE'd gamma-space +//! invariant — the GPU does *not* linearize albedo on sample, so shader math stays in WoW's +//! gamma/byte space), the BLP's **authored** mip pyramid used verbatim (the real 1.12 client cannot +//! regenerate mips — it uploads the stored ones), repeat + trilinear + anisotropic sampling. +//! - [`BlpVariant::Sprite`] — emissive billboards (sun/moon discs): `Rgba8UnormSrgb`, clamp, mip 0. +//! - [`BlpVariant::Cursor`] — the OS cursor image: `Rgba8UnormSrgb`, single mip. +//! +//! Fidelity rationale (authored mips, the gamma-space `Unorm` invariant) is anchored in +//! `wow-5875-re/system/{terrain,lighting}`; here we only build the GPU resource. + +use bevy::asset::io::Reader; +use bevy::asset::{AssetLoader, LoadContext, RenderAssetUsages}; +use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::reflect::TypePath; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; +use serde::{Deserialize, Serialize}; + +use benilla_formats::{blp_bytes_to_mip_chain, blp_to_rgba, BlpMipChain}; + +/// Which on-GPU form a BLP decodes to. See the module docs. +#[derive(Default, Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub enum BlpVariant { + /// Tiling world/model albedo — `Rgba8Unorm`, authored mips, repeat + anisotropic. The default. + #[default] + WorldArt, + /// Emissive billboard (celestial disc) — `Rgba8UnormSrgb`, clamp, mip 0 only. + Sprite, + /// Gamma-lane effect sprite (weather flakes/splashes) — `Rgba8Unorm` like [`Self::WorldArt`] + /// but **mip 0 only** + clamp: thin cut-out shapes (a snowflake's arms) collapse to + /// near-zero alpha under the authored mip chain when a small world-space quad minifies — + /// the reference draws these through point-sprite/near-mip-0 paths and keeps them crisp. + Effect, + /// OS cursor image — `Rgba8UnormSrgb`, single mip. + Cursor, +} + +/// Per-load settings for [`BlpImageLoader`]: choose the [`BlpVariant`] via `load_with_settings`. +#[derive(Default, Clone, Serialize, Deserialize)] +pub struct BlpLoaderSettings { + pub variant: BlpVariant, +} + +/// Bevy [`AssetLoader`] decoding `*.blp` → [`Image`]. +#[derive(Default, TypePath)] +pub struct BlpImageLoader; + +impl AssetLoader for BlpImageLoader { + type Asset = Image; + type Settings = BlpLoaderSettings; + type Error = std::io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + settings: &Self::Settings, + _ctx: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); + Ok(match settings.variant { + BlpVariant::WorldArt => world_art_image(blp_bytes_to_mip_chain(&bytes).map_err(to_io)?), + BlpVariant::Sprite => { + let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?; + sprite_image(w, h, rgba) + } + BlpVariant::Effect => { + let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?; + effect_image(w, h, rgba) + } + BlpVariant::Cursor => { + let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?; + cursor_image(w, h, rgba) + } + }) + } + + fn extensions(&self) -> &[&str] { + &["blp"] + } +} + +/// Albedo format: non-sRGB `Rgba8Unorm` — the GPU does not linearize albedo on sample, keeping shader +/// math in WoW's gamma/byte space (the RE'd faithful invariant; `wow-5875-re/system/lighting`). +fn color_format() -> TextureFormat { + TextureFormat::Rgba8Unorm +} + +/// World/model albedo: the BLP's authored mip pyramid laid in verbatim, repeat + trilinear + +/// anisotropic. (Port of the old `repeat_texture_authored`.) +fn world_art_image(chain: BlpMipChain) -> Image { + let levels = chain.mips.len() as u32; + let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below + let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum()); + for mip in &chain.mips { + data.extend_from_slice(mip); + } + let mut image = Image::new( + Extent3d { + width: chain.width, + height: chain.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + mip0, + color_format(), + RenderAssetUsages::default(), + ); + image.data = Some(data); + image.texture_descriptor.mip_level_count = levels; + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: 8, + ..Default::default() + }); + image +} + +/// Emissive billboard (celestial disc): sRGB so an unlit pass round-trips the authored gamma bytes to +/// screen; clamp + linear, mip 0 only (the disc maps to one quad and never tiles/minifies far). +fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::ClampToEdge, + address_mode_v: ImageAddressMode::ClampToEdge, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + ..Default::default() + }); + image +} + +/// Gamma-lane effect sprite: `Rgba8Unorm` (the shader does WoW's byte-space combine), clamp, +/// **mip 0 only** — see [`BlpVariant::Effect`]. +fn effect_image(width: u32, height: u32, rgba: Vec) -> Image { + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + color_format(), + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::ClampToEdge, + address_mode_v: ImageAddressMode::ClampToEdge, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + ..Default::default() + }); + image +} + +/// OS cursor image: sRGB, single mip (cursors are tiny and need neither a mip chain nor repeat). +fn cursor_image(width: u32, height: u32, rgba: Vec) -> Image { + Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ) +} diff --git a/crates/benilla-assets/src/coords.rs b/crates/benilla-assets/src/coords.rs new file mode 100644 index 00000000..3cc44cb7 --- /dev/null +++ b/crates/benilla-assets/src/coords.rs @@ -0,0 +1,279 @@ +//! The WoW ⇄ Bevy coordinate transform — the single documented boundary between raw WoW +//! coordinates (kept everywhere in `benilla-formats` / `benilla-protocol`, matching wowdev.wiki + vmangos) +//! and Bevy's render space. +//! +//! WoW is right-handed with **+X north, +Y west, +Z up**, 1 unit = 1 yard. Bevy is right-handed +//! with **+Y up, −Z forward**. The map is the pure rotation `bevy = (−y, z, −x)` — determinant +1, +//! so it never mirrors (winding/normals are preserved) and 1 unit stays 1 yard. See decision 0002. +//! +//! Extracted from `main.rs` into its own module so the transform — including the quaternion +//! conjugation used for model placement, historically the trickiest part — is unit-tested in +//! isolation (no game assets, no GPU, no running app). + +use bevy::math::{Mat3, Quat, Vec3}; +use bevy::transform::components::Transform; + +/// WoW `[x, y, z]` → Bevy. Pure rotation, 1 unit = 1 yard. +pub fn wow_to_bevy(p: [f32; 3]) -> Vec3 { + Vec3::new(-p[1], p[2], -p[0]) +} + +/// Inverse of [`wow_to_bevy`]: Bevy coords → raw WoW `[x, y, z]` (for sending our position upstream). +pub fn bevy_to_wow(b: Vec3) -> [f32; 3] { + [-b.z, -b.x, b.y] +} + +/// The fixed WoW→Bevy basis rotation (the rotation part of [`wow_to_bevy`]); used to conjugate +/// placement rotations, since the model meshes are baked into Bevy space. +fn wow_to_bevy_quat() -> Quat { + // Columns = Bevy images of the WoW basis: X→−Z, Y→−X, Z→+Y. + Quat::from_mat3(&Mat3::from_cols( + Vec3::new(0.0, 0.0, -1.0), + Vec3::new(-1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + )) +} + +/// Convert an MDDF/MODF Euler rotation (degrees) into the Bevy quaternion for a baked (Bevy-space) +/// mesh. +/// +/// Derived from wowdev.wiki's ADT/v18 `createPlacementMatrix`, whose output frame is — verified +/// against our own MDDF→position math — exactly the WoW gameplay frame (Z up). The model→world +/// rotation it gives is `Rx(90°)·Ry(90°)·Ry(ry−270°)·Rz(−rx)·Rx(rz−90°)`, which simplifies to +/// `Rx(90°)·Ry(ry−180°)·Rz(−rx)·Rx(rz−90°)`; we then conjugate it into Bevy space (the meshes are +/// baked there). `ry` is the heading about the up axis; `rx`/`rz` are pitch/roll. +/// +/// The leading `Rx(90°)` is the **M2-local→world base reorientation** the old formula skipped — it +/// only changes the result when pitch/roll (`rx`/`rz`) are non-zero, so flat doodads and symmetric +/// trees are unaffected, but leaning/tilted segments (fences) now orient correctly. +pub fn placement_rotation(rotation_deg: [f32; 3]) -> Quat { + use std::f32::consts::{FRAC_PI_2, PI}; + let (rx, ry, rz) = ( + rotation_deg[0].to_radians(), + rotation_deg[1].to_radians(), + rotation_deg[2].to_radians(), + ); + let in_wow = Quat::from_rotation_x(FRAC_PI_2) + * Quat::from_rotation_y(ry - PI) + * Quat::from_rotation_z(-rx) + * Quat::from_rotation_x(rz - FRAC_PI_2); + let to_bevy = wow_to_bevy_quat(); + to_bevy * in_wow * to_bevy.inverse() +} + +/// The Bevy-space local [`Transform`] of a **WMO doodad** (an MODD entry) within its WMO's model +/// space. Compose it onto the WMO instance's world transform — `wmo_world.mul_transform(this)` — to +/// place the doodad in the world. +/// +/// Unlike MDDF/MODF map placements (Euler angles + the client's base reorientation, see +/// [`placement_rotation`]), an MODD carries the doodad's **full orientation as a quaternion** +/// `(x, y, z, w)` directly — no separate base reorientation — alongside a position and uniform scale, +/// all in WMO model space (WoW axes, Z up; the same space the WMO's own geometry lives in, which we +/// already bake into Bevy via [`wow_to_bevy`]). So we map position via `wow_to_bevy`, conjugate the +/// rotation by the WoW→Bevy basis (the meshes are baked there), and keep the uniform scale (a uniform +/// scale is basis-invariant). This mirrors the conjugation in [`placement_rotation`]. +pub fn wmo_doodad_local(position: [f32; 3], orientation: [f32; 4], scale: f32) -> Transform { + let q_wow = Quat::from_xyzw( + orientation[0], + orientation[1], + orientation[2], + orientation[3], + ); + let to_bevy = wow_to_bevy_quat(); + Transform { + translation: wow_to_bevy(position), + // q and −q are the same rotation; normalize guards a denormalized stored quat. + rotation: (to_bevy * q_wow * to_bevy.inverse()).normalize(), + scale: Vec3::splat(scale), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: Vec3, b: Vec3) -> bool { + (a - b).length() < 1e-5 + } + + /// `bevy_to_wow` must exactly undo `wow_to_bevy` for arbitrary points (a sign/axis slip in + /// either direction would desync our outbound position from the server's frame). + #[test] + fn wow_bevy_round_trips() { + for p in [ + [1.0, 2.0, 3.0], + [-8949.95, -132.49, 83.53], // the Human start (Northshire) + [0.0, 0.0, 0.0], + [100.0, -50.0, 7.0], + ] { + let back = bevy_to_wow(wow_to_bevy(p)); + assert!( + (0..3).all(|i| (back[i] - p[i]).abs() < 1e-3), + "round-trip {p:?} → {back:?}" + ); + } + } + + /// The yaw-conjugation law the transport platform frame rests on (decision 0438 phase 2): + /// a WoW orientation θ about WoW-up (+Z) is exactly a Bevy yaw θ about Bevy-up (+Y) through + /// the basis map — `wow_to_bevy(rot_z(θ)·v) == rot_y(θ)·wow_to_bevy(v)`. This is what lets a + /// rider's boat-local pose convert between spaces with the plain linear map and lets the + /// wire's local orientation be `face_yaw − boat_yaw` with no correction term. + #[test] + fn yaw_conjugates_through_the_basis_map() { + for theta in [0.0f32, 0.7, 2.4, -1.1, 5.9] { + let (s, c) = theta.sin_cos(); + for v in [[3.0f32, -2.0, 1.5], [10.0, 0.0, -4.0]] { + let rotated_wow = [c * v[0] - s * v[1], s * v[0] + c * v[1], v[2]]; + let a = wow_to_bevy(rotated_wow); + let b = Quat::from_rotation_y(theta) * wow_to_bevy(v); + assert!(close(a, b), "θ={theta}: {a:?} vs {b:?}"); + } + } + } + + /// Pin the basis mapping with hand-verified golden values: north/west/up map where the docs say. + #[test] + fn wow_bevy_basis_is_golden() { + // WoW +X (north) → Bevy −Z (forward). + assert!(close( + wow_to_bevy([1.0, 0.0, 0.0]), + Vec3::new(0.0, 0.0, -1.0) + )); + // WoW +Y (west) → Bevy −X. + assert!(close( + wow_to_bevy([0.0, 1.0, 0.0]), + Vec3::new(-1.0, 0.0, 0.0) + )); + // WoW +Z (up) → Bevy +Y (up). + assert!(close( + wow_to_bevy([0.0, 0.0, 1.0]), + Vec3::new(0.0, 1.0, 0.0) + )); + } + + /// The conjugation quaternion must reproduce `wow_to_bevy` on every vector — otherwise placement + /// rotations are conjugated by the wrong basis and asymmetric doodads/buildings face wrong. + #[test] + fn placement_quat_matches_the_position_transform() { + for w in [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [3.0, -2.0, 5.0], + ] { + let via_quat = wow_to_bevy_quat() * Vec3::new(w[0], w[1], w[2]); + assert!(close(via_quat, wow_to_bevy(w)), "quat vs fn for {w:?}"); + } + } + + /// Determinant +1 ⇒ a proper rotation (no mirroring); the whole coordinate decision rests on this. + #[test] + fn transform_is_a_proper_rotation() { + let m = Mat3::from_quat(wow_to_bevy_quat()); + assert!( + (m.determinant() - 1.0).abs() < 1e-5, + "det = {}", + m.determinant() + ); + } + + /// A pure MDDF/MODF heading (`ry`) is a yaw about WoW +Z, which conjugates to a yaw about + /// Bevy +Y — so it must leave the vertical axis fixed. This is the invariant that actually + /// validates the conjugation (a wrong basis would tilt the model when it should only spin). + #[test] + fn heading_only_placement_spins_about_vertical() { + for deg in [0.0, 30.0, 90.0, 250.0] { + let q = placement_rotation([0.0, deg, 0.0]); + assert!( + close(q * Vec3::Y, Vec3::Y), + "heading {deg}° should fix +Y, got {:?}", + q * Vec3::Y + ); + } + } + + /// An MODD with identity orientation is a pure translate (by `wow_to_bevy(pos)`) + uniform + /// scale — no rotation. A wrong conjugation basis would leak rotation in here. + #[test] + fn wmo_doodad_identity_is_translate_scale() { + let t = wmo_doodad_local([3.0, -2.0, 5.0], [0.0, 0.0, 0.0, 1.0], 2.5); + assert!(close(t.translation, wow_to_bevy([3.0, -2.0, 5.0]))); + assert!( + t.rotation.dot(Quat::IDENTITY).abs() > 0.9999, + "rot = {:?}", + t.rotation + ); + assert!((t.scale - Vec3::splat(2.5)).length() < 1e-5); + } + + /// A doodad at the WMO origin, composed onto the WMO instance's world transform, must land + /// exactly at the instance's world position — the property the whole compose relies on. + #[test] + fn wmo_doodad_at_origin_lands_on_instance() { + let wmo_world = Transform { + translation: Vec3::new(10.0, 20.0, -30.0), + rotation: placement_rotation([0.0, 137.0, 0.0]), + scale: Vec3::ONE, + }; + let local = wmo_doodad_local([0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], 1.0); + let world = wmo_world.mul_transform(local); + assert!(close(world.translation, wmo_world.translation)); + } + + /// An MODD heading — a rotation about WoW +Z (up) — must conjugate to a yaw about Bevy +Y, so it + /// leaves the vertical axis fixed (the same invariant that validates `placement_rotation`'s + /// conjugation, but via the quaternion path). A wrong basis would tilt the doodad. + #[test] + fn wmo_doodad_heading_spins_about_vertical() { + for deg in [0.0_f32, 35.0, 90.0, 215.0] { + let q = Quat::from_rotation_z(deg.to_radians()); // yaw about WoW +Z (up) + let t = wmo_doodad_local([0.0, 0.0, 0.0], q.to_array(), 1.0); + assert!( + close(t.rotation * Vec3::Y, Vec3::Y), + "heading {deg}° should fix +Y, got {:?}", + t.rotation * Vec3::Y + ); + } + } + + /// Placement rotations are always unit quaternions (a non-unit quat would scale/shear the mesh). + #[test] + fn placement_rotations_are_unit_quaternions() { + for r in [[0.0, 0.0, 0.0], [12.0, 34.0, 56.0], [-90.0, 180.0, 45.0]] { + let q = placement_rotation(r); + assert!((q.length() - 1.0).abs() < 1e-4, "non-unit quat for {r:?}"); + } + } + + /// Golden snapshot of the full placement rotation, including pitch/roll — captured after the + /// fence orientation was confirmed correct on screen. Locks the MDDF convention so a future + /// change to the formula can't silently regress doodad/building orientation. The first case is a + /// real leaning fence segment (`[rx, ry, rz]` degrees). Quaternions double-cover rotations, so + /// compare by |dot| ≈ 1 (q and −q are the same rotation). + #[test] + fn placement_rotation_goldens() { + use std::f32::consts::FRAC_1_SQRT_2; // 0.70710678… + let cases = [ + ( + [86.0_f32, 252.0, 93.5], + Quat::from_xyzw(-0.691160, -0.107332, -0.156292, 0.697388), + ), + ( + [0.0, 0.0, 90.0], + Quat::from_xyzw(FRAC_1_SQRT_2, -FRAC_1_SQRT_2, 0.0, 0.0), + ), + ( + [30.0, 45.0, 60.0], + Quat::from_xyzw(0.360423, -0.822363, -0.391904, 0.200562), + ), + ]; + for (deg, expected) in cases { + let q = placement_rotation(deg); + assert!( + q.dot(expected).abs() > 0.9999, + "placement_rotation({deg:?}) = {q:?}, expected ~{expected:?}" + ); + } + } +} diff --git a/crates/benilla-assets/src/lib.rs b/crates/benilla-assets/src/lib.rs new file mode 100644 index 00000000..d4f450f2 --- /dev/null +++ b/crates/benilla-assets/src/lib.rs @@ -0,0 +1,485 @@ +//! `benilla-assets` — the bridge from the WoW 1.12.1 MPQ patch chain into Bevy's asset system. +//! +//! Registers an `mpq://` [`AssetSource`](bevy::asset::io::AssetSource) backed by +//! [`benilla_formats::Chain`], so every WoW asset loads through the standard +//! [`AssetServer`](bevy::asset::AssetServer) as a `Handle` — gaining async loading, handle dedup, +//! a dependency graph, and hot-reload for free, instead of the bespoke caches and hand-rolled +//! worker/finalize pipeline the old client carried (decision 0005). The per-format +//! [`AssetLoader`](bevy::asset::AssetLoader)s (BLP→`Image`, M2/WMO→model, ADT→tile, DBC→catalog) +//! build on this foundation. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use benilla_formats::Chain; +use bevy::asset::io::{ + AssetReader, AssetReaderError, AssetSourceBuilder, ErasedAssetReader, PathStream, Reader, + VecReader, +}; +use bevy::asset::AssetApp; +use bevy::prelude::*; + +pub mod coords; +pub mod minimap_grid; + +mod model; +pub use model::{ + bone_target_id, AnimClip, BillboardInfo, GlobalBone, GlobalSeqChannel, ModelAnimations, + ModelAttachment, ModelJoint, ModelMarker, ModelSkeleton, ModelSubmesh, +}; +mod adt; +mod terrain; +mod wdt; +pub use adt::{AdtLoader, AdtTile}; +pub use wdt::{WdtIndex, WdtIndexLoader}; +mod blp; +pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant}; +mod m2; +pub use m2::{M2Model, M2ModelLoader, ModelEmitter, ModelLight, ModelRibbon, PortraitCamera}; +mod wmo; +pub use benilla_formats::{WmoPortalInfo, WmoPortalRef}; +pub use wmo::{ + cap96, collision_tri_bounds, floor168, footprint_tri_bounds, DoodadBase, WmoGroupNav, WmoModel, + WmoModelLoader, +}; + +/// The asset-source id for MPQ-backed assets: load paths look like `mpq://World/Azeroth/foo.adt`. +pub const MPQ_SOURCE: &str = "mpq"; + +/// A Bevy [`AssetReader`] over the vanilla MPQ patch chain. Cheap to clone — every clone shares the +/// one open chain ([`Chain`] reads through fresh per-call handles, so this is `Send + Sync`). +#[derive(Clone)] +pub struct MpqAssetReader { + chain: Arc, +} + +impl MpqAssetReader { + /// Open the patch chain from a `Data` directory (or a single `.MPQ`), ready to back an + /// `mpq://` source. + pub fn open(data_dir: &Path) -> Result { + Ok(Self { + chain: Arc::new(Chain::open(data_dir)?), + }) + } +} + +impl AssetReader for MpqAssetReader { + async fn read<'a>(&'a self, path: &'a Path) -> Result { + let internal = path + .to_str() + .ok_or_else(|| AssetReaderError::NotFound(path.to_path_buf()))?; + if !self.chain.contains(internal) { + return Err(AssetReaderError::NotFound(path.to_path_buf())); + } + let bytes = self + .chain + .read(internal) + .map_err(|e| AssetReaderError::Io(Arc::new(std::io::Error::other(format!("{e:#}")))))?; + Ok(VecReader::new(bytes)) + } + + async fn read_meta<'a>(&'a self, path: &'a Path) -> Result { + // MPQ assets carry no sidecar `.meta`; Bevy falls back to default meta on `NotFound`. + Err::(AssetReaderError::NotFound(path.to_path_buf())) + } + + async fn read_directory<'a>( + &'a self, + path: &'a Path, + ) -> Result, AssetReaderError> { + // We load by explicit path; directory enumeration / folder-watching isn't supported. + Err(AssetReaderError::NotFound(path.to_path_buf())) + } + + async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result { + Ok(false) + } +} + +/// Register the `mpq://` asset source on `app`, backed by the patch chain at `data_dir`. +/// +/// Must be called **before** Bevy's `AssetPlugin` builds (i.e. before `DefaultPlugins`): asset +/// sources are read when the plugin initializes the [`AssetServer`](bevy::asset::AssetServer). +pub fn register_mpq_source(app: &mut App, data_dir: &Path) -> Result<()> { + let reader = MpqAssetReader::open(data_dir)?; + app.register_asset_source( + MPQ_SOURCE, + AssetSourceBuilder::new(move || -> Box { Box::new(reader.clone()) }), + ); + Ok(()) +} + +/// Register benilla's asset loaders on `app`. Call **after** Bevy's `AssetPlugin` (loaders register +/// into the live [`AssetServer`](bevy::asset::AssetServer)); pair with [`register_mpq_source`], which +/// must run *before* `AssetPlugin`. +pub fn register_asset_loaders(app: &mut App) { + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + app.register_asset_loader(BlpImageLoader); + app.register_asset_loader(M2ModelLoader); + app.register_asset_loader(WmoModelLoader); + app.register_asset_loader(AdtLoader); + app.register_asset_loader(WdtIndexLoader); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::tasks::block_on; + + /// crates/benilla-assets -> repo root -> WoW/Data (gitignored; test skips when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + #[test] + fn mpq_reader_loads_real_client_bytes_through_the_assetreader() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = MpqAssetReader::open(&data).expect("open mpq reader"); + + // A real file resolves and drains to its bytes through the Bevy AssetReader/Reader traits. + let bytes = block_on(async { + let mut r = AssetReader::read(&reader, Path::new("DBFilesClient/Spell.dbc")) + .await + .expect("read Spell.dbc via AssetReader"); + let mut buf = Vec::new(); + r.read_to_end(&mut buf).await.expect("drain reader"); + buf + }); + assert_eq!(&bytes[..4], b"WDBC", "Spell.dbc starts with the WDBC magic"); + assert!(bytes.len() > 1_000_000, "Spell.dbc should be sizable"); + + // A missing path yields NotFound — the variant Bevy relies on for meta fallback. + let missing = block_on(AssetReader::read(&reader, Path::new("does/not/exist.blp"))); + assert!( + matches!(missing, Err(AssetReaderError::NotFound(_))), + "missing path should be NotFound" + ); + } + + #[test] + fn loads_a_blp_image_through_the_full_mpq_pipeline() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + // Full pipeline, headless: mpq:// source → AssetServer → BlpImageLoader → Handle. + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + register_asset_loaders(&mut app); + + let handle: Handle = app + .world() + .resource::() + .load("mpq://Interface/Icons/Spell_Holy_ArcaneIntellect.blp"); + + let mut got = None; + // Same generous ceiling as the WMO test below — parallel-session load starves the IO pool. + for _ in 0..15_000 { + app.update(); + if let Some(img) = app.world().resource::>().get(&handle) { + got = Some(( + img.width(), + img.height(), + img.texture_descriptor.mip_level_count, + )); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let (w, h, mips) = + got.expect("the spell icon should load via mpq:// + BlpImageLoader within 300 ticks"); + assert_eq!((w, h), (64, 64), "spell icon is 64x64"); + assert!( + mips >= 1, + "authored mip levels should be present, got {mips}" + ); + } + + /// Regression guard: `load_with_settings(Sprite)` must reach the loader on the async `mpq://` + /// path so a streamed tile lands as `Rgba8UnormSrgb`, not the loader's `WorldArt` default + /// (`Rgba8Unorm`). The minimap streams its tiles this way (`minimap.rs`), and the UI pass's + /// contract is sRGB textures (linearize → multiply → re-encode); a silent regression to the + /// gamma-space default re-encodes every tile ~2× brighter — the decision-0178 over-bright class + /// of bug, but for the minimap. Skips without the vanilla client data. + #[test] + fn minimap_tile_settings_reach_the_async_loader() { + use bevy::render::render_resource::TextureFormat; + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + register_asset_loaders(&mut app); + + // IN ISOLATION — the real app (minimap.rs) only ever loads a tile via + // `load_with_settings(Sprite)`, never a bare `load()` of the same path. Loading both here + // would let Bevy's path-dedup hand the second call the first's settings, masking the truth. + let tile = "mpq://textures/Minimap/ea283abc0bf9637c3fad5e840a65b38b.blp"; + let server = app.world().resource::().clone(); + let sprite_h: Handle = + server.load_with_settings(tile, |s: &mut BlpLoaderSettings| { + s.variant = BlpVariant::Sprite; + }); + + let mut sprite_fmt = None; + // Same generous ceiling as the WMO test below — parallel-session load starves the IO pool. + for _ in 0..15_000 { + app.update(); + if let Some(img) = app.world().resource::>().get(&sprite_h) { + sprite_fmt = Some(img.texture_descriptor.format); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + eprintln!("Sprite settings (isolated) -> {sprite_fmt:?}"); + assert_eq!( + sprite_fmt, + Some(TextureFormat::Rgba8UnormSrgb), + "load_with_settings(Sprite) must produce an sRGB tile on the async mpq:// path" + ); + } + + /// The WMO interior-minimap tile grid (`crate::minimap::group_axis_grid`) verified against real + /// authored data: load Ironforge and check the per-group tile count each axis matches the + /// `md5translate.trs` ground truth — group 66 = 2×2, 44 = 1(X)×2(Y), 89 = 2(X)×1(Y), and the + /// small groups 1×1. Grounds the (RE-inferred) footprint→grid bake the interior renderer rests on. + #[test] + fn ironforge_group_grid_matches_trs() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: no client data"); + return; + } + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("mpq"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + app.init_asset::(); + register_asset_loaders(&mut app); + let h: Handle = app + .world() + .resource::() + .load("mpq://World/wmo/KhazModan/Cities/Ironforge/Ironforge.wmo"); + let mut model = None; + // A generous ~30 s ceiling (healthy runs finish in well under a second): the async IO + // pool gets starved when a parallel session runs its own full gates on this machine, and + // the old ~4 s budget flaked exactly then (2026-07-10, two concurrent workspace runs). + for _ in 0..15_000 { + app.update(); + if let Some(m) = app.world().resource::>().get(&h) { + model = Some(m.clone()); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let m = model.expect("Ironforge loads"); + // (group index, expected X-count, expected Y-count) — read off the trs tile names. + let grid = |ext: f32| crate::minimap_grid::group_axis_grid(ext).0; + for &(g, ex_n, ey_n) in &[ + (1, 1, 1), + (2, 1, 1), + (10, 1, 1), + (44, 1, 2), + (66, 2, 2), + (89, 2, 1), + ] { + let gn = m.group_nav.get(g).expect("group present"); + let nx = grid(gn.bbox_max[0] - gn.bbox_min[0]); + let ny = grid(gn.bbox_max[1] - gn.bbox_min[1]); + assert_eq!((nx, ny), (ex_n, ey_n), "group {g} grid"); + } + } + + #[test] + fn loads_an_m2_model_through_the_pipeline() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + app.init_asset::(); + // The M2 loader emits labeled sub-assets the skinned + animated path needs (decision 0019): the + // inverse bind poses, plus the idle `AnimationClip` + `AnimationGraph`. The real app registers + // these via `DefaultPlugins` (bevy_mesh's `MeshPlugin` + `AnimationPlugin`); the minimal harness + // registers them here so the campfire's M2 (which has bones + a Stand sequence) loads. + app.init_asset::(); + app.init_asset::(); + app.init_asset::(); + register_asset_loaders(&mut app); // inits M2Model + registers BLP/M2 loaders + + // A doodad with embedded (hardcoded) textures — the campfire (`.mdx` ref → `.m2` physical file). + let handle: Handle = app + .world() + .resource::() + .load("mpq://World/Azeroth/Elwynn/PassiveDoodads/Campfire/ElwynnCampfire.m2"); + + let mut info = None; + for _ in 0..600 { + app.update(); + if let Some(m) = app.world().resource::>().get(&handle) { + let meshes: Vec> = + m.submeshes.iter().map(|s| s.mesh.clone()).collect(); + let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count(); + info = Some((meshes, textured, m.bounds.is_some())); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let (meshes, textured, has_bounds) = + info.expect("the campfire M2 should load via mpq:// + M2ModelLoader within 600 ticks"); + assert!(!meshes.is_empty(), "campfire should have render batches"); + assert!(has_bounds, "M2 carries authored bounds"); + assert!(textured > 0, "campfire batches reference embedded textures"); + + // Each submesh mesh sub-asset is present and non-empty (baked to Bevy space). + let mesh_assets = app.world().resource::>(); + for h in &meshes { + let mesh = mesh_assets.get(h).expect("submesh mesh sub-asset present"); + assert!(mesh.count_vertices() > 0, "submesh has vertices"); + } + } + + #[test] + fn loads_a_wmo_model_through_the_pipeline() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + app.init_asset::(); + register_asset_loaders(&mut app); + + // The Goldshire Inn — a root + group WMO; the loader reads the groups via read_asset_bytes. + let handle: Handle = app + .world() + .resource::() + .load("mpq://World/wmo/Azeroth/Buildings/GoldshireInn/GoldshireInn.wmo"); + + let mut info = None; + for _ in 0..600 { + app.update(); + if let Some(m) = app.world().resource::>().get(&handle) { + let meshes: Vec> = + m.submeshes.iter().map(|s| s.mesh.clone()).collect(); + let textured = m.submeshes.iter().filter(|s| s.texture.is_some()).count(); + info = Some((meshes, textured)); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let (meshes, textured) = + info.expect("the Goldshire Inn WMO should load via mpq:// + WmoModelLoader"); + assert!( + !meshes.is_empty(), + "the inn has render batches across its groups" + ); + assert!(textured > 0, "WMO batches reference textures"); + + let mesh_assets = app.world().resource::>(); + for h in &meshes { + let mesh = mesh_assets.get(h).expect("group submesh sub-asset present"); + assert!(mesh.count_vertices() > 0, "group submesh has vertices"); + } + } + + #[test] + fn loads_an_adt_terrain_tile_through_the_pipeline() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + // Find an existing Elwynn-area Azeroth tile (don't hardcode exact coords). + let reader = benilla_formats::Chain::open(&data).expect("open chain"); + let mut url = None; + 'find: for tx in 28..36u32 { + for ty in 46..52u32 { + if reader.contains(&format!("World\\Maps\\Azeroth\\Azeroth_{tx}_{ty}.adt")) { + url = Some(format!("mpq://World/Maps/Azeroth/Azeroth_{tx}_{ty}.adt")); + break 'find; + } + } + } + let url = url.expect("an Azeroth ADT tile should exist near Elwynn"); + + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + register_mpq_source(&mut app, &data).expect("register mpq source"); + app.add_plugins(bevy::asset::AssetPlugin::default()); + app.init_asset::(); + app.init_asset::(); + register_asset_loaders(&mut app); + + let handle: Handle = app.world().resource::().load(url); + let mut info = None; + for _ in 0..2000 { + app.update(); + if let Some(t) = app.world().resource::>().get(&handle) { + info = Some(( + t.mesh.clone(), + t.layer_array.clone(), + t.alpha_array.clone(), + t.shadow_array.clone(), + t.doodads.len(), + )); + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + let (mesh_h, layer_h, alpha_h, shadow_h, n_doodads) = + info.expect("the ADT tile should load via mpq:// + AdtLoader"); + + let meshes = app.world().resource::>(); + let vc = meshes + .get(&mesh_h) + .expect("merged terrain mesh present") + .count_vertices(); + assert!( + vc > 0 && vc <= 256 * 145, + "merged tile mesh ≤ 256 chunks × 145 verts, got {vc}" + ); + + let images = app.world().resource::>(); + let layer = images.get(&layer_h).expect("layer array present"); + assert_eq!( + layer.texture_descriptor.size.width, 256, + "layer array packed at LAYER_TEX_SIZE" + ); + assert!( + layer.texture_descriptor.mip_level_count > 1, + "layer array carries the authored mip chain" + ); + assert!(images.get(&alpha_h).is_some(), "alpha array present"); + assert!(images.get(&shadow_h).is_some(), "shadow array present"); + // Placement lists are carried as data (count is tile-dependent — some tiles are bare). + eprintln!("ADT tile loaded: {vc} verts, {n_doodads} doodad placements"); + } +} diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs new file mode 100644 index 00000000..48b3b50c --- /dev/null +++ b/crates/benilla-assets/src/m2.rs @@ -0,0 +1,582 @@ +//! M2 → model asset loader. +//! +//! Decodes a WoW M2 (`*.m2`) into an [`M2Model`]: one [`ModelSubmesh`] per render batch, each carrying +//! a [`Mesh`](bevy::mesh::Mesh) **labeled sub-asset** (baked to Bevy space via [`crate::coords`]) plus +//! the batch's texture as a `Handle` **dependency** (through the `mpq://` source). +//! +//! Deliberately app-independent: builds **meshes + texture handles + metadata only**, not materials +//! (the WoW lighting material is an app concern built at spawn) — so this asset is shared verbatim by +//! doodads, creatures, GameObjects, and the star model. Creature **skin variations** (`Monster1/2/3`) +//! are blank here (no `skins` passed) and resolved at the spawn site from `CreatureDisplayInfo`; this +//! loads the model's own embedded (hardcoded) textures. +//! +//! References use `.mdx`/`.mdl`, but the physical archive file is `.m2`; callers map the extension when +//! forming the `mpq://…m2` load path (the loader registers for `m2`). + +use benilla_formats::{ + hand_grip_finger_poses, parse_m2_animations, parse_m2_attachments, parse_m2_bounds, + parse_m2_collision_hull, parse_m2_global_sequence_bones, parse_m2_lights, + parse_m2_particle_emitters, parse_m2_playable_animation_lookup, parse_m2_portrait_camera, + parse_m2_render_submeshes, parse_m2_skeleton, CollisionMesh, M2Bounds, M2Light, + ParticleEmitterDef, +}; +use bevy::animation::graph::AnimationGraph; +use bevy::asset::io::Reader; +use bevy::asset::{Asset, AssetLoader, LoadContext}; +use bevy::mesh::skinning::SkinnedMeshInverseBindposes; +use bevy::prelude::*; +use bevy::reflect::TypePath; + +use crate::bone_target_id; +use crate::coords::wow_to_bevy; +use crate::model::{ + arm_subtree_roots, billboard_info, build_animation_clip, build_attachments, build_global_bones, + build_grip_clip, build_skeleton, build_skinned_submesh_mesh, build_submesh_mesh, + finger_subtree_roots, in_subtree, skeleton_pivots, upper_subtree_root, AnimClip, + ModelAnimations, ModelAttachment, ModelSkeleton, ModelSubmesh, +}; + +/// A loaded M2 model: its render batches + authored bounds (the distance-fade size source) + the +/// coarse collision hull (the app bakes a collider from it per placement) + its particle emitters. +#[derive(Asset, TypePath, Clone)] +pub struct M2Model { + pub submeshes: Vec, + /// Authored bounding sphere/box (model-local yards) — the reference's distance-fade size. `None` + /// if the header bounds couldn't be read. + pub bounds: Option, + /// The doodad's collision hull in raw WoW model space (trunk/solid; ≪ render mesh). `None` when the + /// model carries no hull (`nBoundingTriangles == 0` — many small props are collide-iff-hull). + pub collision: Option, + /// Particle emitters (flames, glows, smoke). Each carries its parsed def (positions in raw WoW + /// model space) + its resolved texture handle. Empty for most models; one or two for campfires. + pub emitters: Vec, + /// Ribbon emitters (weapon trails, wisp streamers, missile trails — wow-re + /// `ribbon-emitter-spec.md`). Same shape as the particle emitters: parsed def + resolved trail + /// texture + the host bone's pivot for joint riding. Empty for nearly everything (176 models + /// corpus-wide author one). + pub ribbons: Vec, + /// M2 light blocks (raw WoW model space + the host bone's rest pivot). The spawn site turns each + /// casting `type==1` (point) light into a Bevy `PointLight` that lays the faithful dynamic + /// hot-spot on every lit surface (decisions 0016/0273). Empty for nearly all models; one on fire + /// props (campfire/firepit/cooker/forge) — and one on the held torch every torch-bearing NPC + /// carries. + pub lights: Vec, + /// The model's rest skeleton in Bevy space (decision 0019) — the joint tree the skinned creature + /// path spawns one entity per bone from. Empty for a boneless model. + pub skeleton: ModelSkeleton, + /// The matching inverse bind poses (`translate(−pivot)` per bone), shared across every instance of + /// this model; each skinned submesh references it from its `SkinnedMesh`. A labeled sub-asset. + pub inverse_bindposes: Handle, + /// The model's animations, ready to play (decision 0019) — one shared `AnimationGraph` with every + /// sequence's clip. `None` for a model with no animated sequences (static props, boneless models). + /// Built once here; each creature instance drives it with its own `AnimationPlayer`. + pub animations: Option, + /// The **file-order-first sequence's authored duration** (seconds, one full pass) — carried even + /// when no clip builds ([`ModelAnimations`] drops sequences that move no bone, and a model whose + /// sequences all sit still has no `animations` at all). The real client's effect-completion + /// clock runs one pass of the armed sequence regardless of bone keys or the LOOP flag + /// (byte-verified, wow-re `ceffect-selfterm.md`: the fire is the CM2 advance `0x7194f8`, a pure + /// end-boundary compare; loop is tested only *after* it) — the spell-fx self-termination reads + /// this. Named approximation: the client arms `animationLookup[0]`'s sequence, not strictly the + /// file-order-first one; they coincide on every effect model probed (single-sequence models). + /// `None` for a model with no sequences at all. + pub first_seq_span: Option, + /// The model's attachment points (decision 0072 — held items): weapon/shield hand slots, sheath + /// points, etc. Each carries the bone it rides + its bind-pose-relative offset (see + /// [`ModelAttachment`]). Empty for a model with no attachment table (most doodads/WMO props). + pub attachments: Vec, + /// The animation-event table's positional markers (`$CSL`/`$CSR`/`$CST` casting hands, `$BWR` + /// ranged release, …), baked like [`Self::attachments`] and queried by 4CC first-match — the + /// client's `0x7130e0`/`0x7131b0` surface. Empty for a model with no events. + pub markers: Vec, + /// The model's authored **portrait camera** (`benilla_formats::M2PortraitCamera` baked to Bevy + /// space) — the exact rig the real client renders the unit-frame portrait through (VERIFIED, + /// wow-re portrait-render §4: `cameraLookup[0]`, `lookAt` + authored perspective, no engine-side + /// framing on top). `None` for a model with no camera table (props, a few creatures). + pub portrait_camera: Option, + /// The camera **table's first record** — the glue screens' `Model:SetCamera(0)` rig (the + /// create/select background scenes; their camera *lookup* is the 0xffff none sentinel, so + /// `portrait_camera` sees nothing there). Same Bevy-space conversion as `portrait_camera`. + pub camera0: Option, + /// A bow's `$WTT`/`$WTB` bowstring anchors (wow-re `nocked-ammo-cancel.md` §G2), baked to Bevy + /// space: `[top, bottom]` as `(bone index, model-local position)` — the two limb-tip points the + /// engine-drawn string spans. `None` for every non-bow model. + pub string_anchors: Option<[(u16, Vec3); 2]>, + /// MD20 header `GlobalModelFlags` (`+0x10`). Bits `&3` are the **terrain-conform gate** + /// (wow-re `terrain-tilt.md`, §5 byte-verified): `1` = pitch to the ground slope (every + /// mount + most quadrupeds), `3` = pitch **and** roll (kodo/crab/spider), `0`/`2` = level. + /// The entity layer reads it to conform a standing model to the terrain under it. + pub global_flags: u32, +} + +/// An M2's authored portrait-camera rig in Bevy space (see [`M2Model::portrait_camera`]): eye/target +/// in model-local yards at scale 1 (the client bakes portraits with root scale reset to 1), the +/// projection scalars straight from the record. +#[derive(Clone, Copy)] +pub struct PortraitCamera { + pub eye: Vec3, + pub target: Vec3, + /// Roll about the eye→target axis (radians) — rotates the up vector; `0.0` on every portrait + /// camera audited (kept for fidelity, not observed nonzero). + pub roll: f32, + /// The record's FOV (radians), verbatim — a **diagonal** angle in the client's convention; + /// the consumer builds the client's diagonal-FOV projection at the portrait's fixed 4/3 + /// aspect from it (`benilla`'s `WowPortraitProjection`; wow-re portrait-render §4 corrected). + pub fov: f32, + pub near: f32, + pub far: f32, +} + +/// One particle emitter of an [`M2Model`]: the parsed [`ParticleEmitterDef`] plus its resolved +/// particle texture as a `Handle` dependency (loaded through the `mpq://` source, like a +/// submesh albedo). The app's particle system simulates + renders it at the spawn site. +#[derive(Clone)] +pub struct ModelEmitter { + pub def: ParticleEmitterDef, + pub texture: Option>, + /// The host bone's (`def.bone`) pivot, **raw WoW model space** (0130 phase 4). `def.position` is + /// model-space; `position − bone_pivot` is the same point in the bone's own frame — the offset + /// an emitter riding a live joint composes through the joint's transform. Rest pose is identity + /// about the pivot, so the static path (`placement · position`) is the exact special case. + /// `[0; 3]` when the bone index is out of range (or the model is boneless). + pub bone_pivot: [f32; 3], + /// The **recursion model** (`def.recursion_model`) as a loaded dependency: its own emitters + /// become this emitter's CHILD emitters (wow-re `part-child-recursion.md`), wired by the + /// app's particle system once the asset resolves. `None` when unauthored. + pub recursion: Option>, + /// The **geometry model** (`def.geometry_model`) as a loaded dependency: this emitter's + /// particles render as 3-D instances of it instead of billboard quads (wow-re + /// `part-model-particles.md`). `None` when unauthored. + pub geometry: Option>, +} + +/// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) → its +/// `mpq://…m2` load URL — the same extension remap the client's loader does (the physical +/// archive file is `.m2`). +fn m2_dep_url(raw: &str) -> String { + let p = raw.to_ascii_lowercase().replace('\\', "/"); + let stem = p + .strip_suffix(".mdx") + .or_else(|| p.strip_suffix(".mdl")) + .or_else(|| p.strip_suffix(".m2")) + .unwrap_or(&p); + format!("mpq://{stem}.m2") +} + +/// One ribbon emitter of an [`M2Model`]: the parsed [`benilla_formats::RibbonEmitterDef`] plus its +/// resolved trail texture and — like [`ModelEmitter::bone_pivot`] — the host bone's rest pivot, so +/// the spawn site can ride the bone's joint with `position − pivot` in the joint frame (the +/// reference transforms the bone-local position by the live bone matrix each frame, `0x718960`). +#[derive(Clone)] +pub struct ModelRibbon { + pub def: benilla_formats::RibbonEmitterDef, + pub texture: Option>, + pub bone_pivot: [f32; 3], +} + +/// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus — like +/// [`ModelEmitter::bone_pivot`] — its host bone's rest pivot, so a light on an ANIMATING model can +/// ride that bone's joint with `position − pivot` in the joint frame. The reference does exactly +/// this every frame (`0x718960` transforms the def position by the live bone matrix before +/// registering the light), which is how a torch's glow tracks the hand that swings it. +#[derive(Clone, Copy)] +pub struct ModelLight { + pub def: M2Light, + pub bone_pivot: [f32; 3], +} + +/// Bevy [`AssetLoader`] decoding `*.m2` → [`M2Model`]. +#[derive(Default, TypePath)] +pub struct M2ModelLoader; + +impl AssetLoader for M2ModelLoader { + type Asset = M2Model; + type Settings = (); + type Error = std::io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + ctx: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); + + // `dir` only feeds creature skin-variation resolution, which is inert here (we pass no skins — + // skins are applied at the spawn site from CreatureDisplayInfo), so an empty dir is correct. + let subs = parse_m2_render_submeshes(&bytes, "", &[]).map_err(to_io)?; + let bounds = parse_m2_bounds(&bytes).ok(); + // The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip + // hull-less props cheaply. + let collision = parse_m2_collision_hull(&bytes) + .ok() + .filter(|c| !c.is_empty()); + + let mut submeshes = Vec::with_capacity(subs.len()); + for (i, sub) in subs.iter().enumerate() { + let mesh = ctx.add_labeled_asset(format!("submesh{i}"), build_submesh_mesh(sub)); + // The skinned twin (decision 0019): same geometry + JOINT_INDEX/JOINT_WEIGHT. Built for + // every M2 (the loader can't know the model's role); only the creature path uses it, so a + // doodad/GameObject model carries an unused skinned mesh — a bounded interim cost (the first + // deferred optimisation is building this lazily, or unifying all M2s onto the skinned path). + let skinned_mesh = ctx.add_labeled_asset( + format!("submesh{i}_skinned"), + build_skinned_submesh_mesh(sub), + ); + // Embedded texture → an mpq:// dependency (WorldArt, the loader default). Lowercase the + // path: Bevy's loader lookup is case-sensitive, and these tables carry uppercase `.BLP` + // (the `BlpImageLoader` registers `blp`), so an uppercase extension silently falls back to + // type-based resolution — which is ambiguous with Bevy's built-in image loader and spams a + // "Multiple AssetLoaders found" warning per texture. Lowercasing also dedupes case variants. + let texture = sub.texture.as_deref().map(|t| { + ctx.load::(format!( + "mpq://{}", + t.replace('\\', "/").to_ascii_lowercase() + )) + }); + submeshes.push(ModelSubmesh { + mesh, + skinned_mesh, + texture, + skin_slot: sub.skin_slot, + geoset_id: sub.geoset_id, + char_slot: sub.char_slot, + blend: sub.blend, + two_sided: sub.two_sided, + interior: false, // M2 has no interior/exterior group concept + emissive: sub.emissive, // M2 UNLIT (0x01) glass/glow → fullbright + sidn: None, // WMO-only (MOMT SIDN night glow) + window: false, // WMO-only (MOMT WINDOW interior light) + additive: sub.additive, // M2 blend 3/4 → additive (glow cards) + no_depth_write: sub.no_depth_write, // M2 render flag 0x10 + no_depth_test: sub.no_depth_test, // M2 render flag 0x08 + fog_policy: sub.fog_policy, // M2 render flag 0x02 / the per-blend fog table + billboard: billboard_info(sub), // glow cards / chains face the camera + alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new), + uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new), + rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new), + wmo_batch: None, // M2 batches have no MOBA section + ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane + }); + } + // M2 light blocks (raw — wow-m2's light parse reads the wrong vanilla record shape). The spawn + // site filters to casting point lights ([`M2Light::casts`]). Empty for nearly all models. + // The bone-pivot bake is below, once the skeleton is parsed. + let light_defs = parse_m2_lights(&bytes); + + // The rest skeleton (decision 0019): bake the raw bone tree to Bevy-space joints + the matching + // inverse bind poses. A boneless model yields an empty skeleton + an empty bindpose set (the + // skinned mesh then carries no joint attributes, so it never takes the SKINNED path). + let skeleton_raw = parse_m2_skeleton(&bytes).unwrap_or_default(); + + // Particle emitters: resolve each emitter's texture to an `mpq://` image dependency (same + // lowercase normalisation as submesh albedos), and bake its host bone's pivot so the spawn + // site can ride a live joint (see [`ModelEmitter::bone_pivot`]). Absent on most models. + let emitters = parse_m2_particle_emitters(&bytes) + .unwrap_or_default() + .into_iter() + .map(|def| { + let texture = def.texture.as_deref().map(|t| { + ctx.load::(format!( + "mpq://{}", + t.replace('\\', "/").to_ascii_lowercase() + )) + }); + let bone_pivot = skeleton_raw + .bones + .get(def.bone as usize) + .map_or([0.0; 3], |b| b.pivot); + // The recursion/geometry models load as ordinary mpq:// M2 dependencies — + // children wiring and model-particle instancing happen app-side on resolve. + let recursion = def + .recursion_model + .as_deref() + .map(|p| ctx.load::(m2_dep_url(p))); + let geometry = def + .geometry_model + .as_deref() + .map(|p| ctx.load::(m2_dep_url(p))); + ModelEmitter { + def, + texture, + bone_pivot, + recursion, + geometry, + } + }) + .collect(); + + // Ribbon emitters (trails): same texture resolution + bone-pivot bake as the particles. + let ribbons = benilla_formats::parse_m2_ribbon_emitters(&bytes) + .unwrap_or_default() + .into_iter() + .map(|def| { + let texture = def.texture.as_deref().map(|t| { + ctx.load::(format!( + "mpq://{}", + t.replace('\\', "/").to_ascii_lowercase() + )) + }); + let bone_pivot = skeleton_raw + .bones + .get(def.bone as usize) + .map_or([0.0; 3], |b| b.pivot); + ModelRibbon { + def, + texture, + bone_pivot, + } + }) + .collect(); + + // M2 lights: the same host-bone pivot bake, so a light on an animating model (the torch in + // an NPC's hand) can ride its bone's joint instead of freezing at the rest-pose spot. + let lights = light_defs + .into_iter() + .map(|def| ModelLight { + bone_pivot: skeleton_raw + .bones + .get(def.bone as usize) + .map_or([0.0; 3], |b| b.pivot), + def, + }) + .collect(); + let (skeleton, inverse_bindposes) = build_skeleton(&skeleton_raw); + let inverse_bindposes = ctx.add_labeled_asset( + "inverse_bindposes".to_string(), + SkinnedMeshInverseBindposes::from(inverse_bindposes), + ); + + // The attachment-point table (decision 0072 — held items): the same bind-pose pivots + // `build_skeleton` used above, so a held item's offset is exactly consistent with the joint + // it's spawned under. Empty for a boneless/attachment-less model. + let attachments = build_attachments( + &parse_m2_attachments(&bytes).unwrap_or_default(), + &skeleton_pivots(&skeleton_raw), + ); + // The event-marker table, same bake (the missile-launch $CSL/$CSR/$CST/$BWR points). + let markers = crate::model::build_markers( + &benilla_formats::parse_m2_event_markers(&bytes).unwrap_or_default(), + &skeleton_pivots(&skeleton_raw), + ); + + // The animations (decision 0019): build each sequence's clip into one shared `AnimationGraph` + // (the bench scrubs them; gameplay plays Stand). All as labeled sub-assets. `None` unless at + // least one sequence produced an animated clip. + let sequences = parse_m2_animations(&bytes); + let first_seq_span = sequences.first().map(|a| a.duration).filter(|d| *d > 0.0); + let animations = { + let mut graph = AnimationGraph::new(); + let root = graph.root; + // Per-arm mask groups for the client's per-slot one-shots (the draw/stow plays each + // hand's ceremony on its own arm subtree, over the gait). Each arm's subtree root is the + // **shoulder-keybone ancestor of the hand attachment's bone** (attachment id 1 = right + // hand, 2 = left; keybones 2/3 = the shoulders — data-driven, no left/right guessing). + // Every joint *outside* an arm joins that arm's mask group, so a node masked with group + // 0 (or 1) animates only the right (or left) arm. + let arm_roots = arm_subtree_roots(&skeleton_raw, &attachments); + if let Some((right_root, left_root)) = arm_roots { + for i in 0..skeleton_raw.bones.len() { + let target = bone_target_id(i as u16); + if !in_subtree(&skeleton_raw, i, right_root) { + graph.add_target_to_mask_group(target, 0); + } + if !in_subtree(&skeleton_raw, i, left_root) { + graph.add_target_to_mask_group(target, 1); + } + } + } + // The upper-body one-shot mask group (decision 0087): group 2 collects every joint + // *outside* the SpineLow subtree (the legs + pelvis), so a clip masked with it animates + // only the upper body — the general form of the per-arm ceremony above, one subtree wider. + let upper_root = upper_subtree_root(&skeleton_raw); + if let Some(upper_root) = upper_root { + for i in 0..skeleton_raw.bones.len() { + if !in_subtree(&skeleton_raw, i, upper_root) { + graph.add_target_to_mask_group(bone_target_id(i as u16), 2); + } + } + } + // Per-hand **finger** mask groups (3 = right hand, 4 = left) for the weapon grip: a clip + // masked with group 3 (or 4) animates only that hand's finger key-bone subtrees, so the + // `HandsClosed` pose curls the fingers while the arm keeps its gait (wow-re + // `hand-grip-mechanism.md`). Empty per hand for a model with no finger key-bones (beasts). + let finger_roots = finger_subtree_roots(&skeleton_raw); + for i in 0..skeleton_raw.bones.len() { + let target = bone_target_id(i as u16); + if !finger_roots[0].is_empty() + && !finger_roots[0] + .iter() + .any(|&r| in_subtree(&skeleton_raw, i, r)) + { + graph.add_target_to_mask_group(target, 3); + } + if !finger_roots[1].is_empty() + && !finger_roots[1] + .iter() + .any(|&r| in_subtree(&skeleton_raw, i, r)) + { + graph.add_target_to_mask_group(target, 4); + } + } + let mut clips = Vec::new(); + // The doodad-animation content gate (decision 0130, `ModelAnimations::first_seq`): the + // file-order-first sequence's clip index, kept only when that sequence really MOVES a bone + // (>1 key on some track). A constant 1-key pose still builds a clip below, but looping it + // is visually the static mesh — the spawn site must not pay a skin + player for it. + let mut first_seq = None; + for (i, anim) in sequences.iter().enumerate() { + if let Some(clip) = build_animation_clip(anim, &skeleton) { + if i == 0 + && anim.bones.iter().any(|b| { + b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1 + }) + { + first_seq = Some(clips.len()); + } + let clip = ctx.add_labeled_asset(format!("clip{i}"), clip); + let node = graph.add_clip(clip.clone(), 1.0, root); + // The sheath family gets per-arm masked variants ([`AnimClip::arm_nodes`]). + let arm_nodes = + (matches!(anim.anim_id, 89 | 90) && arm_roots.is_some()).then(|| { + ( + graph.add_clip_with_mask(clip.clone(), 1 << 0, 1.0, root), + graph.add_clip_with_mask(clip.clone(), 1 << 1, 1.0, root), + ) + }); + // The upper-body masked variant ([`AnimClip::upper_node`]): the masked destination + // for a one-shot routed over a live base (swing/emote — the route is chosen per play + // in `creature_anim`). Built for every clip when the model has a split key-bone, so + // any one-shot id can take the masked route; `None` on the −1-sentinel models. + let upper_node = upper_root + .map(|_| graph.add_clip_with_mask(clip.clone(), 1 << 2, 1.0, root)); + clips.push(AnimClip { + anim_id: anim.anim_id, + node, + looping: anim.looping, + duration: anim.duration, + move_speed: anim.move_speed, + blend_time: anim.blend_time, + // Into the mesh vertices' frame, so the pick's world math is one transform. + bounds_center: wow_to_bevy(anim.bounds_center), + bounds_radius: anim.bounds_radius, + // The axis mapping flips signs, so min/max are re-derived componentwise. + bounds_min: wow_to_bevy(anim.bounds_min).min(wow_to_bevy(anim.bounds_max)), + bounds_max: wow_to_bevy(anim.bounds_min).max(wow_to_bevy(anim.bounds_max)), + events: anim.events.clone().into(), + arm_nodes, + upper_node, + frequency: anim.frequency, + replay: (anim.min_replay, anim.max_replay), + }); + } + } + // The per-hand weapon-grip nodes ([`ModelAnimations::hand_close`]): a purpose-built grip clip + // — each finger key-bone posed at its **clamped** `HandsClosed` value — masked to one hand's + // finger subtrees (group 3 = right, 4 = left). Built separately from the sequence clips above + // because the general in-band read drops the weapon-hand fingers (keyed off the HandsClosed + // frame); [`hand_grip_finger_poses`] clamps them, scoped to the grip (wow-re + // `hand-grip-mechanism.md`). `None` per hand for a model with no finger key-bones / no grip pose. + let mut hand_close: [Option; 2] = + [None, None]; + // Every bone in a finger subtree — the key-bone AND its child segments (the fingertip joints), + // so the whole finger curls, not just its base (HandsClosed keys the tips too, ~51° vs ~30°). + let finger_bones: Vec = (0..skeleton_raw.bones.len()) + .filter(|&i| { + finger_roots + .iter() + .flatten() + .any(|&r| in_subtree(&skeleton_raw, i, r)) + }) + .map(|i| i as u16) + .collect(); + let grip_poses = hand_grip_finger_poses(&bytes, &finger_bones); + if !grip_poses.is_empty() { + let grip = + ctx.add_labeled_asset("grip_clip".to_string(), build_grip_clip(&grip_poses)); + if !finger_roots[0].is_empty() { + hand_close[0] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 3, 1.0, root)); + } + if !finger_roots[1].is_empty() { + hand_close[1] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root)); + } + } + // The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2 + // header alongside the sequences above, so a model lacking a requested clip can resolve + // to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the + // creature driver silently doing nothing. + let playable_animation_lookup = + parse_m2_playable_animation_lookup(&bytes).unwrap_or_default(); + // Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose + // sequences produced no clips can still pulse/flicker on its free-running loops (the + // doodad `GlobalSeqOnly` tier, decision 0130) — dropping them because `clips` came up + // empty would silently freeze it. + let global_bones = + build_global_bones(&parse_m2_global_sequence_bones(&bytes), &skeleton); + (!clips.is_empty() || !global_bones.is_empty()).then(|| { + let graph = ctx.add_labeled_asset("anim_graph".to_string(), graph); + ModelAnimations { + graph, + clips, + playable_animation_lookup, + hand_close, + global_bones, + first_seq, + } + }) + }; + + // The authored portrait camera (raw WoW model space → Bevy, same pure rotation the meshes + // bake through, so eye/target land in exactly the submeshes' frame) — and the camera + // table's first record, the glue screens' `SetCamera(0)` rig. + let to_bevy = |c: benilla_formats::M2PortraitCamera| PortraitCamera { + eye: wow_to_bevy(c.position), + target: wow_to_bevy(c.target), + roll: c.roll, + fov: c.fov, + near: c.near_clip, + far: c.far_clip, + }; + let portrait_camera = parse_m2_portrait_camera(&bytes).map(to_bevy); + let camera0 = benilla_formats::parse_m2_camera(&bytes, 0).map(to_bevy); + + // The bowstring anchors (bows only): raw WoW positions → Bevy, the meshes' frame. + let string_anchors = benilla_formats::parse_m2_string_anchors(&bytes).map(|a| { + [ + (a.top.0, wow_to_bevy(a.top.1)), + (a.bottom.0, wow_to_bevy(a.bottom.1)), + ] + }); + + // The header GlobalModelFlags word sits at a fixed offset (MD20 magic @0, +0x10) — a + // bare scalar the sectioned parsers never touch. + let global_flags = bytes + .get(0x10..0x14) + .map_or(0, |b| u32::from_le_bytes(b.try_into().unwrap())); + + Ok(M2Model { + submeshes, + bounds, + collision, + emitters, + ribbons, + lights, + skeleton, + inverse_bindposes, + animations, + first_seq_span, + attachments, + markers, + portrait_camera, + camera0, + string_anchors, + global_flags, + }) + } + + fn extensions(&self) -> &[&str] { + &["m2"] + } +} diff --git a/crates/benilla-assets/src/minimap_grid.rs b/crates/benilla-assets/src/minimap_grid.rs new file mode 100644 index 00000000..d2c16c3c --- /dev/null +++ b/crates/benilla-assets/src/minimap_grid.rs @@ -0,0 +1,79 @@ +//! The WMO **interior minimap** tile grid: how one WMO group's world footprint subdivides into the +//! `___.blp` minimap tiles the client streams inside a building (decision 0203's +//! interior arc). Pure geometry — no Bevy — so the app renderer and the against-real-data test share +//! one source of truth. +//! +//! Mechanism (RE'd in `wow-5875-re`'s minimap node, tile producer in the CMapObj/WMO TU; the grid +//! **corrected + verified against Ironforge's authored tiles this session**, see +//! `ironforge_group_grid_matches_trs`): the bake is a fixed **0.5 yd/texel**, each tile a +//! power-of-two texel square clamped to `[32, 256]` px, sized to cover the group's footprint extent, +//! and the tiles tile across that extent. +//! +//! Both roundings go **up**: the binary's round-to-int (`FUN_0073fdf5`) loads a control word with +//! the RC field = round-toward-+∞, i.e. `ceil` — so the pixel edge is `2^ceil(log2(extent·2))` and +//! the count is `ceil(extent / 128)` (a full 256-px tile = 128 yd). The RE note first read these as +//! `floor`, which under-counts (group 1 → 0 columns, group 66 → 1×1); the byte-verified `ceil` +//! matches the authored data — root cause in `wmo-interior-minimap.md` (fold-back this session). + +/// The minimap tile bake resolution: world yards per texel (RE constant `0xca7ebc`). +pub const YD_PER_TEXEL: f32 = 0.5; + +/// Smallest / largest authored tile edge in texels (RE clamp; a full tile = 256 px = 128 yd). +const MIN_TILE_PX: u32 = 32; +const MAX_TILE_PX: u32 = 256; + +/// World yards a full (256-px) tile spans — the fixed divisor of the `ceil(extent/128)` tile count. +const TILE_SPAN_YD: f32 = MAX_TILE_PX as f32 * YD_PER_TEXEL; + +/// For one axis of a WMO group whose footprint `extent` (yards) is known: `(tile_count, tile_world)` +/// — how many minimap tiles span that axis, and the world size (yards) each tile covers (its stride). +/// The tile world size is the next-pow2 texel square (clamped) that covers `extent` at `0.5 yd/texel`; +/// the count is `ceil(extent / 128)`, both per the binary's round-toward-+∞. +/// +/// Verified against Ironforge: group 66 → 2×2, 44 → 1×2, 89 → 2×1, small groups → 1×1 +/// (`benilla-assets` test `ironforge_group_grid_matches_trs`). +pub fn group_axis_grid(extent: f32) -> (u32, f32) { + let texels = (extent / YD_PER_TEXEL).max(1.0); + let px = next_pow2(texels.ceil() as u32).clamp(MIN_TILE_PX, MAX_TILE_PX); + let tile_world = px as f32 * YD_PER_TEXEL; + let count = (extent / TILE_SPAN_YD).ceil().max(1.0) as u32; + (count, tile_world) +} + +/// Smallest power of two `>= n` (with `next_pow2(0) == 1`). +fn next_pow2(n: u32) -> u32 { + if n <= 1 { + 1 + } else { + 1u32 << (32 - (n - 1).leading_zeros()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn next_pow2_rounds_up() { + assert_eq!(next_pow2(1), 1); + assert_eq!(next_pow2(41), 64); + assert_eq!(next_pow2(64), 64); + assert_eq!(next_pow2(65), 128); + assert_eq!(next_pow2(372), 512); + } + + #[test] + fn grid_counts_and_sizes_match_ironforge_footprints() { + // Extents (yd) read off the real Ironforge groups; expected counts read off the trs tiles. + // grp 1 (20.6): 1 tile, sized 64 px = 32 yd. + assert_eq!(group_axis_grid(20.6), (1, 32.0)); + // grp 44 X (51.3): 1 tile of 128 px = 64 yd; Y (201.0): 2 tiles of 256 px = 128 yd. + assert_eq!(group_axis_grid(51.3), (1, 64.0)); + assert_eq!(group_axis_grid(201.0), (2, 128.0)); + // grp 66 (185.6): 2 tiles of 128 yd. + assert_eq!(group_axis_grid(185.6), (2, 128.0)); + // grp 89 X (128.4): just over one tile → 2; Y (107.7): 1. + assert_eq!(group_axis_grid(128.4).0, 2); + assert_eq!(group_axis_grid(107.7).0, 1); + } +} diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs new file mode 100644 index 00000000..cc1cf315 --- /dev/null +++ b/crates/benilla-assets/src/model.rs @@ -0,0 +1,773 @@ +//! Shared building blocks for the M2 and WMO model loaders: the per-batch submesh type and the +//! WoW→Bevy mesh bake. Both formats decode to a list of these (geometry + texture + blend/sidedness), +//! with materials deferred to the spawn site (see the loader module docs) so the assets stay +//! app-independent. + +use benilla_formats::{ + BillboardKind, BoneScaleAnim, CharSkinSlot, M2Attachment, ModelAnimation, ModelBlend, + RenderSubmesh, Skeleton, +}; +use bevy::animation::animatable::Animatable; +use bevy::animation::animation_curves::{AnimatableCurve, AnimatableKeyframeCurve}; +use bevy::animation::{animated_field, AnimationClip, AnimationTargetId}; +use bevy::asset::RenderAssetUsages; +use bevy::math::Mat3; +use bevy::mesh::{Indices, PrimitiveTopology, VertexAttributeValues}; +use bevy::prelude::*; + +use crate::coords::wow_to_bevy; + +mod anims; +// `PlayableAnim`/`ResolvedAnim` aren't re-exported further by `lib.rs` (nothing outside this crate +// names them yet), so rustc can't see this re-export escape — allow silences the resulting +// unused-import false positive on an otherwise-live facade re-export. +#[allow(unused_imports)] +pub use anims::{AnimClip, ModelAnimations, PlayableAnim, ResolvedAnim}; + +/// A billboarded submesh's render data (Bevy space): the bone pivot the card rotates about (model-local +/// — compose with the instance transform), the card's resting plane normal, and the billboard kind. +/// The mesh is built **centred at `pivot`** so the spawn site can rotate it about the pivot to face the +/// camera each frame. +#[derive(Clone)] +pub struct BillboardInfo { + pub pivot: Vec3, + /// The billboard bone's index — the joint the card rides on an animated host (swinging lamp, + /// mount lights). On a rest-pose host the pivot alone places the card. + pub bone: u16, + pub normal: Vec3, + pub kind: BillboardKind, + /// The billboard bone's global-sequence scale animation (the looping glow-card pulse), if any — + /// sampled per-frame at the spawn site and folded into the card scale. `None` for a static card. + pub scale_anim: Option, + /// The bone's per-sequence translation loops `(anim id, loop)`, keys baked to **Bevy axes** — + /// a model-local offset the card adds at its pivot when the spawn site arms one. Anim 0 is the + /// load arm's default (the questgiver `?` marker's low bob); anim 190 the marker's raised bob, + /// armed while the unit shows an overhead name (only the marker arms these today; see + /// `benilla_formats::Billboard::seq_translations`). + pub seq_translations: Vec<(u16, BoneScaleAnim)>, +} + +/// One render batch of a loaded model (an M2 batch or a WMO group batch): geometry as a Bevy `Mesh` +/// sub-asset, the batch's albedo texture (WorldArt), and its blend/sidedness. Carries handles + +/// metadata only — the material is built at spawn (an app concern). +#[derive(Clone)] +pub struct ModelSubmesh { + /// Geometry, baked to Bevy space (a labeled sub-asset of the model). The **static** mesh — no + /// joint attributes — used by doodads/GameObjects (which never skin). + pub mesh: Handle, + /// The **skinned** twin of [`Self::mesh`] (decision 0019): the same geometry plus the per-vertex + /// `JOINT_INDEX`/`JOINT_WEIGHT` attributes, so Bevy's mesh pipeline takes the `SKINNED` path. Used + /// only by the skinned creature path (which also attaches a `SkinnedMesh` + joint hierarchy); a + /// static instance must use [`Self::mesh`] instead, since a skinned-layout mesh with no + /// `SkinnedMesh` component indexes the joint buffer out of bounds (garbage, not bind pose). + pub skinned_mesh: Handle, + /// Embedded albedo texture, if any. `None` for creature skin slots (filled at spawn from the + /// display's skin variation — see [`Self::skin_slot`]). + pub texture: Option>, + /// The creature skin variation this batch draws from (`Some(0/1/2)` ⇒ `Monster1/2/3`) when its + /// [`Self::texture`] is `None` — so the entity spawn site can fill it from `CreatureDisplayInfo`. + /// `None` for batches with their own embedded texture (all doodads/WMOs; most M2 batches). + pub skin_slot: Option, + /// The batch's `skinSectionId` (geoset / mesh-part ID, see + /// [`benilla_formats::RenderSubmesh::geoset_id`]) — `group*100 + variant`. The character compositor + /// selects which geosets a player shows by this; `0` for a single-geoset model (creatures/doodads). + pub geoset_id: u16, + /// The character runtime texture slot this batch carries (see + /// [`benilla_formats::RenderSubmesh::char_slot`]) — the spawn site fills its texture per-player from + /// the appearance (decisions 0041 / 0044 / 0045). `None` for everything with its own texture. + pub char_slot: Option, + pub blend: ModelBlend, + pub two_sided: bool, + /// This batch is a WMO **interior** group (see [`benilla_formats::RenderSubmesh::interior`]) — lit + /// by its baked MOCV with the directional sun off. `false` for M2 doodads and exterior WMO groups. + pub interior: bool, + /// This batch is unlit (see [`benilla_formats::RenderSubmesh::emissive`]) — rendered fullbright: + /// M2 `UNLIT (0x01)` glass/glow, or WMO `UNLIT` on an exterior-group batch. `false` otherwise. + pub emissive: bool, + /// The WMO MOMT **SIDN** night-glow colour (see [`benilla_formats::RenderSubmesh::sidn`]) — the + /// authored emissive RGB the shader ramps by the night fraction on lit lanes. `None` for M2. + pub sidn: Option<[u8; 3]>, + /// WMO MOMT **WINDOW** (see [`benilla_formats::RenderSubmesh::window`]) — an interior-group + /// batch takes the brighter Direct/Ambient-midpoint light. `false` for M2. + pub window: bool, + /// This batch blends **additively** (M2 glow cards / coronae — see + /// [`benilla_formats::RenderSubmesh::additive`]): the spawn site builds it with `AlphaMode::Add` so + /// its warm colour is added on top of the scene, not mixed with the background. `false` otherwise. + pub additive: bool, + /// M2 render flag **0x10 — disable depth write** (see [`benilla_formats::RenderSubmesh::no_depth_write`]): + /// `specialize` keeps depth-write ON for transparent batches *unless* this is set, matching the real + /// client (so a model's transparent cards occlude each other instead of bleeding through). `false` for WMO. + pub no_depth_write: bool, + /// M2 render flag **0x08 — disable depth test** (drawn over everything). `false` for WMO. + pub no_depth_test: bool, + /// The batch's fog COLOUR policy (see [`benilla_formats::RenderSubmesh::fog_policy`]) — `Scene` + /// for WMO and every non-M2 batch. + pub fog_policy: benilla_formats::FogPolicy, + /// Set when this batch rides an M2 billboard bone (glow cards, chains): the spawn site faces it to + /// the camera each frame. The [`Self::mesh`] is built centred at the pivot so it rotates in place. + pub billboard: Option, + /// The batch's **animated material alpha** (decision 0130 phase 2, wow-re `m2-alpha-combine-cull`): + /// time-varying colour-alpha/transparency-weight loops (or a dimming constant) the doodad spawn + /// site samples per instance into the render-alpha channel. `None` for the overwhelming majority + /// (both factors static-1 or statically culled). `Arc` keeps the submesh clone cheap. + pub alpha_anim: Option>, + /// The batch's **UV-animation** loop (decision 0130 phase 3, wow-re `m2-texanim-uv`): the + /// texture transform's translation track — sampled per frame into the batch material's UV + /// offset (flowing waterfalls, scrolling energy). `None` for the ~98.6% of models with no + /// texture transform and all WMO batches. The `Arc` doubles as the material-dedup identity: + /// every instance of a loaded model shares this allocation. + pub uv_anim: Option>, + /// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell + /// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped + /// at parse (`benilla-formats`): the render side seeds the material's tint at the first key — + /// pixel-identical to the old static bake — and the material-animating lanes tick it per + /// frame. `None` for constant/keyless tints (the vertex bake). + pub rgb_anim: Option>, + /// The batch's MOBA section for WMO group batches ([`benilla_formats::WmoBatchClass`] — an + /// interior group's per-batch lighting law: INT = unlit `tex × MOCV`, TRANS = the MOCV-alpha + /// lit↔bake lerp, EXT = the exterior day/night law). `None` for every M2 batch. + pub wmo_batch: Option, + /// The batch's flat **ground-plane quad** shape ([`benilla_formats::GroundQuad`], detected at + /// load — corners stay in WoW model space; consumers map through `coords::wow_to_bevy`). The + /// ground-fx decal lane (`benilla::ground_fx`) re-renders such parts of a base-anchored spell + /// effect as projected surface decals. `None` for ordinary geometry and all WMO batches. + pub ground_quad: Option, +} + +/// Turn a [`RenderSubmesh`] (model space) into a Bevy [`Mesh`] baked to Bevy space — decision 0002, +/// applied here at the render boundary. Prefers the authored normals (soft, outward — how WoW lights +/// foliage); recomputes flat ones only when absent. WMO per-vertex MOCV colour is folded in when +/// present (M2 has none). The WoW→Bevy map is a pure rotation, so it applies to normals too. +pub(crate) fn build_submesh_mesh(sub: &RenderSubmesh) -> Mesh { + // A billboard batch is built centred at its bone pivot, so the spawn site can rotate it about that + // point to face the camera (the entity transform then re-places it at the pivot in the world). + let center = sub + .billboard + .as_ref() + .map_or(Vec3::ZERO, |b| wow_to_bevy(b.pivot)); + let positions: Vec<[f32; 3]> = sub + .positions + .iter() + .map(|p| (wow_to_bevy(*p) - center).to_array()) + .collect(); + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); + mesh.insert_indices(Indices::U32(sub.indices.clone())); + if sub.vertex_colors.len() == sub.positions.len() { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); + } + if sub.normals.len() == sub.positions.len() { + let normals: Vec<[f32; 3]> = sub + .normals + .iter() + .map(|n| wow_to_bevy(*n).to_array()) + .collect(); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + } else { + mesh.compute_normals(); + } + mesh +} + +/// Build the **skinned** twin of [`build_submesh_mesh`]: the same baked geometry plus the per-vertex +/// `JOINT_INDEX` (`Uint16x4` — M2 bone indices, used directly as joint indices) + `JOINT_WEIGHT` +/// (`Float32x4`, normalised). These two attributes are the entire trigger for Bevy's `SKINNED` shader +/// path (decision 0019). When the submesh carries no skin (WMO / a boneless batch) this is identical +/// to the static mesh — harmless, but the creature path is the only consumer regardless. +pub(crate) fn build_skinned_submesh_mesh(sub: &RenderSubmesh) -> Mesh { + let mut mesh = build_submesh_mesh(sub); + if sub.joints.len() == sub.positions.len() && !sub.joints.is_empty() { + mesh.insert_attribute( + Mesh::ATTRIBUTE_JOINT_INDEX, + VertexAttributeValues::Uint16x4(sub.joints.clone()), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_JOINT_WEIGHT, + VertexAttributeValues::Float32x4(sub.weights.clone()), + ); + } + mesh +} + +/// One joint of a model's rest skeleton, baked to Bevy space (decision 0019): its parent joint index +/// (`-1` = root → parented to the entity) and its **local** rest translation. The skinned creature +/// path spawns one entity per joint carrying this translation; animation later drives each joint's TRS. +#[derive(Clone, Copy)] +pub struct ModelJoint { + pub parent: i16, + pub local_translation: Vec3, + /// The bone's billboard arm, if authored (M2 bone flags `0x08/0x10/0x20/0x40`). The byte law + /// replaces the billboarded bone's rotation IN THE PALETTE, so children — and any geometry + /// skinned to them — inherit the camera facing (the frost-armor sheets skin to the scale-in + /// CHILD of a lock-Z bone). Rigged hosts feed this to the billboard joint pass; the per-batch + /// card split only covers geometry skinned to the billboard bone itself. + pub billboard: Option, + /// Bone flag `0x04` (see [`benilla_formats::SkeletonBone::ignore_parent_rotation`]): the bone + /// keeps the MODEL ROOT's orientation — its pivot rides the parent's full matrix, its rotation + /// doesn't. Same palette pass as `billboard`, different replacement rotation. + pub ignore_parent_rotation: bool, +} + +/// A model's rest skeleton in Bevy space (decision 0019). Built once per M2 asset; the creature path +/// spawns a joint-entity hierarchy per instance from `joints` and shares the matching inverse bind +/// poses ([`build_skeleton`]'s second return) across instances. +#[derive(Clone, Default)] +pub struct ModelSkeleton { + pub joints: Vec, + /// The bone whose `KeyBoneID == 4` (SpineLow) — the client's display-facing counter-twist spine + /// channel (`0x711f10(4, …)` in the `0x607ed0` body-facing tail): while a unit's rendered root + /// yaw is offset from its aim (a strafe), this subtree counter-rotates back toward the aim. + /// `None` when the model lacks the key-bone (beasts, props). + pub spine_bone: Option, + /// The bone whose `KeyBoneID == 6` (Head) — the twist's head channel (`0x711f10(6, …)`), taking + /// the gap the spine channel leaves so the head lands back on the aim. `None` when absent. + pub head_bone: Option, +} + +/// Bake a raw [`Skeleton`] into the Bevy-space [`ModelSkeleton`] + its inverse-bind-pose matrices. +/// +/// Verified rig math (wow-5875-re: vanilla M2 has no inverse-bind array, rest pose is identity TRS, +/// the **pivot encodes bind position**) composed with Bevy's joint formula +/// (`joint_matrix = joint_global · inverse_bindpose`, which *becomes* `world_from_local`): +/// - **inverse bind pose** `i = translate(−pivot_i)` (model→bone-local at bind), +/// - **joint rest-local translation** = `pivot_i − pivot_parent` (pure translations, so they telescope +/// to `pivot_i` up the chain). At rest every joint matrix collapses to the entity transform, so the +/// creature renders exactly where the static mesh did, undeformed — even with a scaled entity +/// transform (the two pivot translations cancel before scale acts). +/// +/// Pivots map WoW→Bevy via [`wow_to_bevy`] (a pure rotation), so the whole skeleton lives in mesh space. +pub(crate) fn build_skeleton(skel: &Skeleton) -> (ModelSkeleton, Vec) { + let pivots = skeleton_pivots(skel); + let joints = skel + .bones + .iter() + .enumerate() + .map(|(i, b)| { + let parent_pivot = usize::try_from(b.parent) + .ok() + .and_then(|p| pivots.get(p).copied()) + .unwrap_or(Vec3::ZERO); + ModelJoint { + parent: b.parent, + local_translation: pivots[i] - parent_pivot, + billboard: b.billboard, + ignore_parent_rotation: b.ignore_parent_rotation, + } + }) + .collect(); + let inverse_bindposes = pivots.iter().map(|p| Mat4::from_translation(-*p)).collect(); + // A bone carries KeyBoneID `k` iff `keyBoneLookup[k]` points at it (same rule as + // [`upper_subtree_root`]); 4 = SpineLow, 6 = Head — the display-facing twist channels. + let key_bone = |k: i16| { + skel.bones + .iter() + .position(|b| b.key_bone == k) + .map(|i| i as u16) + }; + ( + ModelSkeleton { + joints, + spine_bone: key_bone(4), + head_bone: key_bone(6), + }, + inverse_bindposes, + ) +} + +/// Each bone's Bevy-space bind-pose pivot (`wow_to_bevy(bone.pivot)`), in file order — the one +/// source [`build_skeleton`] (inverse bind poses) and [`build_attachments`] (attach-point offsets) +/// both derive from, so the two never compute a divergent pivot. +pub(crate) fn skeleton_pivots(skel: &Skeleton) -> Vec { + skel.bones.iter().map(|b| wow_to_bevy(b.pivot)).collect() +} + +/// One M2 attachment point (decision 0072 — held items), baked to a **Bevy-space bone-local +/// offset**: a child spawned under the bone's joint entity at `Transform::from_translation(offset)` +/// sits exactly at the attach point at bind pose and rides the bone's animation thereafter. +/// `offset` is `≈ Vec3::ZERO` on character models (the attach bones are leaves sitting exactly at +/// the attach point — VERIFIED, direct dump of `HumanMale.m2`, `decisions/0072`). +#[derive(Clone, Copy)] +pub struct ModelAttachment { + pub id: u16, + pub bone: u16, + pub offset: Vec3, +} + +/// The right/left **arm subtree roots** for the per-arm animation masks: each hand attachment's +/// (id 1 = right, id 2 = left) bone walked up to its shoulder-keybone ancestor (keybones 2/3 — +/// either; the walk just finds "the shoulder above this hand", so no left/right convention is +/// assumed). `None` when the model lacks either hand attachment or a shoulder ancestor (beasts, +/// props — they never play per-slot one-shots). +pub(crate) fn arm_subtree_roots( + skel: &Skeleton, + attachments: &[ModelAttachment], +) -> Option<(usize, usize)> { + let shoulder_of = |hand_attach_id: u16| -> Option { + let mut bone = attachments + .iter() + .find(|a| a.id == hand_attach_id) + .map(|a| a.bone as usize)?; + loop { + let b = skel.bones.get(bone)?; + if matches!(b.key_bone, 2 | 3) { + return Some(bone); + } + bone = usize::try_from(b.parent).ok()?; + } + }; + Some((shoulder_of(1)?, shoulder_of(2)?)) +} + +/// The model's **upper-body split key-bone** subtree root — the client's `CGUnit+0xd5c`, selected by +/// the capability probe `0x60ce70` (`keyBoneLookup[4]` preferred, `[6]` fallback, else the −1 +/// sentinel = no split). On HumanMale `keyBoneLookup[4]` is bone 20 (SpineLow: chest/shoulders/arms/ +/// hands/head — a subtree that provably **excludes** the legs, which hang off the pelvis sibling +/// bone 21), so a clip masked to this subtree moves the upper body only (wow-re +/// `anim-composition-model.md` §1, `923ac7bc`). A bone carries KeyBoneID `k` iff `keyBoneLookup[k]` +/// points at it, so the SpineLow bone is the one whose `key_bone == 4` (else `== 6`). `None` when the +/// model has neither (the −1 sentinel) — the one-shot route then falls back to full-body. +pub(crate) fn upper_subtree_root(skel: &Skeleton) -> Option { + let bone_with = |k: i16| skel.bones.iter().position(|b| b.key_bone == k); + bone_with(4).or_else(|| bone_with(6)) +} + +/// The model's **finger key-bone subtree roots** per hand `(right, left)` — the client's `CloseHand` +/// targets. WoW rigs each hand's fingers under **key-bones 8–12 (mainhand/right)** and **13–17 (offhand/ +/// left)**; the grip pose (`HandsClosed`, AnimationData 15) is armed on exactly these when a weapon is +/// held in that hand (wow-re `hand-grip-mechanism.md`: `0x479660`/`0x60b590`). Only the *rigged* ones +/// exist (HumanMale carries 11/12 and 16/17; the rest are the −1 sentinel), so each entry is the set of +/// bones actually carrying a finger key-bone in that hand's range — **empty** for a model with no finger +/// key-bones (beasts/props), which never grip. +pub(crate) fn finger_subtree_roots(skel: &Skeleton) -> [Vec; 2] { + let roots = |lo: i16, hi: i16| -> Vec { + skel.bones + .iter() + .enumerate() + .filter(|(_, b)| (lo..=hi).contains(&b.key_bone)) + .map(|(i, _)| i) + .collect() + }; + [roots(8, 12), roots(13, 17)] +} + +/// Whether bone `i` is `root` or one of its descendants (a parent-chain walk). +pub(crate) fn in_subtree(skel: &Skeleton, i: usize, root: usize) -> bool { + let mut cur = i; + loop { + if cur == root { + return true; + } + match skel + .bones + .get(cur) + .and_then(|b| usize::try_from(b.parent).ok()) + { + Some(p) => cur = p, + None => return false, + } + } +} + +/// Bake the raw [`M2Attachment`] table into [`ModelAttachment`]s: `offset = wow_to_bevy(position) − +/// pivot_bevy(bone)`, the bone-local offset from the joint's bind position. Records whose `bone` +/// indexes past `pivots` are dropped (defence in depth — `benilla-m2` already range-checks against +/// its own bone array; this guards against the skeleton/attachment parses ever disagreeing). +pub(crate) fn build_attachments( + attachments: &[M2Attachment], + pivots: &[Vec3], +) -> Vec { + attachments + .iter() + .filter_map(|a| { + let pivot = pivots.get(a.bone as usize)?; + Some(ModelAttachment { + id: a.id, + bone: a.bone, + offset: wow_to_bevy(a.position) - *pivot, + }) + }) + .collect() +} + +/// One M2 animation-event **positional marker**, baked exactly like [`ModelAttachment`] (same +/// bone-local Bevy-space offset convention — a consumer transforms it by the bone joint's live +/// global). The client's by-4CC position queries (`0x7130e0`/`0x7131b0`) read this table first +/// match: the missile launch points `$CSL`/`$CSR`/`$CST` (casting hand) and `$BWR` (ranged +/// release) are the consumers here. +#[derive(Clone, Copy)] +pub struct ModelMarker { + /// The identifier 4CC, stored forward (`*b"$CSL"`). + pub ident: [u8; 4], + pub bone: u16, + pub offset: Vec3, +} + +/// Bake the raw [`benilla_formats::EventMarker`] table into [`ModelMarker`]s — the same +/// `offset = wow_to_bevy(position) − pivot_bevy(bone)` bake and the same defence-in-depth bone +/// range check as [`build_attachments`]. File order is preserved (queries take the first ident +/// match, the client's scan order). +pub(crate) fn build_markers( + markers: &[benilla_formats::EventMarker], + pivots: &[Vec3], +) -> Vec { + markers + .iter() + .filter_map(|m| { + let pivot = pivots.get(m.bone as usize)?; + Some(ModelMarker { + ident: m.ident, + bone: m.bone, + offset: wow_to_bevy(m.position) - *pivot, + }) + }) + .collect() +} + +/// A stable [`AnimationTargetId`] for bone index `bone` (decision 0019). The idle clip's curves and the +/// joint entities' target ids both derive from the bone index (via a synthetic name), so they match +/// without a real `Name` hierarchy. Scoped to one creature's joints + its shared clip. +pub fn bone_target_id(bone: u16) -> AnimationTargetId { + AnimationTargetId::from_name(&Name::new(format!("benilla_bone_{bone}"))) +} + +/// The WoW→Bevy basis change as a quaternion. DR 0002's map is a proper rotation (det +1); a bone +/// rotation track is a WoW-space quat, and a rotation transforms under a basis change by **conjugation** +/// `r·q·r⁻¹`, so this `r` is what [`build_idle_clip`] conjugates each rotation keyframe by. Derived from +/// `wow_to_bevy` itself (its images of the basis vectors are `R`'s columns) so it can't drift from it. +fn wow_to_bevy_quat() -> Quat { + Quat::from_mat3(&Mat3::from_cols( + wow_to_bevy([1.0, 0.0, 0.0]), + wow_to_bevy([0.0, 1.0, 0.0]), + wow_to_bevy([0.0, 0.0, 1.0]), + )) +} + +/// Build a keyframe curve, handling Bevy's ≥2-sample requirement: a single key (a constant channel) +/// becomes a flat curve spanning `[0, duration]`. `None` for no keys (the channel stays at rest). +fn keyframe_curve( + duration: f32, + keys: Vec<(f32, T)>, +) -> Option> { + match keys.len() { + 0 => None, + 1 => AnimatableKeyframeCurve::new([ + (0.0, keys[0].1.clone()), + (duration.max(1e-3), keys[0].1.clone()), + ]) + .ok(), + _ => AnimatableKeyframeCurve::new(keys).ok(), + } +} + +/// Build one sequence's [`AnimationClip`] from parsed raw-WoW keyframes (decision 0019), transforming +/// each channel into the joints' Bevy-space `Transform`: +/// - **translation** = the bone's rest local (`pivot − pivot_parent`) **plus** `wow_to_bevy(track)` — +/// the M2 translation track is a delta on the pivot offset; +/// - **rotation** = the WoW quat conjugated into Bevy space (`r·q·r⁻¹`); +/// - **scale** = the WoW scale with axes permuted to Bevy's (magnitudes; near-always uniform anyway). +/// +/// `None` if no channel produced a curve (every bone holds bind pose → nothing worth playing). +pub(crate) fn build_animation_clip( + anim: &ModelAnimation, + skeleton: &ModelSkeleton, +) -> Option { + let r = wow_to_bevy_quat(); + let mut clip = AnimationClip::default(); + let mut any = false; + for bk in &anim.bones { + let target = bone_target_id(bk.bone); + let rest = skeleton + .joints + .get(bk.bone as usize) + .map_or(Vec3::ZERO, |j| j.local_translation); + let trans: Vec<_> = bk + .translation + .iter() + .map(|(t, v)| (*t, rest + wow_to_bevy(*v))) + .collect(); + if let Some(c) = keyframe_curve(anim.duration, trans) { + clip.add_curve_to_target( + target, + AnimatableCurve::new(animated_field!(Transform::translation), c), + ); + any = true; + } + let rot: Vec<_> = bk + .rotation + .iter() + .map(|(t, q)| { + ( + *t, + r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(), + ) + }) + .collect(); + if let Some(c) = keyframe_curve(anim.duration, rot) { + clip.add_curve_to_target( + target, + AnimatableCurve::new(animated_field!(Transform::rotation), c), + ); + any = true; + } + let scale: Vec<_> = bk + .scale + .iter() + .map(|(t, s)| (*t, Vec3::new(s[1], s[2], s[0]))) + .collect(); + if let Some(c) = keyframe_curve(anim.duration, scale) { + clip.add_curve_to_target( + target, + AnimatableCurve::new(animated_field!(Transform::scale), c), + ); + any = true; + } + } + any.then_some(clip) +} + +/// Build the weapon-grip **overlay clip** from clamped finger poses ([`benilla_formats::hand_grip_finger_poses`]): +/// a single constant rotation key per finger key-bone, conjugated into Bevy space with the same `r·q·r⁻¹` +/// as [`build_animation_clip`]. Played masked to one hand's finger subtrees, it holds those fingers curled +/// over the gait (rotation only — the fingers don't translate). Empty `poses` → an empty clip (the caller +/// builds no grip node). +pub(crate) fn build_grip_clip(poses: &[(u16, [f32; 4])]) -> AnimationClip { + let r = wow_to_bevy_quat(); + let mut clip = AnimationClip::default(); + for &(bone, q) in poses { + let rot = r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(); + if let Some(c) = keyframe_curve(0.033, vec![(0.0, rot)]) { + clip.add_curve_to_target( + bone_target_id(bone), + AnimatableCurve::new(animated_field!(Transform::rotation), c), + ); + } + } + clip +} + +/// The [`BillboardInfo`] for a submesh that rides a billboard bone (Bevy space): the pivot to rotate +/// about and the card's resting plane normal (from its first triangle — robust to absent authored +/// normals). `None` for ordinary geometry. Pairs with [`build_submesh_mesh`], which centres the mesh. +pub(crate) fn billboard_info(sub: &RenderSubmesh) -> Option { + let bb = sub.billboard.as_ref()?; + let p = |i: u32| wow_to_bevy(sub.positions[i as usize]); + let normal = sub + .indices + .chunks_exact(3) + .find_map(|t| (p(t[1]) - p(t[0])).cross(p(t[2]) - p(t[0])).try_normalize()) + .unwrap_or(Vec3::Z); + Some(BillboardInfo { + pivot: wow_to_bevy(bb.pivot), + bone: bb.bone, + normal, + kind: bb.kind, + scale_anim: bb.scale_anim.clone(), + // Translation values are model-space offsets — bake each key to Bevy axes here (scale keys + // above stay raw: per-axis factors, not vectors). + seq_translations: bb + .seq_translations + .iter() + .map(|(id, a)| { + let mut a = a.clone(); + for (_, v) in &mut a.keys { + *v = wow_to_bevy(*v).to_array(); + } + (*id, a) + }) + .collect(), + }) +} + +/// A global-sequence bone channel baked to Bevy space: the free-clock loop the runtime samples at +/// `(model_time mod period)` and writes onto the joint. `period` and key times are **seconds**; values +/// are already in the joints' Bevy `Transform` frame. See [`GlobalBone`]. +#[derive(Clone)] +pub struct GlobalSeqChannel { + pub period: f32, + pub keys: Vec<(f32, T)>, +} + +impl GlobalSeqChannel { + /// The two keys bracketing `t` (wrapped into `[0, period]`) and the fraction between them. Clamps to + /// the end keys (WoW authors the first/last key at the loop endpoints, so there is no wrap gap). + fn bracket(&self, t: f32) -> (T, T, f32) { + let period = self.period.max(1e-3); + let t = t.rem_euclid(period); + let keys = &self.keys; + if t <= keys[0].0 { + return (keys[0].1, keys[0].1, 0.0); + } + for w in keys.windows(2) { + if t <= w[1].0 { + let span = (w[1].0 - w[0].0).max(1e-6); + return (w[0].1, w[1].1, (t - w[0].0) / span); + } + } + let last = keys[keys.len() - 1].1; + (last, last, 0.0) + } +} + +impl GlobalSeqChannel { + pub fn sample(&self, t: f32) -> Vec3 { + let (a, b, f) = self.bracket(t); + a.lerp(b, f) + } +} + +impl GlobalSeqChannel { + pub fn sample(&self, t: f32) -> Quat { + let (a, b, f) = self.bracket(t); + a.slerp(b, f) + } +} + +/// A bone's global-sequence channels baked to Bevy space (see [`GlobalSeqChannel`]) — any of +/// translation / rotation / scale, each on its own free clock. The runtime writes only the driven +/// components onto the bone's joint entity, leaving the rest to the playing animation. +#[derive(Clone)] +pub struct GlobalBone { + pub bone: u16, + pub translation: Option>, + pub rotation: Option>, + pub scale: Option>, +} + +/// Bake the raw [`benilla_formats::GlobalSeqBone`] channels into Bevy-space [`GlobalBone`]s, applying +/// the same per-channel transforms as [`build_animation_clip`]: translation = the bone's rest-local +/// (`pivot − pivot_parent`) plus `wow_to_bevy(track)`; rotation conjugated `r·q·r⁻¹`; scale axis-permuted +/// to Bevy's. Times/periods convert ms → seconds. +pub(crate) fn build_global_bones( + gseq: &[benilla_formats::GlobalSeqBone], + skeleton: &ModelSkeleton, +) -> Vec { + let r = wow_to_bevy_quat(); + gseq.iter() + .map(|g| { + let rest = skeleton + .joints + .get(g.bone as usize) + .map_or(Vec3::ZERO, |j| j.local_translation); + let ms = |t: u32| t as f32 / 1000.0; + GlobalBone { + bone: g.bone, + translation: g.translation.as_ref().map(|c| GlobalSeqChannel { + period: ms(c.period_ms), + keys: c + .keys + .iter() + .map(|(t, v)| (ms(*t), rest + wow_to_bevy(*v))) + .collect(), + }), + rotation: g.rotation.as_ref().map(|c| GlobalSeqChannel { + period: ms(c.period_ms), + keys: c + .keys + .iter() + .map(|(t, q)| { + ( + ms(*t), + r * Quat::from_xyzw(q[0], q[1], q[2], q[3]) * r.inverse(), + ) + }) + .collect(), + }), + scale: g.scale.as_ref().map(|c| GlobalSeqChannel { + period: ms(c.period_ms), + keys: c + .keys + .iter() + .map(|(t, s)| (ms(*t), Vec3::new(s[1], s[2], s[0]))) + .collect(), + }), + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A [`GlobalSeqChannel`] samples with linear interpolation, clamps at the endpoint keys, and wraps + /// on its period — the eye-blink shape: `0` (open) held, a fast ramp to `1` (shut), back to `0`, and + /// the whole thing repeating every `period`. Models the real eyelid keys (ms → seconds). + #[test] + fn global_seq_channel_samples_and_wraps() { + let ch = GlobalSeqChannel { + period: 6.633, + keys: vec![ + (0.0, Vec3::ZERO), + (0.033, Vec3::ONE), + (0.100, Vec3::ONE), + (0.133, Vec3::ZERO), + ], + }; + assert!( + ch.sample(0.0).abs_diff_eq(Vec3::ZERO, 1e-4), + "loop start = open" + ); + assert!( + ch.sample(0.06).abs_diff_eq(Vec3::ONE, 1e-4), + "shut during the blink" + ); + assert!( + ch.sample(3.0).abs_diff_eq(Vec3::ZERO, 1e-4), + "held open for the rest of the loop" + ); + assert!( + ch.sample(0.0165).abs_diff_eq(Vec3::splat(0.5), 1e-2), + "linear ramp" + ); + assert!( + ch.sample(6.633 + 0.06).abs_diff_eq(Vec3::ONE, 1e-4), + "wraps modulo period" + ); + } + + /// The WoW→Bevy rotation quat must reproduce `wow_to_bevy` on vectors — it's the same rotation, so + /// `r·v` equals the coordinate map. (Guards against `wow_to_bevy_quat` drifting from `wow_to_bevy`.) + #[test] + fn rotation_quat_matches_the_coordinate_map() { + let r = wow_to_bevy_quat(); + for axis in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] { + let by_quat = r * Vec3::from_array(axis); + let by_map = wow_to_bevy(axis); + assert!( + by_quat.abs_diff_eq(by_map, 1e-5), + "r·{axis:?} = {by_quat:?} should equal wow_to_bevy = {by_map:?}" + ); + } + } + + /// A bone rotation about WoW **up** (+Z) must conjugate to a rotation about Bevy **up** (+Y) by the + /// same angle — the load-bearing check for the `r·q·r⁻¹` conjugation + the `[x,y,z,w]` quat order. + /// (A wrong conjugation/sign/order is exactly the "plausible but twisted creature" failure.) + #[test] + fn wow_up_yaw_conjugates_to_bevy_up_yaw() { + let r = wow_to_bevy_quat(); + let theta = std::f32::consts::FRAC_PI_3; // 60°, an asymmetric angle + let (s, c) = (theta / 2.0).sin_cos(); + // A WoW quat for a yaw about +Z: (x,y,z,w) = (0,0,sin,cos). + let q_wow = Quat::from_xyzw(0.0, 0.0, s, c); + let q_bevy = r * q_wow * r.inverse(); + let expected = Quat::from_axis_angle(Vec3::Y, theta); + // Quats equal up to sign: |dot| ≈ 1. + assert!( + q_bevy.dot(expected).abs() > 0.9999, + "WoW +Z yaw should map to Bevy +Y yaw: got {q_bevy:?}, want {expected:?}" + ); + } + + /// Conjugating the identity rotation is the identity (a sanity floor for the conjugation). + #[test] + fn identity_rotation_conjugates_to_identity() { + let r = wow_to_bevy_quat(); + let id = r * Quat::IDENTITY * r.inverse(); + assert!(id.dot(Quat::IDENTITY).abs() > 0.9999, "got {id:?}"); + } +} diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs new file mode 100644 index 00000000..4df46c43 --- /dev/null +++ b/crates/benilla-assets/src/model/anims.rs @@ -0,0 +1,437 @@ +//! The playable-animation surface: the types + resolution logic the game queries at runtime +//! ([`AnimClip`], [`ModelAnimations`], [`ResolvedAnim`], and `ModelAnimations`'s `find`/ +//! `pick_variation`/`resolve`) — kept separate from the bake-side builders in [`super`] (meshes, +//! skeleton, attachments, masks/subtree helpers, grip poses, billboard info). + +use benilla_formats::AnimDataCatalog; +pub use benilla_formats::PlayableAnim; +use bevy::animation::graph::{AnimationGraph, AnimationNodeIndex}; +use bevy::prelude::*; + +/// One animation in a model's [`ModelAnimations`] graph (decision 0019): its `AnimationData.dbc` id, +/// the graph node an `AnimationPlayer` plays to run it, and whether it loops. +#[derive(Clone)] +pub struct AnimClip { + pub anim_id: u16, + pub node: AnimationNodeIndex, + pub looping: bool, + /// Clip length in seconds — used to seek a one-shot clip (Death) to its end pose, e.g. for a unit + /// that streamed in already dead so it shows the settled corpse instead of replaying the collapse. + pub duration: f32, + /// The sequence's authored design movement speed (`ModelAnimation::move_speed`, yd/s) — the + /// denominator the locomotion playback-rate scaler divides the unit's live speed by, so a fast + /// mover cycles its legs proportionally faster. `0.0` for a non-locomotion clip (left at rate 1×). + pub move_speed: f32, + /// The sequence's blend-in time (`ModelAnimation::blend_time`, seconds) — the cross-fade duration the + /// spawn site uses when transitioning *into* this clip, so a gait change eases instead of snapping. + pub blend_time: f32, + /// The sequence's bounds-sphere centre, **Bevy model space** (the raw `M2Sequence` CAaBox centre + /// mapped like the mesh vertices, so it shares the rendered parts' frame). With + /// [`Self::bounds_radius`], the mouse-pick **broad phase**: the real client tests the cursor ray + /// against the *current* animation's sphere — world-placed + scaled, no pad — before the posed + /// per-triangle narrow test (wow-re pick-volume RE, `0x7089c0`). + pub bounds_center: Vec3, + /// The sequence's bounds-sphere radius (model-local yards). `0.0` ⇒ unauthored (callers fall back). + pub bounds_radius: f32, + /// The sequence's CAaBox, **Bevy model space** (componentwise min/max of the WoW-space corners + /// mapped like the mesh vertices). The unit **blob shadow**'s projection box: the real client + /// sizes the shadow from the *current* animation's box — clamped into ±5 per axis, scaled by + /// the world matrix, yaw-rotated then axis-aligned (VERIFIED wow-5875-re `unit-blob-shadow.md`, + /// `0x711a20` + the `0x6d7920` corner build). `(Vec3::ZERO, Vec3::ZERO)` ⇒ unauthored. + pub bounds_min: Vec3, + pub bounds_max: Vec3, + /// The sequence's event keyframes (`$SND`/footsteps/`$CSS`… — decision 0070 slice 3), sorted by + /// time, seconds on the clip clock. Shared (`Arc`) so cloning a clip stays cheap; empty for the + /// overwhelming majority of sequences. + pub events: std::sync::Arc<[benilla_formats::AnimEvent]>, + /// Per-arm masked variants `(right, left)` of this clip — graph nodes that animate **only** the + /// right/left arm subtree (shoulder keybone down), for the client's per-slot one-shots played + /// *over* the gait (the draw/stow: mainhand ceremony on the right arm, offhand on the left, + /// legs untouched). Built only for the sheath family (ids 89/90) on models whose hand + /// attachments + shoulder keybones resolve; `None` otherwise. + pub arm_nodes: Option<(AnimationNodeIndex, AnimationNodeIndex)>, + /// The **upper-body masked variant** of this clip: a graph node that animates only the + /// SpineLow-subtree ([`upper_subtree_root`] — keyBoneLookup[4]/[6], the legs excluded), for the + /// one-shot route's masked destination (decision 0087 — a swing/emote played *over* a live base: + /// legs keep running/sitting/jumping while the torso swings or emotes). The general form of the + /// per-arm [`Self::arm_nodes`] ceremony machinery, one subtree wider. `None` for a model with no + /// split key-bone (the −1 sentinel) — the route falls back to full-body on the base node. + pub upper_node: Option, + /// This variation's weight in the per-play roll (`M2Sequence.frequency` @+0x14): sequences + /// sharing an `anim_id` are **variations** (the alternating 1H swing arcs, the wound recoils), + /// and the real client picks one per one-shot play by a `_rand()`-weighted walk of the chain + /// (wow-re `anim-id-resolution.md`, op4 `0x71248a..`) — [`ModelAnimations::pick_variation`]. + pub frequency: u16, + /// The sequence's `(minReplay, maxReplay)` range (`M2Sequence` +0x18/+0x1c): the client rolls a + /// play count `R` at every arm and multiplies it into the play window, so a clamp-flag one-shot + /// runs `R` times before freezing (wow-re `loop-replay-fidget.md`; a loop-flag sequence ignores + /// it). Benilla expresses the same window as a `RepeatAnimation::Count(R)` on the one-shot play. + /// `(0, 0)` — the overwhelming majority — rolls to `R = 1`. + pub replay: (u32, u32), +} + +/// A model's animations as a playable bundle (decision 0019): one shared `AnimationGraph` holding every +/// sequence's clip, plus the per-sequence node/id/loop info. Built once per M2 asset and shared across +/// instances; each creature instance drives this graph with its own `AnimationPlayer`. Also attached to +/// the creature **as a component**, so the model bench can scrub `clips` and gameplay (Milestone C) can +/// select by `AnimationData.dbc` id (Stand now, walk/run later) — both query it on the entity. +#[derive(Clone, Component)] +pub struct ModelAnimations { + pub graph: Handle, + pub clips: Vec, + /// The **`HandsClosed` finger-grip masked nodes** `(right, left)` — graph nodes that animate only one + /// hand's finger key-bone subtrees ([`finger_subtree_roots`]) with the model's `HandsClosed` + /// (AnimationData 15) pose, played as a persistent weighted overlay over the gait while a weapon is + /// held in that hand (wow-re `hand-grip-mechanism.md`: the fingers curl for a held weapon; a + /// forearm-mounted shield or an empty hand stays open). `None` per hand for a model with no finger + /// key-bones or no `HandsClosed` clip (beasts, props — they never grip). + pub hand_close: [Option; 2], + /// The model's baked **PlayableAnimationLookup** (decision 0082, M2 header `+0x2c/+0x30`, see + /// [`PlayableAnim`]): row `i` is the model's own precomputed substitute for requested + /// `AnimationData.dbc` id `i`. Empty for a model whose header carries no table (a boneless + /// doodad-shaped cube fallback, a malformed M2) — [`Self::resolve`] then degrades to identity. + pub playable_animation_lookup: Vec, + /// The model's **global-sequence bone channels** baked to Bevy space (the eye-blink eyelid scale and + /// kin): free-clock loops the runtime samples independently of the playing animation and composes + /// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence + /// tracks. + pub global_bones: Vec, + /// Index into [`Self::clips`] of the **file-order-first sequence**, when that sequence actually + /// moves a bone (some track with >1 key). This is the doodad-animation content gate (decision + /// 0130): the client arms every M2 instance once at load with `animations[0].id` and loops it + /// forever (wow-re `doodad-anim-host.md`), so a placed doodad plays exactly this clip. `None` for + /// a model whose first sequence is a motionless pose (constant 1-key tracks build a clip too, but + /// looping it renders identically to the static mesh — the ~90% of placed doodads `doodadscan` + /// measured stay on the static path). + pub first_seq: Option, +} + +/// A requested `AnimationData.dbc` id resolved to what a specific model actually plays (decision +/// 0082, wow-re `anim-id-resolution.md`, byte-verified `0x711bf0`) — see [`ModelAnimations::resolve`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ResolvedAnim { + /// The `AnimationData.dbc` id to look up via [`ModelAnimations::find`]. + pub id: u16, + /// PATH 1's high16 direction/variant playback code (`0` from PATH 2 / the no-table degrade, which + /// have no such code of their own). **Plumbed through, not applied**: decision 0082 flags the + /// exact playback consumption (wow-re `~0x7126d2`) as untraced — no consumer reads this yet. + pub dir_flags: u16, +} + +impl ModelAnimations { + /// The clip for an `AnimationData.dbc` id (0 Stand, 4 Walk, 5 Run, …), or `None` if this model has + /// no sequence with that id (e.g. a static-Stand creature has no animated id-0 clip — the caller + /// then falls back to Stand or to bind pose rather than to an arbitrary clip). Returns the id's + /// **head variation** — the stable identity callers compare/loop on; a one-shot play that should + /// alternate variations goes through [`Self::pick_variation`] instead. + pub fn find(&self, anim_id: u16) -> Option<&AnimClip> { + self.clips.iter().find(|c| c.anim_id == anim_id) + } + + /// The clip a free/effect model instance runs: the caller's `preferred` id when the model has + /// it (the thrown-weapon missile asks for InFlight), else the file-order-first clip — the + /// existing default. One selector so the rig ([`crate::…`]'s `arm_effect_rig`) and its ribbon + /// gate agree on which sequence is playing. + pub fn preferred_clip(&self, preferred: Option) -> Option<&AnimClip> { + preferred + .and_then(|id| self.find(id)) + .or_else(|| self.clips.first()) + } + + /// Pick a **variation** of `anim_id` for one play, given a fresh `roll` (the client's `_rand()`, + /// `0..0x7fff`): the byte-verified weighted walk (wow-re `anim-id-resolution.md`, op4 with + /// variationIdx −1) — `roll < frequency` picks the current node, else `roll -= frequency` and + /// advance; chain exhaustion falls back to the **head** (the client leaves the resolved seqIndex + /// untouched on the exhaust path — `edi` set at `0x71247a`, only the win path overwrites it at + /// `0x7124e9`; wow-re `loop-replay-fidget.md`, correcting decision 0114's clamp-to-last). + /// Variations are the sequences sharing `anim_id`, taken in file order (retail exporters bake + /// the `variationNext` chain contiguously and in order — the same assumption [`Self::find`]'s + /// head = variation 0 already rests on). `None` iff the model has no sequence for `anim_id`. + pub fn pick_variation(&self, anim_id: u16, roll: u16) -> Option<&AnimClip> { + let mut roll = i32::from(roll); + let mut head = None; + for c in self.clips.iter().filter(|c| c.anim_id == anim_id) { + head.get_or_insert(c); + if roll < i32::from(c.frequency) { + return Some(c); + } + roll -= i32::from(c.frequency); + } + head + } + + /// Resolve a *requested* `AnimationData.dbc` id to the id this model actually plays (decision + /// 0082, wow-re `anim-id-resolution.md` — the byte-verified two-path `0x711bf0` resolver the real + /// client runs before every sequence lookup, ahead of [`Self::find`]): + /// - **PATH 1** (`requested < playable_animation_lookup.len()`): the model's own baked table — + /// one dword read, already the precomputed `AnimationData.dbc` Fallback-column walk's result for + /// THIS model's actual sequence set (a chicken lacking Attack2H bakes `[18] = 16`). + /// - **PATH 2** (`requested` ≥ the table length, table non-empty): the guarded `AnimationData.dbc` + /// Fallback-column walk over `catalog` — reachable only for the 5 ids ≥ 203 on a normal retail + /// asset set (`nPlayableAnimationLookup` is a fixed 203; `AnimationData.dbc` has 208 rows). + /// - **No table at all** (`playable_animation_lookup` empty — a cube fallback / malformed M2): + /// degrades to identity; [`Self::find`] then does its own direct lookup (today's behavior). + pub fn resolve(&self, requested: u16, catalog: &AnimDataCatalog) -> ResolvedAnim { + if let Some(row) = self.playable_animation_lookup.get(requested as usize) { + return ResolvedAnim { + id: row.resolved_id, + dir_flags: row.dir_flags, + }; + } + if self.playable_animation_lookup.is_empty() { + return ResolvedAnim { + id: requested, + dir_flags: 0, + }; + } + self.resolve_path2(requested, catalog) + } + + /// PATH 2 — the `AnimationData.dbc` Fallback-column walk (wow-re `anim-id-resolution.md`): follow + /// `catalog.fallback` from `requested` until this model actually has a sequence for the current id + /// (mirrors the real walk's `animationLookup[eax] != 0xffff` success test via [`Self::find`]), + /// guarded by a 208-entry visited set (`AnimationData.dbc`'s row count — the byte-verified cycle + /// guard). `catalog.fallback` already collapses the walk's NULL-row guard, self-fallback + /// termination, and "Fallback is 0 (Stand)" into a single `None` — each of those is a dead end that + /// leads to the same exhaustion default, so folding them costs nothing here. Exhaustion → Stand(0), + /// matching decision 0082's stated simplification (the real client's fuller default — the + /// GameObject-rest pose 147, or the model's first sequence id — is a further-out RE gap no benilla + /// id exercises: every id benilla requests resolves through PATH 1). + fn resolve_path2(&self, requested: u16, catalog: &AnimDataCatalog) -> ResolvedAnim { + const STAND: ResolvedAnim = ResolvedAnim { + id: 0, + dir_flags: 0, + }; + let mut visited = [false; 208]; + let mut id = requested; + loop { + if self.find(id).is_some() { + return ResolvedAnim { id, dir_flags: 0 }; + } + match visited.get_mut(id as usize) { + Some(seen) if !*seen => *seen = true, + _ => return STAND, // revisited (cycle) or id ≥ 208 (NULL/max-id guard) + } + match catalog.fallback(id) { + Some(next) => id = next, + None => return STAND, // unknown row / self-fallback / already-Stand → exhaustion + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── ModelAnimations::resolve (decision 0082) ────────────────────────────────────────────── + + use bevy::animation::graph::AnimationNodeIndex; + + fn test_clip(anim_id: u16) -> AnimClip { + AnimClip { + anim_id, + node: AnimationNodeIndex::new(0), + looping: true, + duration: 1.0, + move_speed: 0.0, + blend_time: 0.0, + bounds_center: Vec3::ZERO, + bounds_radius: 0.0, + bounds_min: Vec3::ZERO, + bounds_max: Vec3::ZERO, + events: Vec::new().into(), + arm_nodes: None, + upper_node: None, + frequency: 0, + replay: (0, 0), + } + } + + fn test_anims(has_clips: &[u16], table: Vec) -> ModelAnimations { + ModelAnimations { + graph: Handle::default(), + clips: has_clips.iter().map(|&id| test_clip(id)).collect(), + playable_animation_lookup: table, + hand_close: [None, None], + global_bones: Vec::new(), + first_seq: None, + } + } + + fn playable(resolved_id: u16, dir_flags: u16) -> PlayableAnim { + PlayableAnim { + resolved_id, + dir_flags, + } + } + + fn empty_catalog() -> AnimDataCatalog { + AnimDataCatalog::from_rows([]) + } + + /// PATH 1: a table entry within range is read verbatim, low16 as the id and high16 as the dir + /// code — the chicken-lacking-Attack2H shape (`[18] = {16, 0}`), model has clip 16 not 18. + #[test] + fn path1_reads_the_baked_table_entry() { + let mut table = vec![playable(0, 0); 19]; + table[18] = playable(16, 0); + let anims = test_anims(&[0, 16], table); + let resolved = anims.resolve(18, &empty_catalog()); + assert_eq!( + resolved, + ResolvedAnim { + id: 16, + dir_flags: 0 + } + ); + } + + /// PATH 1 also carries the dir-flags code — the real byte-verified HumanMale example + /// (`playableAnimationLookup[6] = 0x00030001`, wow-re `anim-id-resolution.md` §4): resolved id 1, + /// dir code 3. PATH 1 never consults the catalog at all (an empty one is fine here). + #[test] + fn path1_carries_dir_flags_through_unapplied() { + let mut table = vec![playable(0, 0); 7]; + table[6] = playable(1, 3); + let anims = test_anims(&[0, 1], table); + let resolved = anims.resolve(6, &empty_catalog()); + assert_eq!( + resolved, + ResolvedAnim { + id: 1, + dir_flags: 3 + } + ); + } + + /// PATH 2: id ≥ the table length walks the DBC Fallback chain (via `catalog`) until the model + /// actually has a sequence for the current id. + #[test] + fn path2_walks_the_dbc_fallback_past_the_table() { + let cat = AnimDataCatalog::from_rows([ + ( + 5, + benilla_formats::AnimEntry { + weapon_flags: 0, + fallback: 4, + }, + ), + ( + 4, + benilla_formats::AnimEntry { + weapon_flags: 0, + fallback: 3, + }, + ), + ]); + let anims = test_anims(&[0, 3], vec![playable(0, 0); 3]); // table covers only ids 0..2 + let resolved = anims.resolve(5, &cat); + assert_eq!( + resolved, + ResolvedAnim { + id: 3, + dir_flags: 0 + } + ); + } + + /// PATH 2's cycle guard: a Fallback loop (5→6→5→…) terminates at Stand(0) rather than spinning. + #[test] + fn path2_cycle_guard_terminates_at_stand() { + let cat = AnimDataCatalog::from_rows([ + ( + 5, + benilla_formats::AnimEntry { + weapon_flags: 0, + fallback: 6, + }, + ), + ( + 6, + benilla_formats::AnimEntry { + weapon_flags: 0, + fallback: 5, + }, + ), + ]); + let anims = test_anims(&[], vec![playable(0, 0); 3]); // model has neither 5 nor 6 + let resolved = anims.resolve(5, &cat); + assert_eq!( + resolved, + ResolvedAnim { + id: 0, + dir_flags: 0 + } + ); + } + + /// PATH 2's self-fallback / NULL-row termination: an id with no usable Fallback (an unknown row, + /// or one whose Fallback is itself/0 — `AnimDataCatalog::fallback` already collapses all three to + /// `None`) exhausts to Stand(0) on the first hop. + #[test] + fn path2_self_or_null_fallback_exhausts_to_stand() { + let anims = test_anims(&[], vec![playable(0, 0); 3]); + let resolved = anims.resolve(99, &empty_catalog()); // id 99 has no row at all + assert_eq!( + resolved, + ResolvedAnim { + id: 0, + dir_flags: 0 + } + ); + } + + /// PATH 2's NULL/max-id guard: an id at or past the 208-row `AnimationData.dbc` bound can't even + /// be marked visited — it exhausts to Stand(0) immediately, not just when it recurs. + #[test] + fn path2_out_of_dbc_range_id_exhausts_to_stand() { + let anims = test_anims(&[], vec![playable(0, 0); 3]); + let resolved = anims.resolve(250, &empty_catalog()); + assert_eq!( + resolved, + ResolvedAnim { + id: 0, + dir_flags: 0 + } + ); + } + + /// A model with no baked table at all (a cube fallback / malformed M2) degrades to identity — + /// `resolve` hands the id straight back so the caller's own `find` does today's direct lookup. + #[test] + fn no_table_degrades_to_identity() { + let anims = test_anims(&[0], Vec::new()); + let resolved = anims.resolve(42, &empty_catalog()); + assert_eq!( + resolved, + ResolvedAnim { + id: 42, + dir_flags: 0 + } + ); + } + + /// The variation pick is the client's weighted walk (wow-re `anim-id-resolution.md`, op4 with + /// variationIdx −1): `roll < frequency` picks, else `roll -= frequency` and advance; chain + /// exhaustion clamps to the last variation; a lone variation always wins. + #[test] + fn pick_variation_walks_the_weighted_chain() { + let mut anims = test_anims(&[16, 16, 5], Vec::new()); + anims.clips[0].frequency = 0x6000; // Attack1H variation 0 — the common arc + anims.clips[1].frequency = 0x1fff; // variation 1 — the off-side swing + let pick = |anims: &ModelAnimations, id, roll| { + let c = anims.pick_variation(id, roll).unwrap(); + anims.clips.iter().position(|x| std::ptr::eq(x, c)).unwrap() + }; + assert_eq!(pick(&anims, 16, 0x0000), 0); + assert_eq!(pick(&anims, 16, 0x5fff), 0); // last roll inside variation 0's weight + assert_eq!(pick(&anims, 16, 0x6000), 1); // first roll past it + assert_eq!(pick(&anims, 16, 0x7ffe), 1); + // Weights under-cover the roll → the HEAD, not the last (the client's exhaust path leaves + // the resolved seqIndex untouched — wow-re loop-replay-fidget.md, corrects decision 0114). + assert_eq!(pick(&anims, 16, 0x7fff), 0); + assert_eq!(pick(&anims, 5, 0x7fff), 2); // lone variation wins by exhaustion→head (weight 0) + assert!(anims.pick_variation(99, 0).is_none()); + } +} diff --git a/crates/benilla-assets/src/terrain.rs b/crates/benilla-assets/src/terrain.rs new file mode 100644 index 00000000..725269bd --- /dev/null +++ b/crates/benilla-assets/src/terrain.rs @@ -0,0 +1,245 @@ +//! Terrain-tile texture-array packing for the ADT loader. +//! +//! A tile's ground textures are stacked into one repeating, mipmapped `texture_2d_array`; its +//! per-chunk alpha (blend-weight) and MCSH shadow maps into two more arrays. The fidelity-critical +//! rule (the "C2" invariant, anchored in `wow-5875-re/system/terrain`): the BLP's **authored** mip +//! pyramid is laid in **verbatim** — never CPU-regenerated by averaging texels in gamma byte space, +//! which darkens mid-tones and cools foliage. Off-Elwynn BLPs authored below [`LAYER_TEX_SIZE`] fill +//! the top mips by `NEAREST` upsample (texel replication — gamma-safe, no averaging). + +use benilla_formats::BlpMipChain; +use bevy::asset::RenderAssetUsages; +use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::prelude::*; +use bevy::render::render_resource::{ + Extent3d, TextureDataOrder, TextureDimension, TextureFormat, TextureViewDescriptor, + TextureViewDimension, +}; + +/// Edge length every layer BLP is uploaded at for the per-tile array (uniform so they share one +/// `texture_2d_array`). The Elwynn A/B target's layer BLPs are all natively 256² with 8 authored mips. +pub(crate) const LAYER_TEX_SIZE: u32 = 256; + +/// Per-layer mip count for the array — the authored count at 256². Fewer-mip BLPs repeat the last +/// authored level; sub-256² BLPs upsample (NEAREST) to fill the top. +pub(crate) const LAYER_MIP_COUNT: u32 = 8; + +/// Albedo format: non-sRGB `Rgba8Unorm` — the GPU does not linearize on sample, so shader math stays +/// in WoW's gamma/byte space (the RE'd invariant; matches the model/BLP albedo format). +fn color_format() -> TextureFormat { + TextureFormat::Rgba8Unorm +} + +/// One ground texture packed for the per-tile array: its authored mip chain at [`LAYER_TEX_SIZE`]², +/// concatenated mip-major (level 0 first), `mip_count` levels. +pub(crate) struct LayerTexture { + pub(crate) chain: Vec, + pub(crate) mip_count: u32, +} + +/// Pack a BLP's authored mip pyramid into a [`LayerTexture`] for the tile array — authored levels +/// used verbatim (the C2 fix). When the BLP's mip 0 is smaller than `target_size` (off-Elwynn) the +/// top of the chain is NEAREST-upsampled (texel replication, gamma-safe), with the BLP's authored +/// smaller mips slotting into the deeper array levels; when its chain runs out the last authored mip +/// is reused (NEAREST-resampled if the dims still differ). +pub(crate) fn chain_to_layer(blp: BlpMipChain, target_size: u32) -> LayerTexture { + let mut chain = Vec::with_capacity(mip_chain_byte_size(target_size, LAYER_MIP_COUNT)); + for level in 0..LAYER_MIP_COUNT { + let level_w = (target_size >> level).max(1); + let level_h = level_w; // tilesets are square in 1.12 + if blp.width >= level_w { + // `BLP width >> j == level_w` ⟹ `j = log2(BLP width / level_w)`, clamped to the BLP's + // available mips (if it doesn't go that deep, repeat the last authored level). + let ratio = (blp.width / level_w).max(1); + let src_level = (ratio.trailing_zeros() as usize).min(blp.mips.len() - 1); + let (sw, sh) = blp.mip_size(src_level as u32); + extend_nearest(&mut chain, &blp.mips[src_level], sw, sh, level_w, level_h); + } else { + // Authored smaller than this level — NEAREST-upsample mip 0 (texel replication). + extend_nearest( + &mut chain, + &blp.mips[0], + blp.width, + blp.height, + level_w, + level_h, + ); + } + } + LayerTexture { + chain, + mip_count: LAYER_MIP_COUNT, + } +} + +/// A solid-colour layer chain at [`LAYER_TEX_SIZE`]² with [`LAYER_MIP_COUNT`] levels — the per-tile +/// array's fallback layer 0 (a solid colour downsamples to itself, so every mip is the same colour). +pub(crate) fn solid_layer_chain(rgba: [u8; 4]) -> LayerTexture { + let mut chain = Vec::with_capacity(mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT)); + for level in 0..LAYER_MIP_COUNT { + let w = (LAYER_TEX_SIZE >> level).max(1); + for _ in 0..(w * w) { + chain.extend_from_slice(&rgba); + } + } + LayerTexture { + chain, + mip_count: LAYER_MIP_COUNT, + } +} + +/// Total bytes for a mip pyramid of `mip_count` levels starting at `top²` RGBA8. +pub(crate) fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize { + (0..mip_count) + .map(|i| { + let w = (top >> i).max(1); + (w * w * 4) as usize + }) + .sum() +} + +/// Append `src` (RGBA8 `sw × sh`) resized to `dw × dh` to `out` using `NEAREST` — pure texel +/// replication/decimation, so gamma-encoded bytes pass through unchanged (no averaging → no +/// darkening). Identity at `(sw, sh) == (dw, dh)`. +pub(crate) fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) { + if sw == dw && sh == dh { + out.extend_from_slice(src); + return; + } + let Some(buf) = image::RgbaImage::from_raw(sw, sh, src.to_vec()) else { + out.extend(std::iter::repeat_n(0u8, (dw * dh * 4) as usize)); + return; + }; + let resized = image::imageops::resize(&buf, dw, dh, image::imageops::FilterType::Nearest); + out.extend_from_slice(resized.as_raw()); +} + +/// Stack the tile's ground textures into a repeating, mipmapped `2d_array`. `rgba` holds each layer's +/// full `mip_levels`-deep chain (LayerMajor order); `layer_count` layers at `size²` mip 0. +pub(crate) fn layer_array_image( + size: u32, + layer_count: u32, + mip_levels: u32, + rgba: Vec, +) -> Image { + // Image::new asserts mip-0 length; seed a correctly-sized buffer, then swap in the full chain. + let mip0_len = (size * size * 4 * layer_count) as usize; + let mut image = array_image(size, layer_count, vec![0u8; mip0_len], color_format()); + image.data = Some(rgba); + image.texture_descriptor.mip_level_count = mip_levels; + image.data_order = TextureDataOrder::LayerMajor; + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: 8, + ..default() + }); + image +} + +/// Stack `layer_count` per-chunk alpha maps (each `size²`, concatenated) into a linear `2d_array` — +/// the tile's blend weights (R/G/B = layers 1/2/3 opacity). Non-colour, so `Rgba8Unorm`. +pub(crate) fn alpha_array_image(size: u32, layer_count: u32, rgba: Vec) -> Image { + array_image(size, layer_count, rgba, TextureFormat::Rgba8Unorm) +} + +/// Stack `layer_count` MCSH shadow maps (each `size²`, one byte/texel) into a single-channel +/// `2d_array` — WoW's pre-baked static terrain shadows (`R` = shadowed, 0 = lit). +pub(crate) fn shadow_array_image(size: u32, layer_count: u32, data: Vec) -> Image { + array_image(size, layer_count, data, TextureFormat::R8Unorm) +} + +/// Build a `2d_array` `Image` from concatenated per-layer bytes, with a `D2Array` view so the +/// `dimension = "2d_array"` bindings resolve. +fn array_image(size: u32, layer_count: u32, data: Vec, format: TextureFormat) -> Image { + let mut image = Image::new( + Extent3d { + width: size, + height: size, + depth_or_array_layers: layer_count, + }, + TextureDimension::D2, + data, + format, + RenderAssetUsages::default(), + ); + image.texture_view_descriptor = Some(TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..default() + }); + image +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A synthetic BLP mip chain: `mips` authored levels of a solid grey fill at `size²`. + fn synthetic_chain(size: u32, mips: u32) -> BlpMipChain { + let levels = (0..mips) + .map(|i| { + let w = (size >> i).max(1); + vec![200u8; (w * w * 4) as usize] + }) + .collect(); + BlpMipChain { + width: size, + height: size, + mips: levels, + } + } + + #[test] + fn chain_to_layer_packs_a_native_256_blp_to_the_full_chain() { + let layer = chain_to_layer(synthetic_chain(256, 8), LAYER_TEX_SIZE); + assert_eq!(layer.mip_count, LAYER_MIP_COUNT); + assert_eq!( + layer.chain.len(), + mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT) + ); + } + + #[test] + fn chain_to_layer_upsamples_a_small_blp_without_darkening() { + // A 64² BLP packed to 256²: the top levels are NEAREST-upsampled. A solid 200 in must come + // out solid 200 — averaging (the C2 bug) would dim it below 200. + let layer = chain_to_layer(synthetic_chain(64, 7), 256); + assert_eq!(layer.chain.len(), mip_chain_byte_size(256, LAYER_MIP_COUNT)); + assert!( + layer.chain.iter().all(|&b| b == 200), + "NEAREST upsample must preserve the authored gamma bytes (no averaging)" + ); + } + + #[test] + fn solid_layer_chain_is_one_colour_at_every_mip() { + let layer = solid_layer_chain([10, 20, 30, 40]); + assert_eq!( + layer.chain.len(), + mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT) + ); + assert!(layer.chain.chunks_exact(4).all(|px| px == [10, 20, 30, 40])); + } + + #[test] + fn layer_array_image_has_the_expected_shape() { + let bytes = vec![0u8; 3 * mip_chain_byte_size(LAYER_TEX_SIZE, LAYER_MIP_COUNT)]; + let img = layer_array_image(LAYER_TEX_SIZE, 3, LAYER_MIP_COUNT, bytes); + assert_eq!(img.texture_descriptor.size.width, LAYER_TEX_SIZE); + assert_eq!(img.texture_descriptor.size.depth_or_array_layers, 3); + assert_eq!(img.texture_descriptor.mip_level_count, LAYER_MIP_COUNT); + } + + #[test] + fn alpha_and_shadow_arrays_have_the_expected_shape_and_format() { + let alpha = alpha_array_image(64, 2, vec![0u8; 2 * 64 * 64 * 4]); + assert_eq!(alpha.texture_descriptor.size.depth_or_array_layers, 2); + assert_eq!(alpha.texture_descriptor.format, TextureFormat::Rgba8Unorm); + let shadow = shadow_array_image(64, 2, vec![0u8; 2 * 64 * 64]); + assert_eq!(shadow.texture_descriptor.size.depth_or_array_layers, 2); + assert_eq!(shadow.texture_descriptor.format, TextureFormat::R8Unorm); + } +} diff --git a/crates/benilla-assets/src/wdt.rs b/crates/benilla-assets/src/wdt.rs new file mode 100644 index 00000000..fd628ee5 --- /dev/null +++ b/crates/benilla-assets/src/wdt.rs @@ -0,0 +1,92 @@ +//! WDT → map tile-index asset loader. +//! +//! Decodes a map's tiny `.wdt` into a [`WdtIndex`] — the 64×64 `MAIN` tile-existence grid the +//! terrain streamer consults before requesting any `.adt` (decision 0476): open ocean authors no +//! tiles, and probing them wholesale spammed asset-server NotFound errors on every boat crossing. +//! The parse itself is `benilla-wdt`'s [`WdtReader`] (oracle-tested in the 0021 migration); this +//! module only wraps it in the Bevy asset machinery. + +use benilla_formats::{WdtFile, WdtReader, WowVersion}; +use bevy::asset::io::Reader; +use bevy::asset::{Asset, AssetLoader, LoadContext}; +use bevy::reflect::TypePath; + +/// A map's parsed WDT: which of the 64×64 ADT tiles exist. +#[derive(Asset, TypePath)] +pub struct WdtIndex(WdtFile); + +impl WdtIndex { + /// Whether tile `(tile_x, tile_y)` has an `.adt` (`MAIN` flag bit 0) — the same index order as + /// the `Map__.adt` file names and [`benilla_formats::world_to_tile`]. + /// Out-of-range ⇒ `false`. + pub fn has_tile(&self, tile_x: u32, tile_y: u32) -> bool { + self.0 + .get_tile(tile_x as usize, tile_y as usize) + .is_some_and(|t| t.has_adt) + } +} + +/// Bevy [`AssetLoader`] decoding a vanilla `*.wdt` → [`WdtIndex`]. +#[derive(Default, TypePath)] +pub struct WdtIndexLoader; + +impl AssetLoader for WdtIndexLoader { + type Asset = WdtIndex; + type Settings = (); + type Error = std::io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + _ctx: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let wdt = WdtReader::new(std::io::Cursor::new(bytes), WowVersion::Classic).read()?; + Ok(WdtIndex(wdt)) + } + + fn extensions(&self) -> &[&str] { + &["wdt"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal synthetic WDT: MVER + a MAIN grid with exactly two tiles flagged present. + /// Pins the parse AND the index order ([`WdtIndex::has_tile`] is `(tile_x, tile_y)`, the + /// on-disk grid is row-major `y * 64 + x`). + #[test] + fn parses_main_grid_in_tile_index_order() { + let mut buf = Vec::new(); + buf.extend_from_slice(b"REVM"); // magics are reversed on disk + buf.extend_from_slice(&4u32.to_le_bytes()); + buf.extend_from_slice(&18u32.to_le_bytes()); + buf.extend_from_slice(b"NIAM"); + buf.extend_from_slice(&(64u32 * 64 * 8).to_le_bytes()); + let mut grid = vec![0u8; 64 * 64 * 8]; + for (x, y) in [(3usize, 5usize), (63, 0)] { + grid[(y * 64 + x) * 8] = 1; // flags bit 0 = has_adt + } + buf.extend_from_slice(&grid); + + let wdt = WdtReader::new(std::io::Cursor::new(buf), WowVersion::Classic) + .read() + .expect("synthetic WDT parses"); + let index = WdtIndex(wdt); + assert!(index.has_tile(3, 5)); + assert!(index.has_tile(63, 0)); + assert!( + !index.has_tile(5, 3), + "index order is (tile_x, tile_y), not swapped" + ); + assert!(!index.has_tile(0, 0)); + assert!( + !index.has_tile(64, 64), + "out of range is absent, not a panic" + ); + } +} diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs new file mode 100644 index 00000000..f2170337 --- /dev/null +++ b/crates/benilla-assets/src/wmo.rs @@ -0,0 +1,647 @@ +//! WMO → model asset loader. +//! +//! A WMO (building) is a **root** file plus N **group** files (`_NNN.wmo`). The loader reads the +//! root from the [`Reader`], then reads each group via [`LoadContext::read_asset_bytes`] (raw bytes, +//! recorded as a dependency — *not* `load()`, which would recurse back into this loader), building one +//! [`ModelSubmesh`] per group render batch with its texture resolved against the root's material +//! tables. +//! +//! Same app-independent shape as the M2 loader (meshes + texture handles + metadata; materials at +//! spawn). WMOs don't size-fade, so there are no bounds. + +use benilla_formats::{ + accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, parse_wmo_lights, + parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, + wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes, CollisionMesh, FootprintTris, + LiquidMesh, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupInfo, WmoLight, WmoPortalInfo, + WmoPortalRef, +}; +use bevy::asset::io::Reader; +use bevy::asset::{Asset, AssetLoader, LoadContext}; +use bevy::prelude::*; +use bevy::reflect::TypePath; + +use crate::model::{build_submesh_mesh, ModelSubmesh}; + +/// Per-WMO-group navigation data for the portal visibility cull (`crate::wmo_portal`): the group's +/// MOGP flags (the EXTERIOR `0x8` bit the flood defers on), its bounding box (WMO model space, WoW +/// axes — the camera current-group containment test), and its slice of [`WmoModel::portal_refs`]. +/// Indexed by **absolute group index** (parallel gaps for unreadable groups carry a degenerate box + +/// zero refs, so they're never a current-group candidate and never flood). +#[derive(Clone, Copy)] +pub struct WmoGroupNav { + pub flags: u32, + pub bbox_min: [f32; 3], + pub bbox_max: [f32; 3], + pub ref_start: u16, + pub ref_count: u16, + /// The group's `WMOAreaTable.WMOGroupID` key (MOGP `uniqueID`; 0 = none carried). + pub area_table_id: u32, + /// The group's MOGP fog indices (disk `+0x30`, empirically pinned against the Goldshire inn) — + /// up to four indices into [`WmoModel::fogs`], walked by the camera-in-interior fog selector + /// (`0x69de20`, wow-re `rf-weather-emission-timeline` ROUND 5). + pub fog_indices: [u8; 4], +} + +/// A loaded WMO building: the render batches across all its groups, flattened, plus the flattened +/// collidable triangles (the app bakes a collider from these per placement). +#[derive(Asset, TypePath, Clone)] +pub struct WmoModel { + /// The building's `WMOAreaTable.WMOID` key (root `MOHD.wmoID`; NSabbey → 59). + pub wmo_id: u32, + pub submeshes: Vec, + /// Group index per entry in [`Self::submeshes`] (parallel) — which WMO group each render batch + /// belongs to, so the portal cull can hide a whole group by toggling its submeshes' `Visibility`. + pub submesh_group: Vec, + /// The portal graph (MOPV vertices / MOPT infos / MOPR refs), WMO model space (WoW axes). The + /// per-frame cull floods this from the camera's current group (`crate::wmo_portal`). Empty ⇒ no + /// portals (single-group props) ⇒ every group stays visible. + pub portal_vertices: Vec<[f32; 3]>, + pub portal_infos: Vec, + pub portal_refs: Vec, + /// Per-group nav (flags + bbox + portal-ref slice), indexed by absolute group index. + pub group_nav: Vec, + /// The root's MFOG fog records — record 0 is the WMO default (the interior-fog selector's + /// seed); a group's [`WmoGroupNav::fog_indices`] point here. Consumed by the camera-in-interior + /// fog resolve (`crate::wmo_portal` in the app), per wow-re `rf-weather-emission-timeline` + /// ROUND 5 (`0x69de20`). + pub fogs: Vec, + /// Per-group **walking-collision** triangles (WoW model space, indexed by absolute group index) — + /// the Leg-A face set for the current-group **down-ray** (`crate::wmo_portal`). The faithful "which + /// room is the camera in" test casts a ray straight down from the eye against the same face set the + /// player's walking collision uses — every non-DETAIL face, **no orientation filter** (`wow-5875-re` + /// `system/models/scratch/wmo-portal-audit.md` Q3/Q4: the client's Leg A is the walking-collision + /// BSP, mask `0x84`; an earlier render-face `|n.z|` floor heuristic mis-seeded doorways and slab + /// edges). Same triangles as [`Self::collision`], kept per group for group attribution. + pub group_collision_tris: Vec>, + /// Per-group AABB of [`Self::group_collision_tris`] (parallel; `None` = no collision faces), + /// computed at load **from the triangles themselves** — the broad phase for the faces-only + /// down-ray (`area_down_ray`). Bounds derived from the faces can only summarize the geometry, + /// never understate it the way an authored MOGI box can (NSabbey group 3's authored bottom + /// floats 1.5 yd above its own floor polys — the 2026-07-12 indoor flap), so culling on them + /// is exact: a group is skipped only when no face of it could own the probe column. + pub group_collision_bounds: Vec>, + /// The union of [`Self::group_collision_bounds`] — the whole-building broad phase: a probe + /// column outside it cannot hit any collision face, so the per-frame indoor trackers skip the + /// placed instance outright (a camera in open country pays one AABB test per building, not a + /// face scan — the 2026-07-12 fps regression). + pub collision_bounds: Option<([f32; 3], [f32; 3])>, + /// **Walking** collidable triangles across all groups in raw WMO-local coords — the player-body + /// gather (excludes DETAIL faces). `None` when the building carries no collidable geometry. + pub collision: Option, + /// **Camera/LOS** collidable triangles — the third-person-camera gather (excludes NOCAMCOLLIDE, + /// *keeps* DETAIL). Distinct from [`Self::collision`]; the app bakes a separate collider on the + /// camera collision layer so the camera can't slip behind visible decals/overhangs (e.g. forge + /// pipes) the player walks under. `None` when the building has no camera-collidable geometry. + pub collision_camera: Option, + /// The root's placed doodads (MODD props — candle stands, banners), in WMO model space; the app + /// spawns each as an M2 composed with the WMO instance transform, selected by doodad set. + pub doodads: Vec, + /// The doodad sets (MODS) — ranges into [`Self::doodads`]; set 0 is global, a placement picks one more. + pub doodad_sets: Vec, + /// The root's MOLT lights (the interior fixture lights — fireplaces, forge, candles, chandeliers), + /// in WMO model space. Spawned as dynamic point lights (decisions 0016/0273) that warm nearby + /// doodads/NPCs, terrain, and the building's own walls/floor over their baked MOCV. + pub lights: Vec, + /// Per-group interior flag + bounding box (MOGI), WMO model space — classifies which placed doodads + /// are interior (lit off the room/fixtures) vs exterior (lit off the sky). See [`WmoGroupInfo`]. + pub group_bounds: Vec, + /// Per-MODD lighting base, parallel to [`Self::doodads`] — resolved ONCE at load (placements are + /// fixed in the root). See [`DoodadBase`]: an interior-group doodad's base is its own MODD + /// entry's baked colour, and its point light comes only from its owning group's MOLR list. The + /// footprint down-ray this replaced (decision 0290) is byte-real code, but it is the ADT-MDDF + /// attach path — a WMO MODD doodad never reaches it (create never calls SetMatrix; wow-re + /// `trace-forensics-abbey-interior-d3d` §1.1). + pub doodad_base: Vec, + /// Per-group FOOTPRINT face set (render tris + baked MOCV + MOPY flags), indexed by absolute + /// group index; `None` for exterior groups / groups without MOCV. The **GameObject M2** light + /// lane samples it: the entity node's env-update attach down-rays the render mesh under the + /// object and bakes the hit's barycentric MOCV, floor-168/cap-96, on the fixed interior axis + /// (`0x69e4c0`; wow-re `wmo-lit-selector.md`, trace-decisive on the abbey benches). + pub group_footprints: Vec>, + /// Per-group AABB of the faces [`Self::group_footprints`] actually references (parallel; + /// `None` = no footprint faces), computed at load from the triangles themselves — the broad + /// phase for the footprint down-ray, the same exact-cull argument as + /// [`Self::group_collision_bounds`]: a probe column outside a group's face bounds in XY, or + /// wholly below its lowest vertex, can hit none of its faces. Without it every re-rayed + /// entity scanned EVERY interior group of the whole model — at Stormwind (one WMO, all + /// districts) that was the live-session frame (decision 0364). + pub group_footprint_bounds: Vec>, + /// Per-group MOLR light refs (indices into [`Self::lights`]), indexed by absolute group index — + /// the point lights that fold into an interior GameObject's committed light are gated by the + /// footprint-hit group's list, same as a MODD prop's by its owning group. + pub group_light_refs: Vec>, + /// Per-group MLIQ liquid surface (the animated water/lava/slime a group embeds — Stormwind's + /// canals + fountains, the Ironforge lava, dungeon pools), indexed by absolute group index; + /// `None` for a group with no `MLIQ`. Built in WMO model space (WoW axes); the app spawns each at + /// the placement transform, on the shared per-kind liquid material. + pub group_liquids: Vec>, +} + +/// The load-resolved lighting base of one placed MODD doodad. The real client fills an +/// interior-group doodad's slot-0 words at CREATE, from the MODD record's own colour field — +/// byte-verified (`0x694e90` → `6950de call 0x6a77e0(&MODD.colour, &diffuse, 0x70, &ambient, 0x60)`) +/// and read live off the reference's abbey draws, where both decoded stands' diffuse words equal +/// their MODD colour bytes verbatim (wow-re `trace-forensics-abbey-interior-d3d` §1). +#[derive(Clone, PartialEq, Debug)] +pub enum DoodadBase { + /// Exterior-group doodad — the sky-lit lane (day/night sun; a WMO prop takes the plain matte). + Exterior, + /// Interior-group doodad: the MODD-colour base + the owning group's light refs. + Interior(InteriorPropBase), +} + +/// An interior MODD doodad's resolved base light — computed once at load, day/night-independent. +#[derive(Clone, PartialEq, Debug)] +pub struct InteriorPropBase { + /// The ambient word: `cap96(MODD.colour)` (0–1 RGB). See [`cap96`]. + pub ambient: [f32; 3], + /// The diffuse word: `floor112(MODD.colour)` (0–1 RGB), committed as a directional lobe on the + /// fixed engine axis (−0.30822, −0.30822, −0.9) — never the day/night sun. See [`floor112`]. + pub diffuse: [f32; 3], + /// The owning group's MOLR light refs (indices into [`WmoModel::lights`]): the ONLY point lights + /// this doodad's committed light folds (range-gated by each light's disk attenStart/attenEnd at + /// spawn). Empty — a group with no MOLR — means NO point light at all, its own flame included. + pub light_refs: Vec, +} + +/// Per-group AABBs of the footprint faces each group actually references — +/// [`WmoModel::group_footprint_bounds`], derived at load from the indexed vertices (not the whole +/// position array, which may carry vertices no footprint face uses). Shared with the fixture +/// builders so tests and the loader can never disagree on what the bounds mean. +pub fn footprint_tri_bounds( + footprints: &[Option], +) -> Vec> { + footprints + .iter() + .map(|fp| { + let fp = fp.as_ref()?; + let mut bounds: Option<([f32; 3], [f32; 3])> = None; + for &i in &fp.indices { + let Some(v) = fp.positions.get(i as usize) else { + continue; + }; + let (min, max) = bounds.get_or_insert((*v, *v)); + for a in 0..3 { + min[a] = min[a].min(v[a]); + max[a] = max[a].max(v[a]); + } + } + bounds + }) + .collect() +} + +/// The per-group AABBs of a per-group triangle set + their union — [`WmoModel::group_collision_bounds`] +/// and [`WmoModel::collision_bounds`], derived from the faces at load (shared with the down-ray tests, +/// so fixtures and the loader can never disagree on what the bounds mean). +#[allow(clippy::type_complexity)] +pub fn collision_tri_bounds( + tris: &[Vec<[[f32; 3]; 3]>], +) -> ( + Vec>, + Option<([f32; 3], [f32; 3])>, +) { + let per_group: Vec> = tris + .iter() + .map(|group| { + let mut bounds: Option<([f32; 3], [f32; 3])> = None; + for tri in group { + for v in tri { + let (min, max) = bounds.get_or_insert((*v, *v)); + for a in 0..3 { + min[a] = min[a].min(v[a]); + max[a] = max[a].max(v[a]); + } + } + } + bounds + }) + .collect(); + let union = per_group.iter().flatten().fold( + None::<([f32; 3], [f32; 3])>, + |acc, (gmin, gmax)| match acc { + None => Some((*gmin, *gmax)), + Some((mut min, mut max)) => { + for a in 0..3 { + min[a] = min[a].min(gmin[a]); + max[a] = max[a].max(gmax[a]); + } + Some((min, max)) + } + }, + ); + (per_group, union) +} + +/// The ambient-word CAP of `0x6a77e0` (exact fixed-point arithmetic, byte-verified — re-derived in +/// wow-re `trace-forensics-abbey-interior-d3d` §1.1 and bit-exact against both decoded abbey stands): +/// a colour whose max channel exceeds 96 is scaled down so max = 96 — per channel +/// `(c·scale + 255) >> 8` with `scale = round(96·255/max − 0.5)`; max ≤ 96 passes through raw. +pub fn cap96(c: [u8; 3]) -> [f32; 3] { + let max = c[0].max(c[1]).max(c[2]); + if max <= 96 { + return c.map(|v| f32::from(v) / 255.0); + } + let scale = ((96.0 * 255.0 / f32::from(max)) - 0.5).round_ties_even() as u32; + c.map(|v| ((u32::from(v) * scale + 255) >> 8) as f32 / 255.0) +} + +/// The diffuse-word FLOOR of `0x6a77e0`: a colour whose max channel is below `thresh` is raised — +/// HSV round-trip re-emitting at value `thresh`, which for an RGB triple is a hue/saturation- +/// preserving scale by `thresh/max`, **truncated** per channel. The rounding was OPEN until the +/// abbey INNBENCH decode exercised the raise leg live: bench MOCVs (59,65,92)/(69,63,83) raise to +/// diffuse (107,118,168)/(139,127,168) — 63·(168/83) = 127.52 lands on 127, which truncation gives +/// and nearest-rounding does not (wow-re `wmo-lit-selector.md`, machine-zero fits). +fn floor_raise(c: [u8; 3], thresh: u8) -> [f32; 3] { + let max = c[0].max(c[1]).max(c[2]); + if max >= thresh || max == 0 { + return c.map(|v| f32::from(v) / 255.0); + } + // Integer scale — truncating by construction AND exact on the max channel (a float + // `v · (thresh/max)` can land a hair under `thresh` and truncate to `thresh − 1`). + c.map(|v| ((u32::from(v) * u32::from(thresh)) / u32::from(max)) as f32 / 255.0) +} + +/// [`floor_raise`] at the MODD create site's diffuse threshold `0x70` (112). Max ≥ 112 passes +/// through RAW — both decoded abbey stands do (maxes 134/141 → the diffuse word IS the MODD colour +/// verbatim). +pub fn floor112(c: [u8; 3]) -> [f32; 3] { + floor_raise(c, 112) +} + +/// [`floor_raise`] at the entity/footprint attach site's diffuse threshold `0xA8` (168) — the +/// GameObject M2 lane (`0x69e4c0`, the entity twin of the ADT-MDDF `0x6a8410`; wow-re +/// `wmo-lit-selector.md`). Both decoded abbey benches take the raise leg (MOCV maxes 92/83 → +/// diffuse max exactly 168 on the wire). +pub fn floor168(c: [u8; 3]) -> [f32; 3] { + floor_raise(c, 168) +} + +/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow +/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the +/// instantiate loop is per-group over MODR — never a spatial test), the owning group's +/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR** +/// list is its complete point-light candidate set. A MODD referenced by no group is never +/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows +/// it anyway). +fn resolve_doodad_bases( + doodads: &[WmoDoodad], + groups: &[WmoGroupInfo], + group_doodad_refs: &[Vec], + group_light_refs: &[Vec], +) -> Vec { + // Invert MODR: MODD index → owning group (first referencing group wins; a doodad shared by two + // groups gets one def per group in the reference — one instance and its first owner here). + let mut owner: Vec> = vec![None; doodads.len()]; + for (gi, refs) in group_doodad_refs.iter().enumerate() { + for &di in refs { + if let Some(slot) = owner.get_mut(di as usize) { + slot.get_or_insert(gi as u16); + } + } + } + doodads + .iter() + .zip(&owner) + .map(|(d, owner)| { + let Some(gi) = owner else { + return DoodadBase::Exterior; + }; + let interior = groups + .get(*gi as usize) + .is_some_and(|g: &WmoGroupInfo| g.interior); + if !interior { + return DoodadBase::Exterior; + } + let rgb = [d.color[0], d.color[1], d.color[2]]; + DoodadBase::Interior(InteriorPropBase { + ambient: cap96(rgb), + diffuse: floor112(rgb), + light_refs: group_light_refs + .get(*gi as usize) + .cloned() + .unwrap_or_default(), + }) + }) + .collect() +} + +/// Bevy [`AssetLoader`] decoding a WMO root `*.wmo` (+ its group files) → [`WmoModel`]. +#[derive(Default, TypePath)] +pub struct WmoModelLoader; + +impl AssetLoader for WmoModelLoader { + type Asset = WmoModel; + type Settings = (); + type Error = std::io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _settings: &(), + ctx: &mut LoadContext<'_>, + ) -> Result { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes).await?; + let to_io = |e: anyhow::Error| std::io::Error::other(format!("{e:#}")); + let root = parse_wmo_root(&bytes).map_err(to_io)?; + + // Root path → stem for the group files. Lowercased so the suffix strip + group URLs are + // consistent (MPQ resolution is case-insensitive regardless). + let root_path = ctx.path().path().to_string_lossy().to_ascii_lowercase(); + let stem = root_path + .strip_suffix(".wmo") + .unwrap_or(&root_path) + .to_string(); + + let mut submeshes = Vec::new(); + // Group index per submesh (parallel to `submeshes`) — the portal cull's handle on which group a + // render batch belongs to. + let mut submesh_group: Vec = Vec::new(); + // Per-group nav, pre-sized to the full group count and indexed by absolute group index so the + // MOPR `group` references line up even when a group file is skipped below. Bounds come from the + // root MOGI (available for every group); flags + portal-ref span are filled from each group + // header as it's read. + let mut group_nav: Vec = (0..root.group_count() as usize) + .map(|gi| { + let (bbox_min, bbox_max) = root + .group_infos() + .get(gi) + .map_or(([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]), |g| { + (g.bbox_min, g.bbox_max) + }); + WmoGroupNav { + flags: 0, + bbox_min, + bbox_max, + ref_start: 0, + ref_count: 0, + area_table_id: 0, + fog_indices: [0; 4], + } + }) + .collect(); + // Per-group walking-collision triangles for the current-group down-ray, indexed by absolute + // group index (the flat collider below is fed from the same gather). + let mut group_collision_tris: Vec> = + vec![Vec::new(); root.group_count() as usize]; + // Collidable triangles flattened across every group (raw WMO-local coords), accumulated as we + // read each group's bytes — no second pass / second read. + let mut col_pos: Vec<[f32; 3]> = Vec::new(); + let mut col_idx: Vec = Vec::new(); + // Camera/LOS gather (keeps DETAIL, drops NOCAMCOLLIDE) — a separate mesh from the walking one. + let mut cam_pos: Vec<[f32; 3]> = Vec::new(); + let mut cam_idx: Vec = Vec::new(); + // Per-group MODR (doodad ownership, transient — consumed into the doodad bases) + MOLR + // (light refs — kept on the asset: the GameObject footprint lane gates its point lobes by + // the hit group's list) + footprint face sets (interior groups only). + let mut group_doodad_refs: Vec> = vec![Vec::new(); root.group_count() as usize]; + let mut group_light_refs: Vec> = vec![Vec::new(); root.group_count() as usize]; + let mut group_liquids: Vec> = + (0..root.group_count()).map(|_| None).collect(); + let mut group_footprints: Vec> = + (0..root.group_count()).map(|_| None).collect(); + for gi in 0..root.group_count() { + let group_url = format!("mpq://{stem}_{gi:03}.wmo"); + let Ok(gbytes) = ctx.read_asset_bytes(group_url).await else { + continue; // a missing/unreadable group is skipped, like the chain reader + }; + // Fill this group's flags + portal-ref span from its MOGP header (what the client's + // visibility pass reads). Bounds were seeded from MOGI above. + if let (Some(h), Some(nav)) = + (wmo_group_header(&gbytes), group_nav.get_mut(gi as usize)) + { + nav.flags = h.flags; + nav.ref_start = h.portal_ref_start; + nav.ref_count = h.portal_ref_count; + nav.area_table_id = h.area_table_id; + nav.fog_indices = h.fog_indices; + } + // Walking gather once per group: the per-group down-ray face set AND the flat collider + // (appended with an index offset) come from the same buffers. + let mut gpos: Vec<[f32; 3]> = Vec::new(); + let mut gidx: Vec = Vec::new(); + accumulate_wmo_group_collision(&gbytes, &mut gpos, &mut gidx); + if let Some(tris) = group_collision_tris.get_mut(gi as usize) { + for t in gidx.chunks_exact(3) { + if let (Some(&a), Some(&b), Some(&c)) = ( + gpos.get(t[0] as usize), + gpos.get(t[1] as usize), + gpos.get(t[2] as usize), + ) { + tris.push([a, b, c]); + } + } + } + let base = col_pos.len() as u32; + col_pos.extend_from_slice(&gpos); + col_idx.extend(gidx.iter().map(|i| i + base)); + accumulate_wmo_group_camera_collision(&gbytes, &mut cam_pos, &mut cam_idx); + let subs = wmo_group_submeshes(&gbytes, &root).map_err(to_io)?; + // This group's MODR (which MODD placements it owns/instantiates) + MOLR (which MOLT + // lights fold into its doodads' committed light) — the per-doodad base resolution below + // consumes both (wow-re trace-forensics-abbey-interior-d3d §1.1/§4). + if let Some(slot) = group_doodad_refs.get_mut(gi as usize) { + *slot = wmo_group_doodad_refs(&gbytes); + } + if let Some(slot) = group_light_refs.get_mut(gi as usize) { + *slot = wmo_group_light_refs(&gbytes); + } + if let Some(slot) = group_footprints.get_mut(gi as usize) { + *slot = wmo_group_footprint_tris(&gbytes); + } + if let Some(slot) = group_liquids.get_mut(gi as usize) { + *slot = wmo_group_liquid_mesh(&gbytes); + } + for (j, sub) in subs.iter().enumerate() { + let mesh = + ctx.add_labeled_asset(format!("group{gi}_sub{j}"), build_submesh_mesh(sub)); + // Lowercase the path: Bevy's loader lookup is case-sensitive and these tables carry + // uppercase `.BLP`, so an uppercase extension falls back to type-based resolution — + // ambiguous with Bevy's built-in image loader, which spams a "Multiple AssetLoaders + // found" warning per texture. Lowercasing matches `BlpImageLoader`'s `blp` + dedupes case. + let texture = sub.texture.as_deref().map(|t| { + ctx.load::(format!( + "mpq://{}", + t.replace('\\', "/").to_ascii_lowercase() + )) + }); + submeshes.push(ModelSubmesh { + skinned_mesh: mesh.clone(), // WMO never skins; alias the static mesh (unused) + mesh, + texture, + skin_slot: sub.skin_slot, // always None for WMO groups (no creature skins) + geoset_id: 0, // WMO has no M2 geoset concept + char_slot: None, // WMO is never a character body + blend: sub.blend, + two_sided: sub.two_sided, + interior: sub.interior, + emissive: sub.emissive, + sidn: sub.sidn, // MOMT SIDN (0x10) — the authored night-glow colour + window: sub.window, // MOMT WINDOW (0x20) — the interior midpoint light + additive: sub.additive, // always false for WMO (additive WMO batches deferred) + no_depth_write: false, // WMO uses the standard opaque/transparent depth state + no_depth_test: false, + fog_policy: sub.fog_policy, // always Scene for WMO (remap_submesh's default) + billboard: None, // WMO geometry isn't billboarded + alpha_anim: None, // WMO batches carry no M2 colour/weight tracks + uv_anim: None, // …nor texture transforms + rgb_anim: None, // …nor M2Color tints + wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law + ground_quad: None, // the fx decal lane is M2-only + }); + submesh_group.push(gi as u16); + } + } + // Resolve every MODD placement's base once — placements, colours, MODR ownership, and MOLR + // light lists are all fixed in the root/groups, so nothing here is ever re-derived. + let doodad_base = resolve_doodad_bases( + root.doodads(), + root.group_infos(), + &group_doodad_refs, + &group_light_refs, + ); + + let collision = (!col_idx.is_empty()).then_some(CollisionMesh { + positions: col_pos, + indices: col_idx, + }); + let collision_camera = (!cam_idx.is_empty()).then_some(CollisionMesh { + positions: cam_pos, + indices: cam_idx, + }); + let portals = root.portals(); + let (group_collision_bounds, collision_bounds) = + collision_tri_bounds(&group_collision_tris); + let group_footprint_bounds = footprint_tri_bounds(&group_footprints); + Ok(WmoModel { + wmo_id: benilla_formats::wmo_root_id(&bytes), + submeshes, + submesh_group, + portal_vertices: portals.vertices.clone(), + portal_infos: portals.infos.clone(), + portal_refs: portals.refs.clone(), + group_nav, + fogs: root.fogs().to_vec(), + group_collision_tris, + group_collision_bounds, + collision_bounds, + collision, + collision_camera, + doodads: root.doodads().to_vec(), + doodad_sets: root.doodad_sets().to_vec(), + lights: parse_wmo_lights(&bytes), + group_bounds: root.group_infos().to_vec(), + doodad_base, + group_footprints, + group_footprint_bounds, + group_light_refs, + group_liquids, + }) + } + + fn extensions(&self) -> &[&str] { + &["wmo"] + } +} + +#[cfg(test)] +mod doodad_base_tests { + use super::*; + + fn grp(interior: bool) -> WmoGroupInfo { + WmoGroupInfo { + interior, + bbox_min: [-1.0; 3], + bbox_max: [1.0; 3], + } + } + + fn prop(color: [u8; 4]) -> WmoDoodad { + WmoDoodad { + model: "candle.m2".into(), + position: [0.0; 3], + orientation: [0.0, 0.0, 0.0, 1.0], + scale: 1.0, + color, + } + } + + /// GOLDEN — the two abbey stands the reference trace decoded (wow-re + /// `trace-forensics-abbey-interior-d3d` §1): MODD[18] colour (78,76,134) → ambient (56,55,96) + /// diffuse raw; MODD[24] colour (90,86,141) → ambient (61,59,96) diffuse raw. Both bit-exact + /// through the `0x6a77e0` fixed-point cap (scales 182 and 173). + #[test] + fn cap96_matches_the_decoded_abbey_stands_bit_exact() { + let as_bytes = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as u8); + assert_eq!(as_bytes(cap96([78, 76, 134])), [56, 55, 96]); + assert_eq!(as_bytes(cap96([90, 86, 141])), [61, 59, 96]); + // max ≤ 96 passes through raw. + assert_eq!(as_bytes(cap96([96, 40, 20])), [96, 40, 20]); + // The diffuse floor: max ≥ 112 is RAW (both stands), below 112 raises hue-preserving. + assert_eq!(as_bytes(floor112([78, 76, 134])), [78, 76, 134]); + assert_eq!(as_bytes(floor112([90, 86, 141])), [90, 86, 141]); + let raised = as_bytes(floor112([56, 28, 14])); + assert_eq!(raised[0], 112); // max channel lands on the 112 value + assert_eq!(raised, [112, 56, 28]); // pure scale — hue/saturation preserved + assert_eq!(as_bytes(floor112([0, 0, 0])), [0, 0, 0]); // black stays black (no divide) + } + + #[test] + fn floor168_matches_the_decoded_abbey_benches_bit_exact() { + // wow-re `wmo-lit-selector.md`: the INNBENCH GameObject draws (14150796/14150859) decode to + // ambient = the raw MOCV (max ≤ 96 → cap96 pass-through) and diffuse = floor168(MOCV) with + // TRUNCATING rounding — 63·168/83 = 127.52 lands on 127 (nearest would give 128). + let as_bytes = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as u8); + assert_eq!(as_bytes(cap96([59, 65, 92])), [59, 65, 92]); + assert_eq!(as_bytes(cap96([69, 63, 83])), [69, 63, 83]); + assert_eq!(as_bytes(floor168([59, 65, 92])), [107, 118, 168]); + assert_eq!(as_bytes(floor168([69, 63, 83])), [139, 127, 168]); + // max ≥ 168 passes through raw. + assert_eq!(as_bytes(floor168([200, 30, 10])), [200, 30, 10]); + } + + /// Ownership is MODR, never spatial: an interior-group doodad takes its MODD colour (cap96 + /// ambient / floor112 diffuse) + its OWNING group's MOLR; an exterior-group doodad is sky-lit; + /// an unreferenced MODD (never instantiated by the reference) defaults exterior. + #[test] + fn modr_ownership_picks_the_lane_and_the_light_refs() { + let doodads = vec![ + prop([90, 86, 141, 255]), // owned by interior group 0 + prop([90, 86, 141, 255]), // owned by exterior group 1 + prop([90, 86, 141, 255]), // referenced by no group + ]; + let groups = [grp(true), grp(false)]; + let modr = vec![vec![0u16], vec![1u16]]; + let molr = vec![vec![7u16, 9u16], vec![3u16]]; + let bases = resolve_doodad_bases(&doodads, &groups, &modr, &molr); + match &bases[0] { + DoodadBase::Interior(b) => { + assert_eq!(b.light_refs, vec![7, 9]); + assert_eq!(b.diffuse.map(|v| (v * 255.0).round() as u8), [90, 86, 141]); + assert_eq!(b.ambient.map(|v| (v * 255.0).round() as u8), [61, 59, 96]); + } + other => panic!("interior-group doodad must take the MODD-colour base, got {other:?}"), + } + assert_eq!(bases[1], DoodadBase::Exterior); + assert_eq!(bases[2], DoodadBase::Exterior); + } + + /// A group with NO MOLR chunk gives its doodads an EMPTY light set — the abbey's group 3 case: + /// the director's stand receives zero point light, its own flame included (machine-zero in the + /// reference decode). + #[test] + fn no_molr_means_no_point_lights_at_all() { + let bases = resolve_doodad_bases( + &[prop([120, 110, 150, 255])], + &[grp(true)], + &[vec![0u16]], + &[Vec::new()], + ); + match &bases[0] { + DoodadBase::Interior(b) => assert!(b.light_refs.is_empty()), + other => panic!("expected interior base, got {other:?}"), + } + } +} diff --git a/crates/benilla-blp/Cargo.toml b/crates/benilla-blp/Cargo.toml new file mode 100644 index 00000000..ef182020 --- /dev/null +++ b/crates/benilla-blp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "benilla-blp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "BLP2 texture decoder for WoW 1.12.1 (build 5875) — in-repo, decision 0021" + +[dependencies] +benilla-bytes = { path = "../benilla-bytes" } +# S3TC/DXT block decompression — the same fixed-point codec wow-blp uses, so DXT output is +# byte-identical. Decompression is a deterministic algorithm (no quantization), unlike encode. +texpresso = "2" diff --git a/crates/benilla-blp/src/bin/blpx.rs b/crates/benilla-blp/src/bin/blpx.rs new file mode 100644 index 00000000..a60b437b --- /dev/null +++ b/crates/benilla-blp/src/bin/blpx.rs @@ -0,0 +1,25 @@ +//! One-off inspection helper (session tool, not shipped): `blpx ` writes mip 0 +//! as P6 PPM, alpha composited over magenta so cutout regions read at a glance (pipe through +//! `sips -s format png` for a viewable PNG). An `` ending in `.rgba` instead dumps mip 0 as +//! raw RGBA8 bytes (width×height×4, row-major) for scripted analysis of the real alpha channel. + +fn main() { + let mut args = std::env::args().skip(1); + let (inp, out) = (args.next().expect("in.blp"), args.next().expect("out.ppm")); + let blp = benilla_blp::decode(&std::fs::read(&inp).expect("read")).expect("decode"); + let mip = &blp.mips[0]; + if out.ends_with(".rgba") { + std::fs::write(&out, &mip.rgba).expect("write"); + println!("{inp} -> {out} ({}x{} raw RGBA8)", mip.width, mip.height); + return; + } + let mut ppm = format!("P6\n{} {}\n255\n", mip.width, mip.height).into_bytes(); + for px in mip.rgba.chunks_exact(4) { + let a = px[3] as u32; + for (c, mag) in px[..3].iter().zip([255u32, 0, 255]) { + ppm.push(((*c as u32 * a + mag * (255 - a)) / 255) as u8); + } + } + std::fs::write(&out, ppm).expect("write"); + println!("{inp} -> {out} ({}x{})", mip.width, mip.height); +} diff --git a/crates/benilla-blp/src/lib.rs b/crates/benilla-blp/src/lib.rs new file mode 100644 index 00000000..86798153 --- /dev/null +++ b/crates/benilla-blp/src/lib.rs @@ -0,0 +1,377 @@ +//! A BLP2 texture decoder for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-blp` (decision +//! 0021). Decodes to `Rgba8Unorm` (the gamma-byte form the renderer uploads verbatim). +//! +//! 1.12 ships **BLP2 / Direct** content in three shapes (no JPEG in practice): +//! - **Raw1** — a 256-entry **BGRA** palette + 1-byte indices, with a separate 0/1/4/8-bit alpha block. +//! - **Raw3** — uncompressed BGRA8. +//! - **DXTC** — S3TC blocks; `alpha_type` selects DXT1 (0) / DXT3 (1) / DXT5 (7), decoded via the same +//! `texpresso` codec `wow-blp` uses, so the output is byte-identical. +//! +//! Two fidelity points that cost the old `wow-blp` two forks, folded in here natively: +//! 1. the palette is **BGRA**, not RGBA (reading it as RGBA swaps R↔B on every palettized Blizzard +//! atlas — the "Westfall clutter is blue" bug); +//! 2. a stale/unknown `alpha_type` byte (e.g. `2` on alpha-less particle atlases) is **not fatal** — +//! alpha presence is governed by `alpha_bits`; `alpha_type` only picks the DXT variant. +//! +//! Proven byte-for-byte against `wow-blp` over the real corpus during the decision-0021 migration +//! (oracle test in git history); the `benilla-formats` texture loaders exercise it on every run. +//! +//! Byte access goes through `benilla-bytes` (decision 0064): every header read is bounds-checked, +//! and `width`/`height` are capped at [`MAX_DIM`] so a corrupt header can't turn into a +//! multi-gigabyte allocation in [`decode_dxt`]. + +use benilla_bytes::ByteExt; + +/// Decode error. Anything outside the 1.12 BLP2 envelope surfaces here rather than guessing. +#[derive(Debug)] +pub enum Error { + NotBlp2, + Truncated(&'static str), + UnknownCompression(u8), + BadColorMap, + OutOfBounds { + level: usize, + }, + /// `width`/`height` from the header exceed [`MAX_DIM`] — most likely a corrupt or hostile + /// header, since no real 1.12 BLP is anywhere near this large. + DimensionsTooLarge { + width: u32, + height: u32, + }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotBlp2 => write!(f, "not a BLP2 file (bad magic)"), + Error::Truncated(what) => write!(f, "truncated BLP: {what}"), + Error::UnknownCompression(c) => write!(f, "unknown BLP compression {c}"), + Error::BadColorMap => write!(f, "BLP color map shorter than 256 entries"), + Error::OutOfBounds { level } => write!(f, "BLP mip level {level} out of bounds"), + Error::DimensionsTooLarge { width, height } => write!( + f, + "BLP dimensions {width}x{height} exceed the {MAX_DIM} sanity cap" + ), + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// A decoded BLP: mip-major `Rgba8Unorm`. `mips[0]` is the full image and is always present; deeper +/// levels follow while the file carries them. [`mip_chain_count`](DecodedBlp::mip_chain_count) is the +/// count `wow-blp` reports for the dimensions (what the renderer's authored-mip upload iterates). +pub struct DecodedBlp { + pub width: u32, + pub height: u32, + /// One RGBA8 buffer per decoded mip level, level 0 first. + pub mips: Vec, + mip_chain_count: usize, +} + +/// One decoded mip level. +pub struct MipLevel { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl DecodedBlp { + /// The authored mip-chain length for these dimensions, matching `wow-blp`'s `mipmaps_count()` + /// (`max(log2 w, log2 h)` when the file has mipmaps, else 0). The renderer's mip-chain upload + /// iterates exactly this many levels; `mips` may hold one more (level 0) when this is 0. + pub fn mip_chain_count(&self) -> usize { + self.mip_chain_count + } +} + +const HEADER_SIZE: usize = 148; // magic..=mip_sizes[16] +const PALETTE_SIZE: usize = 256 * 4; + +/// Sanity cap on header `width`/`height`. Real 1.12 art tops out at 1024×1024; this is pure +/// headroom above any real asset, and its only job is bounding allocation: [`decode_dxt`] sizes +/// both its block-padding buffer and its `w*h*4` RGBA output straight from these fields, so a +/// corrupt header claiming e.g. 65535×65535 would otherwise reserve gigabytes before a single +/// byte of pixel data is checked. +const MAX_DIM: u32 = 8192; + +/// `wow-blp`'s mip count formula (kept bit-identical, float `log2` and all, so the chain length we +/// report matches the oracle exactly). +fn mip_chain_count(width: u32, height: u32, has_mipmaps: bool) -> usize { + if has_mipmaps { + let w = (width as f32).log2() as usize; + let h = (height as f32).log2() as usize; + w.max(h) + } else { + 0 + } +} + +fn level_size(width: u32, height: u32, level: usize) -> (u32, u32) { + if level == 0 { + (width, height) + } else { + ((width >> level).max(1), (height >> level).max(1)) + } +} + +/// Decode a BLP2 texture (raw archive bytes) to RGBA8 mip levels. +pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() < HEADER_SIZE || &bytes[0..4] != b"BLP2" { + return Err(Error::NotBlp2); + } + // content @4 (u32; 1 = Direct — we only handle direct, the only kind 1.12 ships). + // These reads are already covered by the `HEADER_SIZE` length check above; routed through + // `ByteExt` anyway for uniformity (decision 0064) — no future reader should have to ask + // "is this offset guarded?". + let compression = bytes.u8_at(8).ok_or(Error::Truncated("header"))?; + let alpha_bits = bytes.u8_at(9).ok_or(Error::Truncated("header"))? as u32; + let alpha_type = bytes.u8_at(10).ok_or(Error::Truncated("header"))?; + let has_mipmaps = bytes.u8_at(11).ok_or(Error::Truncated("header"))? != 0; + let width = bytes.u32_at(12).ok_or(Error::Truncated("header"))?; + let height = bytes.u32_at(16).ok_or(Error::Truncated("header"))?; + let offsets: Vec = (0..16) + .map(|i| bytes.u32_at(20 + i * 4).ok_or(Error::Truncated("header"))) + .collect::>()?; + let sizes: Vec = (0..16) + .map(|i| bytes.u32_at(84 + i * 4).ok_or(Error::Truncated("header"))) + .collect::>()?; + + // The corrupt-header guard (see `MAX_DIM`): bound before anything downstream sizes an + // allocation from these fields. Zero is left alone — see the decode_dxt/decode_raw1/ + // decode_raw3 zero-pixel behavior, unchanged from before this migration. + if width > MAX_DIM || height > MAX_DIM { + return Err(Error::DimensionsTooLarge { width, height }); + } + + // BLP2 direct content always carries a 256-entry palette right after the header (used only by + // Raw1; present-but-ignored for Raw3/DXT). Read as BGRA bytes. + let palette = bytes + .get(HEADER_SIZE..HEADER_SIZE + PALETTE_SIZE) + .ok_or(Error::BadColorMap)?; + + let chain = mip_chain_count(width, height, has_mipmaps); + // Always decode level 0 (blp_to_rgba needs it even when the chain count is 0); then deeper levels + // up to the chain count, stopping if the file doesn't carry one. + let n = chain.clamp(1, 16); + + let mut mips = Vec::with_capacity(n); + for level in 0..n { + let (lw, lh) = level_size(width, height, level); + let off = offsets[level] as usize; + let sz = sizes[level] as usize; + if level > 0 && (sz == 0 || off == 0) { + break; // chain ends early (some textures stop before the formula's count) + } + let data = bytes + .get(off..off + sz) + .ok_or(Error::OutOfBounds { level })?; + let rgba = match compression { + 1 => decode_raw1(palette, data, lw, lh, alpha_bits), + 2 => decode_dxt(data, lw, lh, alpha_type), + 3 => decode_raw3(data, lw, lh), + other => return Err(Error::UnknownCompression(other)), + }?; + mips.push(MipLevel { + width: lw, + height: lh, + rgba, + }); + } + + Ok(DecodedBlp { + width, + height, + mips, + mip_chain_count: chain, + }) +} + +/// Palettized: 1-byte indices into a 256-entry BGRA palette, then a packed alpha block (`alpha_bits`). +fn decode_raw1(palette: &[u8], data: &[u8], w: u32, h: u32, alpha_bits: u32) -> Result> { + let px = (w as usize) * (h as usize); + let indices = data.get(..px).ok_or(Error::Truncated("raw1 indices"))?; + let alpha = &data[px..]; + let mut out = vec![0u8; px * 4]; + for i in 0..px { + let ci = indices[i] as usize; + let p = ci * 4; + // Palette is BGRA: byte0=B, byte1=G, byte2=R. (The fork fix — RGBA reading swaps R↔B.) + out[i * 4] = palette[p + 2]; // R + out[i * 4 + 1] = palette[p + 1]; // G + out[i * 4 + 2] = palette[p]; // B + out[i * 4 + 3] = alpha_at(alpha, i, alpha_bits); + } + Ok(out) +} + +/// Per-pixel alpha from the packed block. `0` ⇒ opaque (no alpha channel); `1/4/8` ⇒ packed bits. +fn alpha_at(alpha: &[u8], i: usize, alpha_bits: u32) -> u8 { + match alpha_bits { + 1 => { + let bit = (alpha.get(i / 8).copied().unwrap_or(0) >> (i % 8)) & 1; + if bit == 1 { + 255 + } else { + 0 + } + } + 4 => { + let block = alpha.get(i / 2).copied().unwrap_or(0); + let nib = if i.is_multiple_of(2) { + block & 0x0F + } else { + block >> 4 + }; + (nib << 4) | nib + } + 8 => alpha.get(i).copied().unwrap_or(255), + _ => 255, // alpha_bits == 0: fully opaque + } +} + +/// Uncompressed BGRA8 → RGBA8. +fn decode_raw3(data: &[u8], w: u32, h: u32) -> Result> { + let px = (w as usize) * (h as usize); + let src = data.get(..px * 4).ok_or(Error::Truncated("raw3 pixels"))?; + let mut out = vec![0u8; px * 4]; + for i in 0..px { + out[i * 4] = src[i * 4 + 2]; // R + out[i * 4 + 1] = src[i * 4 + 1]; // G + out[i * 4 + 2] = src[i * 4]; // B + out[i * 4 + 3] = src[i * 4 + 3]; // A + } + Ok(out) +} + +/// DXTC: alpha_type 0→DXT1(Bc1), 1→DXT3(Bc2), 7→DXT5(Bc3); anything else (stale byte) → DXT1, since +/// `alpha_bits` already governs whether alpha is meaningful. Undersized small-mip data is zero-padded +/// to the block size (matching wow-blp / SereniaBLPLib), so a short tail mip still decodes. +fn decode_dxt(data: &[u8], w: u32, h: u32, alpha_type: u8) -> Result> { + let fmt = match alpha_type { + 1 => texpresso::Format::Bc2, + 7 => texpresso::Format::Bc3, + _ => texpresso::Format::Bc1, + }; + let need = fmt.compressed_size(w as usize, h as usize); + let blocks = if data.len() < need { + let mut p = vec![0u8; need]; + p[..data.len()].copy_from_slice(data); + std::borrow::Cow::Owned(p) + } else { + std::borrow::Cow::Borrowed(data) + }; + let mut out = vec![0u8; (w as usize) * (h as usize) * 4]; + fmt.decompress(&blocks, w as usize, h as usize, &mut out); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal-but-complete BLP2 header (magic..=mip_sizes[16], `HEADER_SIZE` bytes). + #[allow(clippy::too_many_arguments)] + fn header( + compression: u8, + alpha_bits: u8, + alpha_type: u8, + has_mipmaps: u8, + width: u32, + height: u32, + offsets: [u32; 16], + sizes: [u32; 16], + ) -> Vec { + let mut b = vec![0u8; HEADER_SIZE]; + b[0..4].copy_from_slice(b"BLP2"); + b[4..8].copy_from_slice(&1u32.to_le_bytes()); // content: 1 = Direct + b[8] = compression; + b[9] = alpha_bits; + b[10] = alpha_type; + b[11] = has_mipmaps; + b[12..16].copy_from_slice(&width.to_le_bytes()); + b[16..20].copy_from_slice(&height.to_le_bytes()); + for (i, o) in offsets.iter().enumerate() { + b[20 + i * 4..24 + i * 4].copy_from_slice(&o.to_le_bytes()); + } + for (i, s) in sizes.iter().enumerate() { + b[84 + i * 4..88 + i * 4].copy_from_slice(&s.to_le_bytes()); + } + b + } + + #[test] + fn corrupt_header_huge_dims_errors_cleanly() { + // The fix under test: a header claiming an absurd width/height must fail fast, not size a + // multi-gigabyte allocation in decode_dxt. No palette/pixel bytes needed — the dimension + // check runs before either is touched. + let b = header(3, 0, 0, 0, 65535, 65535, [0; 16], [0; 16]); + assert!(matches!( + decode(&b), + Err(Error::DimensionsTooLarge { + width: 65535, + height: 65535 + }) + )); + // Right at the cap is fine (still fails later for lack of palette/pixel bytes, but not on + // the dimension check); just over the cap on either axis alone is rejected. + let b = header(3, 0, 0, 0, MAX_DIM + 1, 4, [0; 16], [0; 16]); + assert!(matches!(decode(&b), Err(Error::DimensionsTooLarge { .. }))); + let b = header(3, 0, 0, 0, 4, MAX_DIM + 1, [0; 16], [0; 16]); + assert!(matches!(decode(&b), Err(Error::DimensionsTooLarge { .. }))); + } + + #[test] + fn truncated_header_errors_cleanly() { + // Shorter than HEADER_SIZE, even with a valid magic, is rejected before any field read. + let mut b = vec![0u8; 10]; + b[0..4].copy_from_slice(b"BLP2"); + assert!(matches!(decode(&b), Err(Error::NotBlp2))); + assert!(matches!(decode(&[]), Err(Error::NotBlp2))); + assert!(matches!(decode(b"not a blp at all"), Err(Error::NotBlp2))); + } + + #[test] + fn tiny_raw3_decodes_expected_pixels() { + // A 2x2 uncompressed BGRA8 (Raw3) image, no mipmaps: the smallest concrete shape decode() + // handles. Pixel data sits right after the header + 256-entry BGRA palette. + let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32; + let pixel_size = 2 * 2 * 4; + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + offsets[0] = pixel_offset; + sizes[0] = pixel_size; + let mut b = header(3, 8, 0, 0, 2, 2, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); // palette bytes: unused by Raw3, present anyway + // Four BGRA8 pixels. + let bgra: &[u8] = &[ + 10, 20, 30, 40, // B G R A + 50, 60, 70, 80, // + 90, 100, 110, 120, // + 130, 140, 150, 160, + ]; + b.extend_from_slice(bgra); + + let decoded = decode(&b).expect("valid tiny BLP decodes"); + assert_eq!(decoded.width, 2); + assert_eq!(decoded.height, 2); + assert_eq!(decoded.mip_chain_count(), 0); // no mipmaps: chain formula reports 0 + assert_eq!(decoded.mips.len(), 1); // level 0 is still decoded regardless + + let mip = &decoded.mips[0]; + assert_eq!(mip.width, 2); + assert_eq!(mip.height, 2); + // BGRA -> RGBA per pixel. + assert_eq!( + mip.rgba, + vec![ + 30, 20, 10, 40, // R G B A + 70, 60, 50, 80, // + 110, 100, 90, 120, // + 150, 140, 130, 160, + ] + ); + } +} diff --git a/crates/benilla-bytes/Cargo.toml b/crates/benilla-bytes/Cargo.toml new file mode 100644 index 00000000..1f48c4bc --- /dev/null +++ b/crates/benilla-bytes/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "benilla-bytes" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Bounds-checked byte readers + IFF chunk iteration shared by the format parsers — decision 0064" + +[dependencies] diff --git a/crates/benilla-bytes/src/lib.rs b/crates/benilla-bytes/src/lib.rs new file mode 100644 index 00000000..9aad5a8a --- /dev/null +++ b/crates/benilla-bytes/src/lib.rs @@ -0,0 +1,152 @@ +//! Bounds-checked byte readers + IFF chunk iteration shared by the format parsers +//! (decision 0064). Three primitives, nothing else: +//! +//! - [`ByteExt`] — fallible little-endian accessors on `[u8]`. There is deliberately **no** +//! panicking variant: parsers map `None` to their own truncation error with `?`, so an unguarded +//! offset is a compile-shape impossibility, not a convention. +//! - [`chunks`] — the IFF `(magic, payload)` iterator every chunked WoW format walks. **Lenient by +//! documented property**: iteration stops cleanly at a truncated 8-byte header, and a payload +//! whose declared size overruns the buffer is clamped to the buffer end (real 1.12 art needs the +//! tolerance — e.g. the trailing-junk chunks some Classic files carry). +//! - [`capped`] — the allocation guard for count-driven `Vec::with_capacity`: reserve at most what +//! the remaining input could possibly hold, so a corrupt header count can never reserve more +//! than the file's own size. +//! +//! Magic bytes are yielded exactly as stored (WoW writes IFF magics reversed on disk, so callers +//! match `b"DHOM"` for MOHD — same as the pre-0064 per-crate iterators). + +/// Fallible little-endian reads at an offset. `None` iff the read would run past the end. +pub trait ByteExt { + fn u8_at(&self, o: usize) -> Option; + fn u16_at(&self, o: usize) -> Option; + fn u32_at(&self, o: usize) -> Option; + fn i32_at(&self, o: usize) -> Option; + fn f32_at(&self, o: usize) -> Option; + /// A length-checked sub-slice: `n` bytes starting at `o`. + fn bytes_at(&self, o: usize, n: usize) -> Option<&[u8]>; +} + +impl ByteExt for [u8] { + #[inline] + fn u8_at(&self, o: usize) -> Option { + self.get(o).copied() + } + #[inline] + fn u16_at(&self, o: usize) -> Option { + Some(u16::from_le_bytes( + self.get(o..o.checked_add(2)?)?.try_into().ok()?, + )) + } + #[inline] + fn u32_at(&self, o: usize) -> Option { + Some(u32::from_le_bytes( + self.get(o..o.checked_add(4)?)?.try_into().ok()?, + )) + } + #[inline] + fn i32_at(&self, o: usize) -> Option { + self.u32_at(o).map(|v| v as i32) + } + #[inline] + fn f32_at(&self, o: usize) -> Option { + self.u32_at(o).map(f32::from_bits) + } + #[inline] + fn bytes_at(&self, o: usize, n: usize) -> Option<&[u8]> { + self.get(o..o.checked_add(n)?) + } +} + +/// Iterate IFF chunks as `(magic-as-stored, payload)`. See the module doc for the (deliberate) +/// leniency: truncated header → clean stop; over-declared payload → clamped to the buffer end. +pub fn chunks(b: &[u8]) -> impl Iterator { + let mut pos = 0usize; + std::iter::from_fn(move || { + if pos.checked_add(8)? > b.len() { + return None; + } + let magic = [b[pos], b[pos + 1], b[pos + 2], b[pos + 3]]; + let size = b.u32_at(pos + 4)? as usize; + let start = pos + 8; + // `start <= b.len()` (checked above) and `size` came from a u32, so on 64-bit targets the + // sum cannot wrap; saturate anyway so the invariant doesn't depend on pointer width. + let end = start.saturating_add(size).min(b.len()); + pos = end; + Some((magic, &b[start..end])) + }) +} + +/// Cap a count-driven reservation by what `avail` remaining bytes could possibly hold, so a corrupt +/// header can at worst reserve the input's own size. `elem_size` of 0 is a caller bug; treat it as 1 +/// rather than divide by zero. The *count* used for iteration stays the caller's — only the +/// up-front reservation is capped (a short file then fails at the bounds-checked read, not in the +/// allocator). +pub fn capped(count: usize, elem_size: usize, avail: usize) -> usize { + count.min(avail / elem_size.max(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_in_bounds() { + let b: &[u8] = &[0x01, 0x02, 0x03, 0x04, 0x05]; + assert_eq!(b.u8_at(4), Some(0x05)); + assert_eq!(b.u16_at(0), Some(0x0201)); + assert_eq!(b.u32_at(1), Some(0x05040302)); + assert_eq!(b.i32_at(0), Some(0x04030201)); + assert_eq!(b.f32_at(0), Some(f32::from_bits(0x04030201))); + assert_eq!(b.bytes_at(2, 3), Some(&b[2..5])); + } + + #[test] + fn reads_out_of_bounds_are_none() { + let b: &[u8] = &[0x01, 0x02, 0x03]; + assert_eq!(b.u8_at(3), None); + assert_eq!(b.u16_at(2), None); + assert_eq!(b.u32_at(0), None); + assert_eq!(b.bytes_at(1, 3), None); + // Offsets near usize::MAX must not wrap into a "valid" range. + assert_eq!(b.u32_at(usize::MAX - 1), None); + assert_eq!(b.bytes_at(usize::MAX, 8), None); + } + + #[test] + fn chunk_iteration_walks_well_formed_input() { + // Two chunks: "ABCD" (2 bytes) then "EFGH" (0 bytes). + let mut b = Vec::new(); + b.extend_from_slice(b"ABCD"); + b.extend_from_slice(&2u32.to_le_bytes()); + b.extend_from_slice(&[0xAA, 0xBB]); + b.extend_from_slice(b"EFGH"); + b.extend_from_slice(&0u32.to_le_bytes()); + let got: Vec<_> = chunks(&b).collect(); + assert_eq!(got.len(), 2); + assert_eq!((got[0].0, got[0].1), (*b"ABCD", &[0xAA, 0xBB][..])); + assert_eq!((got[1].0, got[1].1), (*b"EFGH", &[][..])); + } + + #[test] + fn chunk_iteration_is_lenient() { + // Over-declared size clamps to the buffer end. + let mut b = Vec::new(); + b.extend_from_slice(b"ABCD"); + b.extend_from_slice(&u32::MAX.to_le_bytes()); + b.extend_from_slice(&[0x01]); + let got: Vec<_> = chunks(&b).collect(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].1, &[0x01][..]); + // A truncated header (< 8 bytes remaining) stops iteration cleanly. + assert_eq!(chunks(&b[..7]).count(), 0); + assert_eq!(chunks(&[]).count(), 0); + } + + #[test] + fn capped_bounds_reservations() { + assert_eq!(capped(10, 4, 1000), 10); // plausible count passes through + assert_eq!(capped(u32::MAX as usize, 48, 96), 2); // corrupt count → what fits + assert_eq!(capped(5, 0, 16), 5); // elem_size 0 treated as 1, no div-by-zero + assert_eq!(capped(5, 4, 0), 0); // empty input reserves nothing + } +} diff --git a/crates/benilla-dbc/Cargo.toml b/crates/benilla-dbc/Cargo.toml new file mode 100644 index 00000000..b5bd99f2 --- /dev/null +++ b/crates/benilla-dbc/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "benilla-dbc" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WDBC (client DBC) reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021" + +[dependencies] +benilla-bytes = { path = "../benilla-bytes" } diff --git a/crates/benilla-dbc/src/lib.rs b/crates/benilla-dbc/src/lib.rs new file mode 100644 index 00000000..37617f42 --- /dev/null +++ b/crates/benilla-dbc/src/lib.rs @@ -0,0 +1,620 @@ +//! A WDBC (client DBC) reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-cdbc` +//! (decision 0021). +//! +//! A DBC is dead simple: a 20-byte `WDBC` header (record count, field count, record size, string-block +//! size), then `record_count × record_size` bytes of fixed-width **4-byte** fields, then a string +//! block. The file carries no column *types*, so the caller supplies a [`Schema`] (its expanded field +//! count must equal the header's `field_count`); each field is read as `u32`/`i32`/`f32`, or as a +//! [`StringRef`] offset into the string block. Layouts are validated clean-room against build 5875. +//! +//! Proven against `wow-cdbc` over real DBCs during the decision-0021 migration (oracle test in git +//! history); the catalog loaders in `benilla-formats` (Map/Light/Creature/GameObject/…) pin exact +//! decoded values end-to-end on every run. +//! +//! Byte access goes through `benilla-bytes` (decision 0064): header reads are bounds-checked, the +//! `record_count × record_size + string_block_size` size arithmetic is overflow-checked (a corrupt +//! header used to wrap past the parse-time guard and panic on the later re-slice), and record +//! reservations are capped by what the body could actually hold. + +use std::io::{Cursor, Write}; + +use benilla_bytes::{capped, ByteExt}; + +/// Column type for a DBC field. Every field is 4 bytes on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FieldType { + UInt32, + Int32, + Float32, + /// A `u32` offset into the string block. + String, +} + +/// One schema field: a name, a type, and a repeat `count` (1 for a scalar; N for an inline array, which +/// occupies N consecutive 4-byte slots). +#[derive(Debug, Clone)] +pub struct SchemaField { + pub name: String, + pub ty: FieldType, + pub count: usize, +} + +impl SchemaField { + pub fn new(name: impl Into, ty: FieldType) -> Self { + Self { + name: name.into(), + ty, + count: 1, + } + } + + pub fn new_array(name: impl Into, ty: FieldType, count: usize) -> Self { + Self { + name: name.into(), + ty, + count, + } + } +} + +/// A hand-supplied description of a DBC's columns (the file has none). +#[derive(Debug, Clone, Default)] +pub struct Schema { + pub name: String, + pub fields: Vec, + pub key_field: Option, +} + +impl Schema { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + fields: Vec::new(), + key_field: None, + } + } + + pub fn add_field(&mut self, field: SchemaField) { + self.fields.push(field); + } + + pub fn set_key_field(&mut self, name: impl Into) { + self.key_field = Some(name.into()); + } + + /// Total 4-byte slots this schema covers (arrays expanded) — must equal the header `field_count`. + fn expanded_len(&self) -> usize { + self.fields.iter().map(|f| f.count).sum() + } +} + +/// A `u32` offset into a record set's string block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StringRef(pub u32); + +/// One decoded field value. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Value { + UInt32(u32), + Int32(i32), + Float32(f32), + StringRef(StringRef), +} + +/// One decoded record: a flat list of [`Value`]s, one per expanded field slot. +#[derive(Debug, Clone)] +pub struct Record { + values: Vec, +} + +impl Record { + /// The value at expanded slot `i` (arrays count as `count` consecutive slots). + pub fn get_value(&self, i: usize) -> Option<&Value> { + self.values.get(i) + } +} + +/// The 20-byte WDBC header. +#[derive(Debug, Clone, Copy)] +pub struct Header { + pub record_count: u32, + pub field_count: u32, + pub record_size: u32, + pub string_block_size: u32, +} + +/// Errors from DBC parsing. +#[derive(Debug)] +pub enum Error { + NotWdbc, + Truncated(&'static str), + /// Schema's expanded field count doesn't match the file's `field_count`. + SchemaFieldMismatch { + schema: usize, + file: u32, + }, + BadStringRef(u32), + /// `record_count × record_size + string_block_size` overflows `usize` — a corrupt header, not a + /// real file (see decision 0064: this used to wrap silently and panic on the later re-slice). + SizeOverflow, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotWdbc => write!(f, "not a WDBC file (bad magic)"), + Error::Truncated(what) => write!(f, "truncated DBC: {what}"), + Error::SchemaFieldMismatch { schema, file } => { + write!(f, "schema has {schema} fields but file has {file}") + } + Error::BadStringRef(off) => write!(f, "string ref {off} out of bounds"), + Error::SizeOverflow => { + write!( + f, + "record_count * record_size + string_block_size overflows usize" + ) + } + } + } +} + +impl std::error::Error for Error {} + +type Result = std::result::Result; + +/// Read a little-endian u32, zero-extending any bytes past the end of `b` (a record whose +/// `record_size` is shorter than `field_count × 4` — see the parse loop). This deliberately differs +/// from [`ByteExt::u32_at`], which is all-or-nothing (`None` on any out-of-range byte): a short tail +/// here is a legitimate, previously-observed record shape, not truncation, so it zero-pads instead of +/// failing. Built on the same bounds-checked primitive (`u8_at`) byte-by-byte so there is no raw +/// indexing left to panic. +fn rd_u32_at(b: &[u8], o: usize) -> u32 { + let mut bytes = [0u8; 4]; + for (k, slot) in bytes.iter_mut().enumerate() { + if let Some(v) = o.checked_add(k).and_then(|idx| b.u8_at(idx)) { + *slot = v; + } + } + u32::from_le_bytes(bytes) +} + +/// The shared `record_count × record_size (+ string_block_size)` layout arithmetic, checked — +/// [`Header::record_count`]/`record_size`/`string_block_size` are attacker-controlled `u32`s, and a +/// corrupt combination must error, never silently wrap past a size guard onto a later panicking +/// re-slice (decision 0064: this is the verified bug the migration fixes). Both `parse` and +/// `parse_records` call this one function so the two can never disagree even if the code drifts +/// later. Returns `(record_bytes_len, record_bytes_len + string_block_size)`. +fn checked_body_layout( + record_count: usize, + record_size: usize, + string_block_size: usize, +) -> Option<(usize, usize)> { + let record_bytes_len = record_count.checked_mul(record_size)?; + let total = record_bytes_len.checked_add(string_block_size)?; + Some((record_bytes_len, total)) +} + +/// A DBC opened for reading: header parsed, body+strings located, schema optional until applied. +pub struct DbcParser<'a> { + header: Header, + /// Everything after the 20-byte header: `record_count × record_size` record bytes + string block. + body: &'a [u8], + schema: Option, +} + +impl<'a> DbcParser<'a> { + /// Parse the header from a cursor over the whole DBC bytes (position is ignored; the file starts + /// with the header). Kept cursor-shaped to match the call sites. + pub fn parse(cursor: &mut Cursor<&'a [u8]>) -> Result { + let all: &'a [u8] = cursor.get_ref(); + if all.len() < 20 || &all[0..4] != b"WDBC" { + return Err(Error::NotWdbc); + } + let header = Header { + record_count: all.u32_at(4).ok_or(Error::Truncated("header"))?, + field_count: all.u32_at(8).ok_or(Error::Truncated("header"))?, + record_size: all.u32_at(12).ok_or(Error::Truncated("header"))?, + string_block_size: all.u32_at(16).ok_or(Error::Truncated("header"))?, + }; + let body = all.get(20..).ok_or(Error::Truncated("body"))?; + let (_, need) = checked_body_layout( + header.record_count as usize, + header.record_size as usize, + header.string_block_size as usize, + ) + .ok_or(Error::SizeOverflow)?; + if body.len() < need { + return Err(Error::Truncated("records + string block")); + } + Ok(Self { + header, + body, + schema: None, + }) + } + + pub fn header(&self) -> &Header { + &self.header + } + + /// Attach a schema, validating its expanded field count against the file header. + pub fn with_schema(mut self, schema: Schema) -> Result { + let n = schema.expanded_len(); + if n != self.header.field_count as usize { + return Err(Error::SchemaFieldMismatch { + schema: n, + file: self.header.field_count, + }); + } + self.schema = Some(schema); + Ok(self) + } + + /// Decode all records into an owned [`RecordSet`] (records + a copy of the string block). + pub fn parse_records(&self) -> Result { + let schema = self.schema.as_ref().ok_or(Error::Truncated("no schema"))?; + let rc = self.header.record_count as usize; + let rs = self.header.record_size as usize; + let fc = self.header.field_count as usize; + // Same checked arithmetic `parse` used to size-check the header — recomputed here (not + // assumed) so the two can never disagree even if the code drifts later. + let (record_bytes_len, strings_end) = + checked_body_layout(rc, rs, self.header.string_block_size as usize) + .ok_or(Error::SizeOverflow)?; + let records_bytes = self + .body + .get(..record_bytes_len) + .ok_or(Error::Truncated("records"))?; + let strings = self + .body + .get(record_bytes_len..strings_end) + .ok_or(Error::Truncated("string block"))? + .to_vec(); + + // Expand the schema into a per-slot type list (arrays → repeated type) + per-slot column names. + let mut types = Vec::with_capacity(fc); + let mut names = Vec::with_capacity(fc); + for field in &schema.fields { + for k in 0..field.count { + types.push(field.ty); + names.push(if field.count == 1 { + field.name.clone() + } else { + format!("{}[{k}]", field.name) + }); + } + } + + // `rc` comes straight from the header; a corrupt `record_size` of 0 would otherwise let it + // pass the size guard unbounded (0 * anything fits). Cap the reservation by what the record + // bytes could actually hold — a short body then fails at the bounds-checked read below, not + // in the allocator. + let mut records = Vec::with_capacity(capped(rc, rs, records_bytes.len())); + for r in 0..rc { + let base = r * rs; + let mut values = Vec::with_capacity(fc); + for (i, ty) in types.iter().enumerate() { + let raw = rd_u32_at(records_bytes, base + i * 4); + values.push(match ty { + FieldType::UInt32 => Value::UInt32(raw), + FieldType::Int32 => Value::Int32(raw as i32), + FieldType::Float32 => Value::Float32(f32::from_bits(raw)), + FieldType::String => Value::StringRef(StringRef(raw)), + }); + } + records.push(Record { values }); + } + + Ok(RecordSet { + field_names: names, + records, + strings, + }) + } +} + +/// A fully-decoded DBC: records plus the string block they reference. +pub struct RecordSet { + field_names: Vec, + records: Vec, + strings: Vec, +} + +impl RecordSet { + pub fn records(&self) -> &[Record] { + &self.records + } + + /// Resolve a [`StringRef`] to its NUL-terminated string in the block (UTF-8, lossy). + pub fn get_string(&self, r: StringRef) -> Result> { + let off = r.0 as usize; + if off > self.strings.len() { + return Err(Error::BadStringRef(r.0)); + } + let end = self.strings[off..] + .iter() + .position(|&b| b == 0) + .map(|p| off + p) + .unwrap_or(self.strings.len()); + Ok(String::from_utf8_lossy(&self.strings[off..end])) + } +} + +/// Write a record set as CSV (header row of column names, then one row per record; string refs +/// resolved). Used by the `benilla-extract` CLI. Minimal RFC-4180 quoting. +pub fn export_to_csv(rs: &RecordSet, mut w: W) -> std::io::Result<()> { + writeln!(w, "{}", rs.field_names.join(","))?; + for record in &rs.records { + let cells: Vec = record + .values + .iter() + .map(|v| match v { + Value::UInt32(x) => x.to_string(), + Value::Int32(x) => x.to_string(), + Value::Float32(x) => x.to_string(), + Value::StringRef(sr) => csv_quote(&rs.get_string(*sr).unwrap_or_default()), + }) + .collect(); + writeln!(w, "{}", cells.join(","))?; + } + Ok(()) +} + +fn csv_quote(s: &str) -> String { + if s.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal WDBC: 20-byte header + record bytes + string block. + fn build_wdbc( + record_count: u32, + field_count: u32, + record_size: u32, + records: &[u8], + strings: &[u8], + ) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"WDBC"); + b.extend_from_slice(&record_count.to_le_bytes()); + b.extend_from_slice(&field_count.to_le_bytes()); + b.extend_from_slice(&record_size.to_le_bytes()); + b.extend_from_slice(&(strings.len() as u32).to_le_bytes()); + b.extend_from_slice(records); + b.extend_from_slice(strings); + b + } + + /// Two fields per record: `id` (UInt32), `name` (a string-block offset). + fn id_name_schema() -> Schema { + let mut s = Schema::new("Test"); + s.add_field(SchemaField::new("id", FieldType::UInt32)); + s.add_field(SchemaField::new("name", FieldType::String)); + s + } + + #[test] + fn parses_minimal_synthetic_wdbc() { + let strings = b"Alice\0Bob\0"; + let mut records = Vec::new(); + records.extend_from_slice(&1u32.to_le_bytes()); // id + records.extend_from_slice(&0u32.to_le_bytes()); // name -> "Alice" @0 + records.extend_from_slice(&2u32.to_le_bytes()); // id + records.extend_from_slice(&6u32.to_le_bytes()); // name -> "Bob" @6 + let bytes = build_wdbc(2, 2, 8, &records, strings); + + let parser = DbcParser::parse(&mut Cursor::new(bytes.as_slice())).expect("parses"); + assert_eq!(parser.header().record_count, 2); + assert_eq!(parser.header().field_count, 2); + + let rs = parser + .with_schema(id_name_schema()) + .expect("schema matches header field_count") + .parse_records() + .expect("decodes"); + assert_eq!(rs.records().len(), 2); + + let r0 = &rs.records()[0]; + assert_eq!(r0.get_value(0), Some(&Value::UInt32(1))); + let Some(Value::StringRef(name0)) = r0.get_value(1).copied() else { + panic!("expected a string ref"); + }; + assert_eq!(rs.get_string(name0).unwrap(), "Alice"); + + let r1 = &rs.records()[1]; + assert_eq!(r1.get_value(0), Some(&Value::UInt32(2))); + let Some(Value::StringRef(name1)) = r1.get_value(1).copied() else { + panic!("expected a string ref"); + }; + assert_eq!(rs.get_string(name1).unwrap(), "Bob"); + } + + #[test] + fn rd_u32_at_zero_extends_short_tail() { + // Preserves the pre-0064 semantics exactly: a read that runs past the end zero-pads the + // missing bytes rather than failing, unlike `ByteExt::u32_at` (all-or-nothing). + let b = [0xAA, 0xBB]; + assert_eq!(rd_u32_at(&b, 0), 0x0000_BBAA); + assert_eq!(rd_u32_at(&b, 1), 0x0000_00BB); + assert_eq!(rd_u32_at(&b, 2), 0); // fully past the end + assert_eq!(rd_u32_at(&b, 5), 0); // offset itself already past the end + } + + #[test] + fn header_size_arithmetic_overflow_errors_cleanly_not_panic() { + // Header fields are u32, so on a 64-bit host record_count*record_size+string_block_size can + // never actually overflow a 64-bit usize (u32::MAX^2 + u32::MAX < usize::MAX) — the real + // trigger is a 32-bit target wrapping in release, which is exactly what decision 0064 flags. + // Exercise the shared checked arithmetic directly with usize inputs a real u32 header could + // never produce on this host, to pin that it errors instead of silently wrapping. + assert_eq!(checked_body_layout(usize::MAX, 2, 0), None); + assert_eq!(checked_body_layout(4, 4, usize::MAX), None); + assert_eq!(checked_body_layout(usize::MAX, 1, 1), None); + assert_eq!(checked_body_layout(10, 4, 6), Some((40, 46))); + + // And through the public API: a header whose declared size can't be satisfied by the (valid, + // in-bounds) body still errors cleanly rather than panicking on a later re-slice. + let bytes = build_wdbc(0, 0, 0, &[], &[]); + assert!(DbcParser::parse(&mut Cursor::new(bytes.as_slice())).is_ok()); + } + + #[test] + fn truncated_body_errors_cleanly_not_panic() { + // Header claims 2 records of 8 bytes + a 10-byte string block, but the body holds only one + // record's worth of bytes and no string block at all. + let bytes = build_wdbc(2, 2, 8, &[0u8; 8], &[]); + let err = DbcParser::parse(&mut Cursor::new(bytes.as_slice())) + .err() + .unwrap(); + assert!(matches!(err, Error::Truncated("records + string block"))); + } + + #[test] + fn hostile_shapes_do_not_panic() { + assert!(matches!( + DbcParser::parse(&mut Cursor::new(&[][..])), + Err(Error::NotWdbc) + )); + assert!(matches!( + DbcParser::parse(&mut Cursor::new(&[0u8; 19][..])), // one short of the 20-byte header + Err(Error::NotWdbc) + )); + let mut bad_magic = vec![0u8; 20]; + bad_magic[0..4].copy_from_slice(b"XXXX"); + assert!(matches!( + DbcParser::parse(&mut Cursor::new(bad_magic.as_slice())), + Err(Error::NotWdbc) + )); + } + + #[test] + fn schema_field_count_must_match_header() { + // Header declares 2 fields; a 3-slot schema is rejected before any record is touched. + let bytes = build_wdbc(1, 2, 8, &[0u8; 8], &[]); + let parser = DbcParser::parse(&mut Cursor::new(bytes.as_slice())).unwrap(); + let mut wide = Schema::new("Wide"); + wide.add_field(SchemaField::new("a", FieldType::UInt32)); + wide.add_field(SchemaField::new("b", FieldType::UInt32)); + wide.add_field(SchemaField::new("c", FieldType::UInt32)); + assert!(matches!( + parser.with_schema(wide), + Err(Error::SchemaFieldMismatch { schema: 3, file: 2 }) + )); + } + + #[test] + fn array_field_expands_to_consecutive_slots() { + // One record: a scalar `id` then a 3-wide `coords` array = 4 field slots. + let mut records = Vec::new(); + records.extend_from_slice(&7u32.to_le_bytes()); // id + records.extend_from_slice(&10u32.to_le_bytes()); // coords[0] + records.extend_from_slice(&20u32.to_le_bytes()); // coords[1] + records.extend_from_slice(&30u32.to_le_bytes()); // coords[2] + let bytes = build_wdbc(1, 4, 16, &records, &[]); + + let mut schema = Schema::new("Arr"); + schema.add_field(SchemaField::new("id", FieldType::UInt32)); + schema.add_field(SchemaField::new_array("coords", FieldType::UInt32, 3)); + + let rs = DbcParser::parse(&mut Cursor::new(bytes.as_slice())) + .unwrap() + .with_schema(schema) + .unwrap() + .parse_records() + .unwrap(); + // Array occupies slots 1..=3, and the expanded column names carry the `[k]` index. + assert_eq!( + rs.field_names, + ["id", "coords[0]", "coords[1]", "coords[2]"] + ); + let r = &rs.records()[0]; + assert_eq!(r.get_value(0), Some(&Value::UInt32(7))); + assert_eq!(r.get_value(1), Some(&Value::UInt32(10))); + assert_eq!(r.get_value(3), Some(&Value::UInt32(30))); + assert_eq!(r.get_value(4), None); // past the last slot + } + + #[test] + fn int32_and_float32_fields_decode() { + let mut records = Vec::new(); + records.extend_from_slice(&(-5i32).to_le_bytes()); + records.extend_from_slice(&1.5f32.to_bits().to_le_bytes()); + let bytes = build_wdbc(1, 2, 8, &records, &[]); + + let mut schema = Schema::new("Nums"); + schema.add_field(SchemaField::new("signed", FieldType::Int32)); + schema.add_field(SchemaField::new("ratio", FieldType::Float32)); + + let rs = DbcParser::parse(&mut Cursor::new(bytes.as_slice())) + .unwrap() + .with_schema(schema) + .unwrap() + .parse_records() + .unwrap(); + let r = &rs.records()[0]; + assert_eq!(r.get_value(0), Some(&Value::Int32(-5))); + assert_eq!(r.get_value(1), Some(&Value::Float32(1.5))); + } + + #[test] + fn get_string_handles_bad_ref_and_unterminated_tail() { + // A one-field record whose string ref we vary by hand. + let strings = b"hi\0tail"; // "tail" has no trailing NUL + let bytes = build_wdbc(1, 1, 4, &0u32.to_le_bytes(), strings); + let rs = { + let mut s = Schema::new("S"); + s.add_field(SchemaField::new("name", FieldType::String)); + DbcParser::parse(&mut Cursor::new(bytes.as_slice())) + .unwrap() + .with_schema(s) + .unwrap() + .parse_records() + .unwrap() + }; + assert_eq!(rs.get_string(StringRef(0)).unwrap(), "hi"); + // An offset into an un-terminated run reads to the end of the block. + assert_eq!(rs.get_string(StringRef(3)).unwrap(), "tail"); + // Offset == len is the empty string at the very end (in bounds); past len errors. + assert_eq!(rs.get_string(StringRef(strings.len() as u32)).unwrap(), ""); + assert!(matches!( + rs.get_string(StringRef(strings.len() as u32 + 1)), + Err(Error::BadStringRef(_)) + )); + } + + #[test] + fn csv_export_resolves_strings_and_quotes_per_rfc4180() { + // A row with a comma, a quote, and a newline must be wrapped and its quotes doubled; a plain + // row stays bare. Two fields: an id and a name that references the string block. + let strings = b"plain\0a,b\0he said \"hi\"\0line1\nline2\0"; + // offsets: plain@0, "a,b"@6, 'he said "hi"'@10, "line1\nline2"@23 + let mut records = Vec::new(); + for (id, off) in [(1u32, 0u32), (2, 6), (3, 10), (4, 23)] { + records.extend_from_slice(&id.to_le_bytes()); + records.extend_from_slice(&off.to_le_bytes()); + } + let bytes = build_wdbc(4, 2, 8, &records, strings); + let rs = DbcParser::parse(&mut Cursor::new(bytes.as_slice())) + .unwrap() + .with_schema(id_name_schema()) + .unwrap() + .parse_records() + .unwrap(); + + let mut out = Vec::new(); + export_to_csv(&rs, &mut out).unwrap(); + let csv = String::from_utf8(out).unwrap(); + let expected = "id,name\n\ + 1,plain\n\ + 2,\"a,b\"\n\ + 3,\"he said \"\"hi\"\"\"\n\ + 4,\"line1\nline2\"\n"; + assert_eq!(csv, expected); + } +} diff --git a/crates/benilla-formats/Cargo.toml b/crates/benilla-formats/Cargo.toml new file mode 100644 index 00000000..893813df --- /dev/null +++ b/crates/benilla-formats/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "benilla-formats" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WoW 1.12.1 asset-format adapters (MPQ/BLP/DBC/ADT/WDT) and the benilla-extract CLI" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +benilla-adt = { path = "../benilla-adt" } +benilla-bytes = { path = "../benilla-bytes" } +benilla-dbc = { path = "../benilla-dbc" } +benilla-m2 = { path = "../benilla-m2" } +benilla-mpq = { path = "../benilla-mpq" } +benilla-blp = { path = "../benilla-blp" } +image = "0.25" +benilla-wdt = { path = "../benilla-wdt" } +benilla-wmo = { path = "../benilla-wmo" } diff --git a/crates/benilla-formats/examples/dump_anim_names.rs b/crates/benilla-formats/examples/dump_anim_names.rs new file mode 100644 index 00000000..451571c7 --- /dev/null +++ b/crates/benilla-formats/examples/dump_anim_names.rs @@ -0,0 +1,25 @@ +//! Session probe: print AnimationData.dbc rows (id, name, weapon flags, fallback) — +//! `cargo run -p benilla-formats --example dump_anim_names [id ...]`. Without ids, +//! prints all 208 rows. Built for the bowstring round (which sequences do bow M2s carry). + +fn main() { + let mut args = std::env::args().skip(1); + let data = args + .next() + .expect("usage: dump_anim_names [id ...]"); + let ids: Vec = args.map(|a| a.parse().expect("id u16")).collect(); + let mut chain = benilla_formats::Chain::open(std::path::Path::new(&data)).expect("chain"); + let cat = benilla_formats::load_anim_data_catalog(&mut chain).expect("catalog"); + for id in 0..u16::try_from(cat.len() + 64).unwrap() { + if !ids.is_empty() && !ids.contains(&id) { + continue; + } + if let Some(name) = cat.name(id) { + println!( + "{id:3} {name:24} wflags {:#04x} fallback {:?}", + cat.weapon_flags(id), + cat.fallback(id) + ); + } + } +} diff --git a/crates/benilla-formats/examples/dump_bone_keys.rs b/crates/benilla-formats/examples/dump_bone_keys.rs new file mode 100644 index 00000000..3932db33 --- /dev/null +++ b/crates/benilla-formats/examples/dump_bone_keys.rs @@ -0,0 +1,69 @@ +//! Session probe: which bones carry keys in a given sequence — `cargo run -p benilla-formats +//! --example dump_bone_keys [bone ...]`. With bone indices, prints their +//! key counts + first rotation keys; without, lists every keyed bone. Extract the file first +//! via `mpqx` (benilla-mpq). Built for the nocked-ammo RE round (decision 0406 follow-up). + +fn main() { + let mut args = std::env::args().skip(1); + let path = args + .next() + .expect("usage: dump_bone_keys [bone ...]"); + let anim_id: u16 = args.next().expect("anim id").parse().expect("anim id u16"); + let bones: Vec = args.map(|a| a.parse().expect("bone index u16")).collect(); + let bytes = std::fs::read(&path).expect("read m2"); + if let Some(a) = benilla_formats::parse_m2_string_anchors(&bytes) { + println!( + "string anchors: top bone {} ({:+.3},{:+.3},{:+.3}) bottom bone {} ({:+.3},{:+.3},{:+.3})", + a.top.0, a.top.1[0], a.top.1[1], a.top.1[2], a.bottom.0, a.bottom.1[0], a.bottom.1[1], a.bottom.1[2] + ); + } + // anim_id 65535 = list every sequence (id/duration/looping + keyed-bone count only). + for a in benilla_formats::parse_m2_animations(&bytes) { + if anim_id == u16::MAX { + println!( + "anim {} dur {:.3}s looping {} — {} keyed bones", + a.anim_id, + a.duration, + a.looping, + a.bones.len() + ); + continue; + } + if a.anim_id != anim_id { + continue; + } + println!( + "anim {} dur {:.3}s looping {} — {} keyed bones", + a.anim_id, + a.duration, + a.looping, + a.bones.len() + ); + for e in &a.events { + println!( + " event {} t={:.3} data {}", + String::from_utf8_lossy(&e.ident), + e.time, + e.data + ); + } + for bk in &a.bones { + if !bones.is_empty() && !bones.contains(&bk.bone) { + continue; + } + println!( + " bone {:3}: {} trans, {} rot, {} scale keys", + bk.bone, + bk.translation.len(), + bk.rotation.len(), + bk.scale.len() + ); + for (t, q) in bk.rotation.iter().take(3) { + println!( + " rot t={t:.3} q=({:+.3},{:+.3},{:+.3},{:+.3})", + q[0], q[1], q[2], q[3] + ); + } + } + } +} diff --git a/crates/benilla-formats/examples/dump_file.rs b/crates/benilla-formats/examples/dump_file.rs new file mode 100644 index 00000000..9a8ef5fe --- /dev/null +++ b/crates/benilla-formats/examples/dump_file.rs @@ -0,0 +1,18 @@ +//! Dump any file from the 5875 patch chain to stdout — the generic MPQ extractor for +//! reference reads (FrameXML sources, DBC layouts): `cargo run -p benilla-formats +//! --example dump_file -- 'Interface\FrameXML\TradeSkillFrame.lua'`. Output is Blizzard +//! data — pipe it to the scratchpad, never into the repo. + +use std::io::Write; +use std::path::PathBuf; + +fn main() -> anyhow::Result<()> { + let virt = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: dump_file "))?; + let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let bytes = chain.read_file(&virt)?; + std::io::stdout().write_all(&bytes)?; + Ok(()) +} diff --git a/crates/benilla-formats/src/anim_data.rs b/crates/benilla-formats/src/anim_data.rs new file mode 100644 index 00000000..38c7c5c0 --- /dev/null +++ b/crates/benilla-formats/src/anim_data.rs @@ -0,0 +1,195 @@ +//! `AnimationData.dbc` — the per-animation policy row behind the sheath reconcile (decision 0080) +//! and, later, missing-clip fallback resolution. +//! +//! Layout — VERIFIED against build 5875 (header + full 208-row decode with name joins, +//! 2026-07-03): **208 × 7 × 28 B**: `ID(0), Name(str, 1), WeaponFlags(2), BodyFlags(3), _col4(4), +//! _col5(5), Fallback(6)`. +//! +//! - **WeaponFlags (col 2)** — the sheath-reconcile bits the client tests on every `PlayAnimation` +//! (`0x5fdf80`). The identification is **byte-verified** (wow-re `sheath-policy.md` §6a, §5 +//! cross-checked): the reconcile indexes the `WowClientDB` cache (`0xc0e070`, +//! loader-asserted 7 columns × 28 B against `DBFilesClient\AnimationData.dbc`) by the current +//! sequence's AnimationData id, and all three flag tests read row offset `+0x8` = column 2. +//! The bits: `4` force-stow (casts, Swim 42–45, Mount 91, +//! SitChair 102–104, Loot 50), `0x10` force-stow (every Emote, AttackUnarmed 16/117, +//! SitGround 96–98, Kneel 114–116, AttackBow/Rifle/Thrown), `0x20` force-draw-melee (armed +//! attacks 17–19/85–88, Ready1H/2H/2HL 26–28, specials, FishingCast/Loop 133–134). The column's +//! exact 5875 value set is `{0, 4, 16, 20, 32}` — precisely the tested bit triple. +//! - **Fallback (col 6)** — identified from the data (the chains are unambiguous: +//! Attack2H→Attack1H→AttackUnarmed, Sprint→Run, JumpLandRun→Run, Sleep→SleepDown, Drowned→Drown, +//! WalkBackwards→Walk, StealthWalk→Walk; col 5 read as a fallback yields nonsense — Attack2H→Stop, +//! Sleep→SpellCast). **Not the primary runtime missing-clip mechanism** (decision 0082, amending +//! this file's earlier reading): the real client's per-id fallback for ids `< 203` is a single +//! indexed read of the M2's own baked `PlayableAnimationLookup` (a per-model precomputed cache of +//! *this* column's walk against that model's actual sequence set — see +//! `benilla_formats::PlayableAnim` / `benilla_assets::ModelAnimations::resolve`). This column is +//! PATH 2 of that resolver (ids ≥ 203 only, `AnimationData.dbc`'s 208 rows) and the *source* the +//! baked tables were computed from. `0` = fall back to Stand. +//! - Cols 4/5 are unidentified here (4 reads priority-like — 500 on Death/Dead/Drown, 10 on +//! locomotion; 5 flag-like) and are not exposed. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +/// One animation's policy row. +#[derive(Clone, Copy)] +pub struct AnimEntry { + /// The sheath-reconcile bits (see the module doc). `0` for most rows. + pub weapon_flags: u32, + /// The substitute animation id when a model lacks this clip (`0` = Stand). + pub fallback: u16, +} + +/// `AnimationData.dbc` id → its policy row. +pub struct AnimDataCatalog { + rows: HashMap, + /// id → the row's Name string (col 1, `"Attack1H"`) — the human face for debug readouts + /// (the inspector's anim line). Beside [`AnimEntry`] so the policy row stays `Copy`. + names: HashMap, +} + +impl AnimDataCatalog { + /// Build a catalog directly from rows — the synthetic-table entry point for tests exercising + /// missing-clip resolution's PATH 2 walk (decision 0082, `benilla_assets::ModelAnimations::resolve`) + /// without a real `AnimationData.dbc`. + pub fn from_rows(rows: impl IntoIterator) -> Self { + Self { + rows: rows.into_iter().collect(), + names: HashMap::new(), + } + } + + /// The sheath-reconcile WeaponFlags for an animation id (`0` for an unknown id — no policy). + pub fn weapon_flags(&self, id: u16) -> u32 { + self.rows.get(&id).map_or(0, |r| r.weapon_flags) + } + + /// The fallback animation for a model lacking clip `id` — `None` when the row has none + /// (fallback 0 = Stand, left to the caller's own id-list fallbacks) or the id is unknown. + pub fn fallback(&self, id: u16) -> Option { + self.rows + .get(&id) + .map(|r| r.fallback) + .filter(|&f| f != 0 && f != id) + } + + /// The row's Name string (`"Run"`, `"Attack1H"`) — `None` for an unknown id or a + /// synthetic-table catalog ([`Self::from_rows`] keeps no names). + pub fn name(&self, id: u16) -> Option<&str> { + self.names.get(&id).map(String::as_str) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("AnimationData"); + for i in 0..7 { + let ty = if i == 1 { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("f{i}"), ty)); + } + s +} + +/// Read `AnimationData.dbc` off the patch chain. +pub fn load_anim_data_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\AnimationData.dbc") + .context("reading AnimationData.dbc")?; + let rs = parse(&bytes, schema(), "AnimationData")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + let mut names = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + rows.insert( + id as u16, + AnimEntry { + weapon_flags: u32_at(r, 2).unwrap_or(0), + fallback: u32_at(r, 6).unwrap_or(0) as u16, + }, + ); + if let Some(name) = str_at(&rs, r, 1).filter(|n| !n.is_empty()) { + names.insert(id as u16, name); + } + } + Ok(AnimDataCatalog { rows, names }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: the WeaponFlags value set is exactly the reconcile's tested bit + /// triple, the decision-0080 spot rows carry the recorded bits, and the fallback chains + /// resolve (Attack2H→Attack1H, Sprint→Run). Skips without client data. + #[test] + fn real_animation_data_decodes() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_anim_data_catalog(&mut chain).expect("load AnimationData"); + assert_eq!(cat.len(), 208); + + // The whole column stays within the tested bit triple {0, 4, 0x10, 0x14, 0x20}. + for (&id, row) in &cat.rows { + assert!( + matches!(row.weapon_flags, 0 | 4 | 0x10 | 0x14 | 0x20), + "anim {id}: unexpected WeaponFlags {:#x}", + row.weapon_flags + ); + } + + // Decision 0080's transcribed highlights. + assert_eq!(cat.weapon_flags(42), 4, "Swim force-stows"); + assert_eq!(cat.weapon_flags(91), 4, "Mount force-stows"); + assert_eq!(cat.weapon_flags(102), 4, "SitChairLow force-stows"); + assert_eq!(cat.weapon_flags(60), 0x10, "EmoteTalk force-stows"); + assert_eq!( + cat.weapon_flags(16), + 0x10, + "AttackUnarmed needs empty hands" + ); + assert_eq!(cat.weapon_flags(96), 0x10, "SitGroundDown force-stows"); + assert_eq!(cat.weapon_flags(17), 0x20, "Attack1H force-draws"); + assert_eq!(cat.weapon_flags(26), 0x20, "Ready1H force-draws"); + assert_eq!(cat.weapon_flags(133), 0x20, "FishingCast force-draws"); + assert_eq!(cat.weapon_flags(0), 0, "Stand carries no policy"); + + // The ranged-handling trio the reconcile exempts (0x69/0x6a/0x70). + for id in [105, 106, 112] { + assert_eq!(cat.weapon_flags(id), 0, "Load* anims carry no policy"); + } + + // The name column (the inspector's anim line). + assert_eq!(cat.name(0), Some("Stand")); + assert_eq!(cat.name(5), Some("Run")); + assert_eq!(cat.name(17), Some("Attack1H")); + assert_eq!(cat.name(255), None, "unknown id has no name"); + + // The fallback chains. + assert_eq!(cat.fallback(18), Some(17), "Attack2H → Attack1H"); + assert_eq!(cat.fallback(17), Some(16), "Attack1H → AttackUnarmed"); + assert_eq!(cat.fallback(143), Some(5), "Sprint → Run"); + assert_eq!(cat.fallback(187), Some(5), "JumpLandRun → Run"); + assert_eq!(cat.fallback(100), Some(99), "Sleep → SleepDown"); + assert_eq!(cat.fallback(4), None, "Walk falls back to Stand (0 → None)"); + } +} diff --git a/crates/benilla-formats/src/area_poi.rs b/crates/benilla-formats/src/area_poi.rs new file mode 100644 index 00000000..9fec0b9a --- /dev/null +++ b/crates/benilla-formats/src/area_poi.rs @@ -0,0 +1,188 @@ +//! `AreaPOI.dbc` — the world-map's named points of interest: inns, flight masters, dungeon +//! entrances, battleground nodes — everything the minimap's nearest-3 POI blips (decision 0203 +//! phase 3) and the world map's POI icons draw from. +//! +//! **Client-only: vmangos carries no struct for this table** (never loaded server-side). Layout +//! pinned by inspection this session (header + full 339-row decode, cross-checked against a +//! community reference struct for the same build, 2026-07-07): **339 × 29 × 116 B**: +//! `ID(0), Importance(1), Icon(2), FactionID(3), Pos[3](4-6, world x/y/z), ContinentID(7), +//! Flags(8), AreaID(9), Name_lang(10, enUS string) + 7 other-locale slots(11-17) + NameFlags(18), +//! Description_lang(19, enUS string) + 7 other-locale slots(20-26) + DescriptionFlags(27), +//! WorldStateID(28)` — the field the task brief's 28-column enumeration left unaccounted for. +//! +//! Every field past `Pos`/`Name`/`Description` was proven by scanning all 339 rows, not assumed: +//! `Importance` ∈ `{0,2,3,4}` and `Icon` takes 43 distinct small values (both enum-shaped); +//! `FactionID` ∈ `{0,83,84}` (a `FactionTemplate` FK used for only the faction-tinted rows); +//! `ContinentID` ∈ `{0,1,30,37,529}` — Azeroth/Kalimdor plus three battleground map ids; +//! `AreaID` ranges `−1..=3427` including both `−1` (raw, stored as `u32::MAX` — the +//! `WMOAreaTable`-style "no group" sentinel) and a distinct `0`; every row's `Pos.x`/`Pos.y` falls +//! well inside `±17066` (the world-map half-extent); every row's enUS `Name_lang` is non-empty; +//! and **field 28 is `0` on 209 rows and a small nonzero id on the other 130**, each of those 130 +//! landing in one of a handful of tight numeric bands (`1301-1304`, `1325-1340`, …) — exactly the +//! shape of a `WorldState` id, confirmed on a named example: id 1613 "Stables" (an Arathi Basin +//! node, `ContinentID` 529) carries `WorldStateID` 1770 and `Description_lang` "In Conflict", the +//! live capture-state label a BG node's map icon shows. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const AREA_POI: &str = "DBFilesClient\\AreaPOI.dbc"; + +/// One `AreaPOI.dbc` row — a named world-map point of interest. +#[derive(Clone, Debug)] +pub struct AreaPoi { + /// Map-icon prominence tier (`{0,2,3,4}` in 5875 — higher shows at more zoomed-out levels). + pub importance: u32, + /// The map-icon art index (43 distinct icons in 5875). + pub icon: u32, + /// `FactionTemplate.dbc` FK for a faction-tinted icon; `0` = untinted (the common case). + pub faction_id: u32, + /// World position `(x, y, z)`. + pub pos: [f32; 3], + /// The map id this POI's `pos` is expressed in (Azeroth `0`, Kalimdor `1`, or a + /// battleground/instance map id). + pub continent_id: u32, + pub flags: u32, + /// `AreaTable.dbc` id, or `u32::MAX` (raw `−1`) for a continent-wide POI — distinct in this + /// data from a plain `0` (see the module doc). + pub area_id: u32, + /// enUS display name (locale slot 0) — non-empty on every 5875 row. + pub name: String, + /// enUS description (locale slot 0) — empty on the ~2/3 of rows with no live status text. + pub description: String, + /// `WorldState` id driving this POI's live label/icon (e.g. a battleground node's capture + /// state); `0` = no live state (209 of 339 rows). + pub world_state_id: u32, +} + +/// `AreaPOI.dbc` rows keyed by `ID`. +pub struct AreaPoiCatalog { + rows: HashMap, +} + +impl AreaPoiCatalog { + pub fn get(&self, id: u32) -> Option<&AreaPoi> { + self.rows.get(&id) + } + + /// Every row, in no particular order — the nearest-3 POI selection (decision 0203 phase 3) + /// filters this by `continent_id` + distance itself. + pub fn rows(&self) -> impl Iterator { + self.rows.iter().map(|(&id, poi)| (id, poi)) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// 29 fields per the module doc: the two loc-string blocks are 9 dwords each (a string + 7 +/// other-locale string slots + a flags dword), matching the `TaxiNodes` schema pattern +/// (`crate::schema_for`). +fn schema() -> Schema { + let mut s = Schema::new("AreaPOI"); + for name in ["ID", "Importance", "Icon", "FactionID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new_array("Pos", FieldType::Float32, 3)); + for name in ["ContinentID", "Flags", "AreaID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for (name_col, flags_col) in [("Name", "NameFlags"), ("Description", "DescriptionFlags")] { + s.add_field(SchemaField::new(name_col, FieldType::String)); // enUS (locale 0) + s.add_field(SchemaField::new_array( + format!("{name_col}OtherLocales"), + FieldType::String, + 7, + )); + s.add_field(SchemaField::new(flags_col, FieldType::UInt32)); + } + s.add_field(SchemaField::new("WorldStateID", FieldType::UInt32)); + s +} + +/// Read `AreaPOI.dbc` off the patch chain into an [`AreaPoiCatalog`]. +pub fn load_area_poi_catalog(chain: &mut Chain) -> Result { + let bytes = chain.read_file(AREA_POI).context("reading AreaPOI.dbc")?; + let rs = parse(&bytes, schema(), "AreaPOI")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let (Some(x), Some(y), Some(z)) = (f32_at(r, 4), f32_at(r, 5), f32_at(r, 6)) else { + continue; + }; + let poi = AreaPoi { + importance: u32_at(r, 1).unwrap_or(0), + icon: u32_at(r, 2).unwrap_or(0), + faction_id: u32_at(r, 3).unwrap_or(0), + pos: [x, y, z], + continent_id: u32_at(r, 7).unwrap_or(0), + flags: u32_at(r, 8).unwrap_or(0), + area_id: u32_at(r, 9).unwrap_or(0), + name: str_at(&rs, r, 10).unwrap_or_default(), + description: str_at(&rs, r, 19).unwrap_or_default(), + world_state_id: u32_at(r, 28).unwrap_or(0), + }; + rows.insert(id, poi); + } + Ok(AreaPoiCatalog { rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table proves the layout: every row's enUS name is readable text, continent + /// ids and world positions land in the expected ranges, and the pinned Arathi Basin "Stables" + /// example resolves its `WorldStateID` + live status description. Skips without client data. + #[test] + fn real_area_poi_layout_sanity() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_area_poi_catalog(&mut chain).expect("load AreaPOI"); + assert_eq!(cat.len(), 339, "all 339 rows load"); + + let mut nonempty_names = 0; + let mut worldstate_nonzero = 0; + for (_, poi) in cat.rows() { + if !poi.name.is_empty() { + nonempty_names += 1; + } + assert!( + poi.pos[0].abs() <= 17066.0 && poi.pos[1].abs() <= 17066.0, + "world x/y within the map half-extent: {:?}", + poi.pos + ); + // 0/1 are the continents; the rest are battleground/instance map ids — all small. + assert!(poi.continent_id < 10_000, "continentId is a small map id"); + if poi.world_state_id != 0 { + worldstate_nonzero += 1; + } + } + assert_eq!(nonempty_names, 339, "every row has a readable enUS name"); + assert!( + worldstate_nonzero >= 100, + "a meaningful share of rows carry a live WorldStateID: {worldstate_nonzero}" + ); + + // The pinned Arathi Basin "Stables" node. + let stables = cat.get(1613).expect("id 1613 (Stables)"); + assert_eq!(stables.name, "Stables"); + assert_eq!(stables.continent_id, 529, "Arathi Basin's map id"); + assert_eq!(stables.world_state_id, 1770); + assert_eq!(stables.description, "In Conflict"); + } +} diff --git a/crates/benilla-formats/src/area_sound.rs b/crates/benilla-formats/src/area_sound.rs new file mode 100644 index 00000000..e604bbbf --- /dev/null +++ b/crates/benilla-formats/src/area_sound.rs @@ -0,0 +1,381 @@ +//! The zone→audio data plane: **AreaTable** (the zone/subzone row the MCNK `areaId` points at) +//! joined to **ZoneMusic** / **SoundAmbience** / **ZoneIntroMusicTable** (decision 0070, slice 1). +//! +//! Layouts — VERIFIED against build 5875 (header + row decodes, 2026-07-02): +//! - `AreaTable.dbc` **1081 × 25 × 100 B**: `ID(0), ContinentID(1), ParentAreaID(2), AreaBit(3), +//! Flags(4), SoundProviderPref(5), SoundProviderPrefUnderwater(6), AmbienceID(7), ZoneMusic(8), +//! IntroSound(9), ExplorationLevel(10), AreaName_lang(11..19), FactionGroupMask(20), +//! LiquidTypeID[4](21..24)`. Spot-check row 12: "Elwynn Forest", Ambience 35, ZoneMusic 1. +//! NOTE the wowdev-wiki 5875 struct appends TBC+ fields (28 cols) — **wrong**; 25 verified +//! (decision 0070). +//! - `ZoneMusic.dbc` **99 × 8 × 32 B**: `ID, SetName(str), SilenceMin[2], SilenceMax[2], +//! Sounds[2]` — the `[2]` arrays are `[day, night]`, intervals in **ms**, `Sounds` → +//! SoundEntries kits (type 28). Row 1: "Zone-Forest", 180 000–300 000 ms, kit 2523/2523. +//! - `ZoneIntroMusicTable.dbc` **43 × 5 × 20 B**: `ID, Name(str), SoundID, Priority, +//! MinDelayMinutes`. Row 61: "Valley of Heroes", 2541, 1, 60. +//! - `SoundAmbience.dbc` **68 × 3 × 12 B**: `ID, AmbienceID[2] = [day, night]` → looping +//! SoundEntries kits (type 50). Row 22: day 4163 / night 4204. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +/// The audio-relevant slice of one `AreaTable` row. +pub struct AreaEntry { + pub id: u32, + /// Parent zone (subzone rows inherit unset audio fields from it; 0 = a top-level zone). + pub parent: u32, + /// `SoundAmbience.dbc` FK (0 = inherit/none). + pub ambience: u32, + /// `ZoneMusic.dbc` FK (0 = inherit/none). + pub zone_music: u32, + /// `ZoneIntroMusicTable.dbc` FK (0 = inherit/none). + pub intro_sound: u32, + /// `SoundProviderPreferences.dbc` FK — the dry-land reverb preset (0 = inherit/none; only + /// 8 areas in 1.12, all dungeon floors — interiors carry theirs on `WMOAreaTable`). + pub sound_provider: u32, + /// `SoundProviderPreferences.dbc` FK while the listener is submerged (pref 11 "Underwater" + /// on 568 areas; 0 = inherit/none). + pub sound_provider_underwater: u32, + /// The zone/subzone display name (enUS column). + pub name: String, +} + +/// One `ZoneMusic` row. All `[2]` arrays are `[day, night]`. +pub struct ZoneMusicEntry { + pub id: u32, + pub set_name: String, + /// Silence between tracks, milliseconds, uniform in `[min, max]`. + pub silence_min: [u32; 2], + pub silence_max: [u32; 2], + /// SoundEntries kits (type 28) — the track pool per phase. + pub sounds: [u32; 2], +} + +/// One `ZoneIntroMusicTable` row — the entry fanfare. +pub struct ZoneIntroEntry { + pub id: u32, + pub sound_id: u32, + /// Higher wins when nested areas compete. + pub priority: u32, + /// Replay throttle, minutes. + pub min_delay_minutes: u32, +} + +/// One `SoundAmbience` row: the looping ambience kits, `[day, night]`. +pub struct SoundAmbienceEntry { + pub id: u32, + pub kits: [u32; 2], +} + +/// The four tables, resolvable from an MCNK `areaId`. +pub struct AreaSoundCatalog { + areas: HashMap, + zone_music: HashMap, + intros: HashMap, + ambience: HashMap, +} + +/// An area's fully-resolved audio identity (after the parent walk). +pub struct AreaAudio<'a> { + pub area_name: &'a str, + pub music: Option<&'a ZoneMusicEntry>, + pub intro: Option<&'a ZoneIntroEntry>, + pub ambience: Option<&'a SoundAmbienceEntry>, + /// Resolved `SoundProviderPreferences` ids, `[dry, underwater]` (0 = none up the chain). + pub sound_provider: [u32; 2], +} + +impl AreaSoundCatalog { + pub fn area(&self, id: u32) -> Option<&AreaEntry> { + self.areas.get(&id) + } + + pub fn zone_music(&self, id: u32) -> Option<&ZoneMusicEntry> { + self.zone_music.get(&id) + } + + /// A `ZoneIntroMusicTable` row by id (the WMO interior override path resolves intros + /// directly — `WMOAreaTable.IntroSound` — instead of through an area row). + pub fn intro(&self, id: u32) -> Option<&ZoneIntroEntry> { + self.intros.get(&id) + } + + /// A `SoundAmbience` row by id (the WMO interior override path). + pub fn ambience_row(&self, id: u32) -> Option<&SoundAmbienceEntry> { + self.ambience.get(&id) + } + + /// Resolve an `areaId` to its audio rows, walking `ParentAreaID` for any FK that is 0 on the + /// subzone row (the standard vanilla inheritance: a subzone without its own music plays the + /// zone's). Cycle-guarded; `None` fields mean "no music/ambience anywhere up the chain". + pub fn resolve(&self, area_id: u32) -> Option> { + let first = self.areas.get(&area_id)?; + let (mut music, mut intro, mut ambience) = (None, None, None); + let mut provider = [0u32; 2]; + let mut cur = Some(first); + for _ in 0..8 { + let Some(a) = cur else { break }; + if music.is_none() && a.zone_music != 0 { + music = self.zone_music.get(&a.zone_music); + } + if intro.is_none() && a.intro_sound != 0 { + intro = self.intros.get(&a.intro_sound); + } + if ambience.is_none() && a.ambience != 0 { + ambience = self.ambience.get(&a.ambience); + } + if provider[0] == 0 { + provider[0] = a.sound_provider; + } + if provider[1] == 0 { + provider[1] = a.sound_provider_underwater; + } + if a.parent == 0 { + break; + } + cur = self.areas.get(&a.parent); + } + Some(AreaAudio { + area_name: &first.name, + music, + intro, + ambience, + sound_provider: provider, + }) + } + + pub fn len(&self) -> usize { + self.areas.len() + } + + pub fn is_empty(&self) -> bool { + self.areas.is_empty() + } +} + +fn area_schema() -> Schema { + let mut s = Schema::new("AreaTable"); + for (i, name) in [ + "ID", + "ContinentID", + "ParentAreaID", + "AreaBit", + "Flags", + "SoundProviderPref", + "SoundProviderPrefUnderwater", + "AmbienceID", + "ZoneMusic", + "IntroSound", + "ExplorationLevel", + ] + .into_iter() + .enumerate() + { + debug_assert!(i < 11); + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new("AreaName", FieldType::String)); + for i in 12..25 { + s.add_field(SchemaField::new(format!("_pad{i}"), FieldType::UInt32)); + } + s +} + +fn zone_music_schema() -> Schema { + let mut s = Schema::new("ZoneMusic"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("SetName", FieldType::String)); + for name in [ + "SilenceMinDay", + "SilenceMinNight", + "SilenceMaxDay", + "SilenceMaxNight", + "SoundDay", + "SoundNight", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +fn intro_schema() -> Schema { + let mut s = Schema::new("ZoneIntroMusicTable"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("Name", FieldType::String)); + s.add_field(SchemaField::new("SoundID", FieldType::UInt32)); + s.add_field(SchemaField::new("Priority", FieldType::UInt32)); + s.add_field(SchemaField::new("MinDelayMinutes", FieldType::UInt32)); + s +} + +fn ambience_schema() -> Schema { + let mut s = Schema::new("SoundAmbience"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("AmbienceDay", FieldType::UInt32)); + s.add_field(SchemaField::new("AmbienceNight", FieldType::UInt32)); + s +} + +/// Read the four tables off the patch chain. +pub fn load_area_sound_catalog(chain: &mut Chain) -> Result { + let read = |chain: &mut Chain, file: &str, schema: Schema, what: &str| { + let bytes = chain + .read_file(file) + .with_context(|| format!("reading {file}"))?; + parse(&bytes, schema, what) + }; + + let rs = read( + chain, + "DBFilesClient\\AreaTable.dbc", + area_schema(), + "AreaTable", + )?; + let mut areas = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + areas.insert( + id, + AreaEntry { + id, + parent: u32_at(r, 2).unwrap_or(0), + ambience: u32_at(r, 7).unwrap_or(0), + zone_music: u32_at(r, 8).unwrap_or(0), + intro_sound: u32_at(r, 9).unwrap_or(0), + sound_provider: u32_at(r, 5).unwrap_or(0), + sound_provider_underwater: u32_at(r, 6).unwrap_or(0), + name: str_at(&rs, r, 11).unwrap_or_default(), + }, + ); + } + + let rs = read( + chain, + "DBFilesClient\\ZoneMusic.dbc", + zone_music_schema(), + "ZoneMusic", + )?; + let mut zone_music = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + zone_music.insert( + id, + ZoneMusicEntry { + id, + set_name: str_at(&rs, r, 1).unwrap_or_default(), + silence_min: [u32_at(r, 2).unwrap_or(0), u32_at(r, 3).unwrap_or(0)], + silence_max: [u32_at(r, 4).unwrap_or(0), u32_at(r, 5).unwrap_or(0)], + sounds: [u32_at(r, 6).unwrap_or(0), u32_at(r, 7).unwrap_or(0)], + }, + ); + } + + let rs = read( + chain, + "DBFilesClient\\ZoneIntroMusicTable.dbc", + intro_schema(), + "ZoneIntroMusicTable", + )?; + let mut intros = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + intros.insert( + id, + ZoneIntroEntry { + id, + sound_id: u32_at(r, 2).unwrap_or(0), + priority: u32_at(r, 3).unwrap_or(0), + min_delay_minutes: u32_at(r, 4).unwrap_or(0), + }, + ); + } + + let rs = read( + chain, + "DBFilesClient\\SoundAmbience.dbc", + ambience_schema(), + "SoundAmbience", + )?; + let mut ambience = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + ambience.insert( + id, + SoundAmbienceEntry { + id, + kits: [u32_at(r, 1).unwrap_or(0), u32_at(r, 2).unwrap_or(0)], + }, + ); + } + + Ok(AreaSoundCatalog { + areas, + zone_music, + intros, + ambience, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end on the **real** 5875 tables: Elwynn Forest (area 12) resolves to the + /// "Zone-Forest" music set (3–5 min silence, kit 2523) and ambience 35; a subzone with its + /// own row zeroes (Crystal Lake 87, parent 12) inherits Elwynn's music through the parent + /// walk. Skips without client data. + #[test] + fn real_area_chain_resolves_elwynn() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_area_sound_catalog(&mut chain).expect("load area-sound catalog"); + assert_eq!(cat.len(), 1081, "all AreaTable rows load"); + + let elwynn = cat.resolve(12).expect("Elwynn resolves"); + assert_eq!(elwynn.area_name, "Elwynn Forest"); + assert_eq!( + elwynn.sound_provider, + [0, 11], + "Elwynn: no dry reverb, Underwater preset submerged" + ); + assert_eq!( + cat.resolve(24).expect("Northshire Abbey").sound_provider, + [73, 11], + "the abbey carries PRESET_AUDITORIUM dry" + ); + let music = elwynn.music.expect("Elwynn has zone music"); + assert_eq!(music.set_name, "Zone-Forest"); + assert_eq!(music.silence_min, [180_000, 180_000]); + assert_eq!(music.silence_max, [300_000, 300_000]); + assert_eq!(music.sounds, [2523, 2523]); + assert!(elwynn.ambience.is_some(), "Elwynn has ambience"); + + // A subzone of Elwynn inherits through the parent walk when its own FKs are 0. + let sub = cat + .areas + .values() + .find(|a| a.parent == 12 && a.zone_music == 0) + .expect("some Elwynn subzone without its own music"); + let resolved = cat.resolve(sub.id).expect("subzone resolves"); + assert_eq!( + resolved.music.expect("inherited music").id, + music.id, + "subzone {} ({}) inherits Elwynn's music", + sub.id, + sub.name + ); + } +} diff --git a/crates/benilla-formats/src/area_table.rs b/crates/benilla-formats/src/area_table.rs new file mode 100644 index 00000000..ee70dccf --- /dev/null +++ b/crates/benilla-formats/src/area_table.rs @@ -0,0 +1,180 @@ +//! `AreaTable.dbc` — the area catalog: id → (map, parent zone, explore bit, name). The world map +//! (decision 0203 phase 2) resolves the player's MCNK `areaId` to its **top-level zone** through +//! the parent chain here (`SetMapToCurrentZone`), and labels zones with the localized `AreaName` +//! (the WorldMapArea string is the art *folder* — "Elwynn" — while the display name lives here — +//! "Elwynn Forest"). Phase 3's exploration overlays key off [`AreaTableRow::explore_flag`]. +//! +//! Layout — VERIFIED against build 5875 (2026-07-07, real-row decode: Northshire Valley id 9 → +//! zone 12 = Elwynn Forest; Booty Bay id 35 → zone 33 = Stranglethorn Vale; cross-checked against +//! mangoszero's `AreaTableEntry`): **25 × u32 cols**: `ID(0), MapID(1), ZoneID(2 — the parent +//! area, 0 = this row IS a top-level zone), ExploreFlag(3), Flags(4), … AreaName(11)` + loc +//! block, `FactionGroupMask(20)`. Sibling readers of the same file: `quest_headers.rs` (id → +//! name only), `area_sound.rs` (the audio columns). +//! +//! The PvP columns (zone-splash arc, decision 0287) — census of the real 5875 table: +//! `FactionGroupMask` is 2/4/0 (Alliance/Horde/neither) on zone rows and ~always 0 on subzone +//! rows; `Flags` bit `0x80` marks exactly the three FFA duel pits (Battle Ring 2177, The Rumble +//! Cage 2857, The Maul 3217 + its UNUSED twin) — *not* the enclosing "Gurubashi Arena" row. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{parse, str_at, u32_at}; + +const AREA_TABLE: &str = "DBFilesClient\\AreaTable.dbc"; + +/// One `AreaTable.dbc` row (the columns the map arc reads). +#[derive(Clone, Debug)] +pub struct AreaTableRow { + /// `Map.dbc` id the area lives on. + pub map_id: u32, + /// The parent area's id — `0` means this row is itself a top-level zone. + pub zone_id: u32, + /// The exploration bit index (`PLAYER_EXPLORED_ZONES` bitset / `WorldMapOverlay` join, + /// phase 3). + pub explore_flag: u32, + /// Area flags (col 4). Bit `0x80` = FFA duel pit ("PvP Area" / `isArena`, decision 0287); + /// bit `0x1` = snow; capitals carry `0x138`. + pub flags: u32, + /// `FactionGroup.dbc` mask of the owning side (col 20): 2 = Alliance, 4 = Horde, 0 = neither + /// (contested — or a subzone row deferring to its zone). + pub faction_group_mask: u32, + /// The localized display name ("Elwynn Forest"). + pub name: String, +} + +/// `AreaTable.dbc` rows keyed by id. +pub struct AreaTableCatalog { + by_id: HashMap, +} + +impl AreaTableCatalog { + pub fn get(&self, id: u32) -> Option<&AreaTableRow> { + self.by_id.get(&id) + } + + /// The display name for `id`, or `None`. + pub fn name(&self, id: u32) -> Option<&str> { + self.by_id.get(&id).map(|r| r.name.as_str()) + } + + /// The top-level zone containing `area_id`: walk the `zone_id` parent chain until a row whose + /// parent is 0 (possibly `area_id` itself). `None` for an unknown id. Depth-guarded — the + /// 5875 chains are 1–2 deep, anything deeper is data corruption, not geography. + pub fn top_zone(&self, area_id: u32) -> Option { + let mut id = area_id; + for _ in 0..8 { + let row = self.by_id.get(&id)?; + if row.zone_id == 0 { + return Some(id); + } + id = row.zone_id; + } + None + } + + pub fn len(&self) -> usize { + self.by_id.len() + } + + pub fn is_empty(&self) -> bool { + self.by_id.is_empty() + } +} + +/// 25 u32-wide columns; only `ID/MapID/ZoneID/ExploreFlag/Flags/AreaName/FactionGroupMask` are +/// read (see module doc). +fn schema() -> Schema { + let mut s = Schema::new("AreaTable"); + for i in 0..25 { + match i { + 11 => s.add_field(SchemaField::new("AreaName", FieldType::String)), + _ => s.add_field(SchemaField::new(format!("c{i}"), FieldType::UInt32)), + } + } + s +} + +/// Read `AreaTable.dbc` off the patch chain into an [`AreaTableCatalog`]. +pub fn load_area_table_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(AREA_TABLE) + .context("reading AreaTable.dbc")?; + let rs = parse(&bytes, schema(), "AreaTable")?; + let mut by_id = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(map_id), Some(zone_id), Some(explore_flag), Some(flags), Some(name)) = ( + u32_at(r, 0), + u32_at(r, 1), + u32_at(r, 2), + u32_at(r, 3), + u32_at(r, 4), + str_at(&rs, r, 11), + ) else { + continue; + }; + let faction_group_mask = u32_at(r, 20).unwrap_or(0); + by_id.insert( + id, + AreaTableRow { + map_id, + zone_id, + explore_flag, + flags, + faction_group_mask, + name, + }, + ); + } + Ok(AreaTableCatalog { by_id }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: the verified parent chains (Northshire → Elwynn, Booty Bay → + /// Stranglethorn), a top-level zone resolving to itself, and the display-vs-art-folder name + /// split. Skips without client data. + #[test] + fn real_area_table_parent_chains_and_names() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_area_table_catalog(&mut chain).expect("load AreaTable"); + assert!( + cat.len() > 1000, + "5875 ships ~1800 areas, got {}", + cat.len() + ); + + let northshire = cat.get(9).expect("Northshire Valley (id 9)"); + assert_eq!(northshire.zone_id, 12, "Northshire's parent is Elwynn"); + assert_eq!(northshire.name, "Northshire Valley"); + assert_eq!(cat.top_zone(9), Some(12)); + + // A leaf two hops down: Booty Bay → Stranglethorn Vale (top-level). + assert_eq!(cat.top_zone(35), Some(33)); + + // A top-level zone resolves to itself; its display name differs from the art folder. + assert_eq!(cat.top_zone(12), Some(12)); + assert_eq!(cat.name(12), Some("Elwynn Forest")); + + // The phase-3 explore bit is populated (Elwynn's is 126 in 5875). + assert_eq!(cat.get(12).expect("Elwynn").explore_flag, 126); + + // The PvP columns (decision 0287): ownership masks on zone rows… + assert_eq!(cat.get(12).expect("Elwynn").faction_group_mask, 2); + assert_eq!(cat.get(14).expect("Durotar").faction_group_mask, 4); + assert_eq!(cat.get(33).expect("Stranglethorn").faction_group_mask, 0); + // …and the FFA-pit bit on the leaf pit rows, NOT the enclosing arena row. + assert_ne!(cat.get(2177).expect("Battle Ring").flags & 0x80, 0); + assert_eq!(cat.get(1741).expect("Gurubashi Arena").flags & 0x80, 0); + } +} diff --git a/crates/benilla-formats/src/bank_bag_slot_prices.rs b/crates/benilla-formats/src/bank_bag_slot_prices.rs new file mode 100644 index 00000000..ca006bf0 --- /dev/null +++ b/crates/benilla-formats/src/bank_bag_slot_prices.rs @@ -0,0 +1,131 @@ +//! `BankBagSlotPrices.dbc` — the bank's purchase-ladder price table (decision 0604). +//! +//! 12 rows × 2 `u32` columns (`ID`, price in copper). Verified from the real 5875 `dbc.MPQ` +//! (dumped this session): rows 1–6 = 1000, 10000, 100000, 250000, 500000, 1000000; rows 7–12 a +//! `999999999` sentinel — only the first 6 bank bag slots are actually purchasable +//! (`GetNumBankSlots()` reports `full` at 6; the sentinel rows exist but are unreachable). +//! `CMSG_BUY_BANK_SLOT` carries no slot index — the server buys slot `purchased_count + 1` itself +//! (`PLAYER_BYTES_2` byte 2) — so the client's only job is pricing that next slot. + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; +use std::collections::HashMap; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +const BANK_BAG_SLOT_PRICES: &str = "DBFilesClient\\BankBagSlotPrices.dbc"; + +/// Row id (1-based slot number) → price in copper. +pub struct BankBagSlotPrices(HashMap); + +impl BankBagSlotPrices { + /// The cost of the *next* bank bag slot, given `purchased_count` already bought (slot + /// `purchased_count + 1`, 1-based to match the DBC's row ids). `None` past the table — no row + /// for that slot number at all, not merely the sentinel (the 6-purchasable-slot cap is a data + /// fact, not something this reader hardcodes). + pub fn next_slot_price(&self, purchased_count: u8) -> Option { + self.0.get(&(u32::from(purchased_count) + 1)).copied() + } + + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("BankBagSlotPrices"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("Price", FieldType::UInt32)); + s.set_key_field("ID"); + s +} + +/// Load `BankBagSlotPrices.dbc` from the patch chain. +pub fn load_bank_bag_slot_prices(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(BANK_BAG_SLOT_PRICES) + .with_context(|| format!("reading {BANK_BAG_SLOT_PRICES}"))?; + table_from(&bytes) +} + +/// The parse itself, split from the chain read so the golden test drives the identical path. +fn table_from(bytes: &[u8]) -> Result { + let rs = parse(bytes, schema(), "BankBagSlotPrices")?; + let mut prices = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(price)) = (u32_at(r, 0), u32_at(r, 1)) { + prices.insert(id, price); + } + } + Ok(BankBagSlotPrices(prices)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A hand-built WDBC with the real 5875 rows (verified against `dbc.MPQ` this session, + /// decision 0604): 6 real prices then the 6-row `999999999` sentinel. + fn synthesize() -> Vec { + let rows: &[[u32; 2]] = &[ + [1, 1_000], + [2, 10_000], + [3, 100_000], + [4, 250_000], + [5, 500_000], + [6, 1_000_000], + [7, 999_999_999], + [8, 999_999_999], + [9, 999_999_999], + [10, 999_999_999], + [11, 999_999_999], + [12, 999_999_999], + ]; + let mut dbc = Vec::new(); + dbc.extend_from_slice(b"WDBC"); + dbc.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + dbc.extend_from_slice(&2u32.to_le_bytes()); // fields + dbc.extend_from_slice(&8u32.to_le_bytes()); // record size (2 × u32) + dbc.extend_from_slice(&1u32.to_le_bytes()); // string block + for row in rows { + for v in row { + dbc.extend_from_slice(&v.to_le_bytes()); + } + } + dbc.push(0); // the string block + dbc + } + + #[test] + fn next_slot_price_climbs_the_ladder_then_hits_the_sentinel() { + let table = table_from(&synthesize()).unwrap(); + assert_eq!(table.len(), 12); + assert_eq!(table.next_slot_price(0), Some(1_000), "first slot"); + assert_eq!(table.next_slot_price(1), Some(10_000)); + assert_eq!(table.next_slot_price(2), Some(100_000)); + assert_eq!(table.next_slot_price(3), Some(250_000)); + assert_eq!(table.next_slot_price(4), Some(500_000)); + assert_eq!( + table.next_slot_price(5), + Some(1_000_000), + "6th and final slot" + ); + assert_eq!( + table.next_slot_price(6), + Some(999_999_999), + "row 7 exists but is the unreachable sentinel" + ); + assert_eq!(table.next_slot_price(11), Some(999_999_999), "row 12"); + } + + #[test] + fn next_slot_price_is_none_past_the_table() { + let table = table_from(&synthesize()).unwrap(); + assert_eq!(table.next_slot_price(12), None, "no row 13"); + assert_eq!(table.next_slot_price(255), None); + } +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs new file mode 100644 index 00000000..ed52d28f --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -0,0 +1,626 @@ +//! Per-model M2 dump printers: `m2coll`, `m2seq`, `m2attach`, `m2anim`, `m2bones`, `m2batch` — +//! the single-model diagnostics that read one `.m2` and print everything a given concern +//! (collision hull, sequences, attachment points, animation channels, bone table, render +//! batches) actually carries. + +use anyhow::{Context, Result}; +use benilla_formats::{Chain, M2AnimSummary}; + +use crate::{normalize, yn}; + +/// Dump an M2's collision hull: counts, model-space AABB (WoW axes, Z up), extents. +pub fn m2coll(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let hull = benilla_formats::load_m2_collision_hull(chain, &name)?; + if hull.is_empty() { + println!("no collision hull (nBoundingTriangles == 0) — nothing collides with this model"); + return Ok(()); + } + let (mut min, mut max) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &hull.positions { + for a in 0..3 { + min[a] = min[a].min(p[a]); + max[a] = max[a].max(p[a]); + } + } + println!( + "{} vertices, {} triangles", + hull.positions.len(), + hull.triangle_count() + ); + println!("aabb (model space, yd; WoW axes, Z up — placement scale multiplies):"); + for (axis, a) in ["x", "y", "z"].iter().zip(0..3) { + println!( + " {axis}: {:>8.3} .. {:>8.3} extent {:>7.3}", + min[a], + max[a], + max[a] - min[a] + ); + } + Ok(()) +} + +/// Dump every sequence's EVENT keyframes: time (s from sequence start), 4CC ident, payload. +/// The event-order instrument (decision 0279): on attack clips, whether `$CPP` (the victim +/// defense dispatch) precedes `$AH0-3`/`$CAH` (the impact dispatch) decides which of the two +/// mutually-exclusive victim reactions the shared swing record feeds. +pub fn m2events(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let seqs = benilla_formats::parse_m2_animations(&data); + for (i, s) in seqs.iter().enumerate() { + if s.events.is_empty() { + continue; + } + let tags: Vec = s + .events + .iter() + .map(|e| { + let ident = String::from_utf8_lossy(&e.ident); + if e.data != 0 { + format!("{:.3}s {ident}({})", e.time, e.data) + } else { + format!("{:.3}s {ident}", e.time) + } + }) + .collect(); + println!( + "{i:>3} anim {:>4} dur {:>6.3}s {}", + s.anim_id, + s.duration, + tags.join(" ") + ); + } + Ok(()) +} + +/// Dump an M2's animation sequences in file order. +pub fn m2seq(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let seqs = benilla_formats::parse_m2_animations(&data); + println!("idx anim mode dur(s) freq replay bones keys"); + for (i, s) in seqs.iter().enumerate() { + // How much data the sequence's own time band actually holds: bones with any + // keyed track, and total keys across T/R/S (clamp constants included — a bone + // unkeyed in this band pins to its nearest authored key, see `read_bone_track`). + // Uneven coverage across same-id variations is what exposed the task-#14 tilt + // (HumanMale Stand idx 136 keys 13 fewer bones than the head). + let bones = s + .bones + .iter() + .filter(|b| !b.translation.is_empty() || !b.rotation.is_empty() || !b.scale.is_empty()) + .count(); + let keys: usize = s + .bones + .iter() + .map(|b| b.translation.len() + b.rotation.len() + b.scale.len()) + .sum(); + println!( + "{i:>3} {:>4} {} {:>7.3} {:>5} ({}, {}) {bones:>5} {keys:>5}", + s.anim_id, + if s.looping { "loop " } else { "clamp" }, + s.duration, + s.frequency, + s.min_replay, + s.max_replay, + ); + } + eprintln!("{} sequences", seqs.len()); + Ok(()) +} + +/// Dump an M2's attachment points (id + bone). +pub fn m2attach(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let attachments = benilla_formats::parse_m2_attachments(&data)?; + println!("id bone"); + for a in &attachments { + println!("{:>2} {}", a.id, a.bone); + } + eprintln!("{} attachment points", attachments.len()); + Ok(()) +} + +/// One texture-transform track line for the `m2anim` dump: key count, interp/gseq tags, and the +/// first/last keys (enough to read a scroll direction + rate off a waterfall). +fn print_txfm_track(name: &str, t: &benilla_m2::M2Track) { + if t.keys.is_empty() { + println!(" {name}: -"); + return; + } + let (t0, v0) = &t.keys[0]; + let (tn, vn) = &t.keys[t.keys.len() - 1]; + println!( + " {name}: {} key(s), interp {}, gseq {}, constant {} [{t0} ms {v0:?} … {tn} ms {vn:?}]", + t.keys.len(), + t.interp, + if t.gseq == 0xffff { + "-".to_string() + } else { + t.gseq.to_string() + }, + t.constant().is_some(), + ); +} + +/// The `m2anim` subcommand's dump — one section per channel family. +fn print_m2anim_summary(s: &M2AnimSummary, bytes: &[u8]) { + println!("sequences: {}", s.sequence_count); + println!( + " seq0 bone motion: {} ({} bone(s) with a >1-key track)", + if s.seq0_has_bone_motion { "yes" } else { "no" }, + s.seq0_animated_bone_count + ); + println!( + "global-sequence bone channels: {}", + s.global_seq_channels.len() + ); + for (bone, kind, period_ms) in &s.global_seq_channels { + println!(" bone {bone:>3} {kind} period {period_ms} ms"); + } + println!( + "transparency tracks: {} total, {} animated (>1 key)", + s.transparency_tracks.0, s.transparency_tracks.1 + ); + println!( + "color rgb tracks: {} total, {} animated (>1 key)", + s.color_rgb_tracks.0, s.color_rgb_tracks.1 + ); + println!( + "color alpha tracks: {} total, {} animated (>1 key)", + s.color_alpha_tracks.0, s.color_alpha_tracks.1 + ); + println!( + "texture transforms: {} (header count; tracks unparsed)", + s.texture_transform_count + ); + println!("particle emitters: {}", s.particle_emitter_count); + // The full defs (pos/blend/texture/shape/rate-keys/ramps) alongside the summary's bone links — + // which emitter is the flame and which the glow is unreadable from bone+flags alone (the + // blood-spurt starburst diagnosis, decision 0141; a flame that "doesn't burn" is usually + // visible right here: an unresolved texture, or a burst rate track whose first key is 0). + let defs = benilla_formats::parse_m2_particle_emitters(bytes).unwrap_or_default(); + for (i, e) in s.emitter_bones.iter().enumerate() { + println!( + " emitter {i} bone {:>3} flags {:#010x} chain animates: {}", + e.bone, + e.flags, + match (e.chain_seq0, e.chain_gseq) { + (true, true) => "seq0 + gseq", + (true, false) => "seq0", + (false, true) => "gseq", + (false, false) => "no (rest pose)", + } + ); + let Some(d) = defs.get(i) else { continue }; + // The emission MODEL first: a BURST emitter fires one ftol(rate) puff at its rate edge + // and never pours — reading its keys as a continuous rate is the exact misdiagnosis + // behind the Eviscerate 0.5s-vs-2s gap (wow-re part-emission-burst-flag.md). + let burst = if d.burst() { "BURST " } else { "" }; + let rate = if d.emission_rate.keys.len() == 1 { + format!("{burst}rate {:.1}/s", d.emission_rate.first()) + } else { + format!( + "{burst}rate keys {:?}", + d.emission_rate + .keys + .iter() + .map(|&(t, v)| (t, v as i32)) + .collect::>() + ) + }; + // The enabled gate (clip-relative ms, like the rate keys): a one-shot effect's + // choreography — "why does this emitter only flash for 200 ms" reads right here. + let rate = if d.enabled.keys.len() == 1 { + rate // always-on (the overwhelmingly common shape) — no noise + } else { + format!("{rate} enabled {:?}", d.enabled.keys) + }; + // A tail's streak length is |velocity|·tail_time — without it "how long is this + // streak" needs a hand-parse of the raw record (the Eviscerate diagnosis gap). + let tail = if d.head_tail >= 1 { + format!( + " tail {:.2}s{}", + d.tail_time, + if d.tail_clamps_to_age() { + " (age-clamped)" + } else { + "" + } + ) + } else { + String::new() + }; + println!( + " {:?} {:?} {} {rate} life {:.2}s speed {:.2} grav {:.2} drag {:.1}{tail} twinkle [{:.2}..{:.2}] spd {:.1} pct {:.2} spin {:.2}", + d.shape, + d.blend, + match d.head_tail { + 0 => "head", + 1 => "tail", + _ => "head+tail", + }, + d.lifespan, + d.emission_speed, + d.gravity, + d.drag, + d.twinkle_min, + d.twinkle_max, + d.twinkle_speed, + d.twinkle_percent, + d.spin, + ); + // The kernel spread (wow-re part-shape-kernels): a sphere's ranges are latitude/longitude + // about +X (area = min/max shell radius); a plane's are the ±θ/±φ cone about +Z (area = + // the spawn rectangle). `(lat ±π, lon ±0)` reads directly as the edge-on ring family. + let spread = match d.shape { + benilla_formats::ParticleShape::Sphere => format!( + "radius [{:.2}..{:.2}] lat ±{:.2} lon ±{:.2}", + d.area_length, d.area_width, d.vertical_range, d.horizontal_range + ), + // Spline repurposing (wow-re part-spline-file-layout): area = tMin/tMax, + // vRange = tangent-spin ψ, hRange = scatter. + benilla_formats::ParticleShape::Spline => match &d.spline { + Some(s) => format!( + "spline {} pts [{:.2} {:.2} {:.2} ..], t [{:.2}..{:.2}] spin ±{:.2} scatter {:.2}", + s.points.len(), + s.points[0][0], + s.points[0][1], + s.points[0][2], + d.area_length, + d.area_width, + d.vertical_range, + d.horizontal_range + ), + None => "spline UNPARSED (degenerate record)".to_string(), + }, + _ => format!( + "area {:.1}x{:.1} cone ±{:.2}/±{:.2}", + d.area_length, d.area_width, d.vertical_range, d.horizontal_range + ), + }; + let zsrc = if d.z_source != 0.0 { + format!(" zSource {:.2}", d.z_source) + } else { + String::new() + }; + // The per-emitter model references: geometry (3-D model particles) and recursion + // (child emitters). + if let Some(g) = &d.geometry_model { + println!(" MODEL-PARTICLES: {g}"); + } + if let Some(r) = &d.recursion_model { + println!(" CHILD-EMITTERS: {r}"); + } + // The emitter-motion terms (wow-re part-emitter-motion): the follow-delta response + // line's authored (speed → fraction) samples, and the velocity-inherit scale. + let motion = match (d.follow_emitter(), d.inherits_emitter_motion()) { + (false, false) => String::new(), + (f, i) => { + let mut s = String::new(); + if f { + s += &format!( + " follow ({:.2}->{:.2}, {:.2}->{:.2})", + d.follow_speed1, d.follow_scale1, d.follow_speed2, d.follow_scale2 + ); + } + if i { + s += &format!(" inheritScale {:.2}", d.inherit_scale); + } + s + } + }; + println!( + " pos [{:.2} {:.2} {:.2}] {spread}{zsrc}{motion} texture: {} cells {}x{}", + d.position[0], + d.position[1], + d.position[2], + d.texture.as_deref().unwrap_or("NONE (unresolved)"), + d.tile_rows, + d.tile_cols, + ); + let c = d.over_life.color; + println!( + " color/alpha keys: [{:.2} {:.2} {:.2} a{:.2}] -> [{:.2} {:.2} {:.2} a{:.2}] -> [{:.2} {:.2} {:.2} a{:.2}] size {:?}", + c[0][0], c[0][1], c[0][2], c[0][3], c[1][0], c[1][1], c[1][2], c[1][3], c[2][0], + c[2][1], c[2][2], c[2][3], d.over_life.scale, + ); + } + println!("ribbon emitters: {}", s.ribbon_emitter_count); + // A keyed look track prints its full `(ms, value)` ramp — the value[0]-only display once + // masked HolySmite's slash ribbons (height keyed 0 → 0.167 → 0 printed as `+0.00`, reading + // as "no ribbon" when the model authors a flare). + let scalar = |t: &benilla_formats::ValueTrack| -> String { + match t.keys.len() { + 0 | 1 => format!("{:.2}", t.first()), + _ => { + let keys: Vec = t + .keys + .iter() + .map(|&(ms, v)| format!("({ms}, {v:.2})")) + .collect(); + format!("keys [{}]", keys.join(", ")) + } + } + }; + for (i, r) in benilla_formats::parse_m2_ribbon_emitters(bytes) + .unwrap_or_default() + .iter() + .enumerate() + { + let rgb = if r.color.keys.len() <= 1 { + let c = r.color.first(); + format!("[{:.2} {:.2} {:.2}]", c[0], c[1], c[2]) + } else { + let keys: Vec = r + .color + .keys + .iter() + .map(|&(ms, c)| format!("({ms}, [{:.2} {:.2} {:.2}])", c[0], c[1], c[2])) + .collect(); + format!("keys [{}]", keys.join(", ")) + }; + println!( + " ribbon {i} bone {:>3} {:?} {:.1} edges/s life {:.2}s g {:.2} tex {}", + r.bone, + r.blend, + r.edges_per_second, + r.edge_lifetime, + r.gravity, + r.texture.as_deref().unwrap_or("NONE (unresolved)"), + ); + println!( + " h above {} below {} rgb {} a {}", + scalar(&r.height_above), + scalar(&r.height_below), + rgb, + scalar(&r.alpha), + ); + } + println!( + "fully static: {}", + if s.is_fully_static() { "yes" } else { "no" } + ); +} + +/// Dump an M2's animation-channel summary plus texture-transform detail. +pub fn m2anim(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let summary = benilla_formats::parse_m2_animation_summary(&data) + .with_context(|| format!("parsing M2 animation summary '{name}'"))?; + print_m2anim_summary(&summary, &data); + + // Texture-transform detail (0130 phase 3 grounding): the parsed TRS tracks plus the + // batch → lookup → transform wiring, straight from the full parser. + let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&data[..])) + .with_context(|| format!("parsing M2 '{name}'"))?; + let m = fmt.model(); + if !m.texture_transforms.is_empty() { + println!("=== texture transforms ==="); + println!("lookup (header 0xac): {:?}", m.texture_transform_lookup); + for (i, t) in m.texture_transforms.iter().enumerate() { + println!(" transform {i}:"); + print_txfm_track("translation", &t.translation); + print_txfm_track("rotation ", &t.rotation); + print_txfm_track("scaling ", &t.scaling); + } + if let Ok(skin) = m.parse_embedded_skin(&data, 0) { + for (bi, batch) in skin.batches().iter().enumerate() { + println!( + " batch {bi}: txfm combo {} (texture combo {}, material {})", + batch.texture_transform_combo_index, + batch.texture_combo_index, + batch.material_index + ); + } + } + } + // Color-alpha + texture-weight keys in full (they're tiny scalar tracks): the "how does this + // effect fade" instrument — the UI cooldown model's finish-flash ramp was pinned from exactly + // this dump (decision 0137 phase 4). + if !m.color_alpha_tracks.is_empty() { + println!("=== color alpha tracks (per M2Color) ==="); + for (i, t) in m.color_alpha_tracks.iter().enumerate() { + println!(" color {i}: interp {}, keys {:?}", t.interp, t.keys); + } + } + if !m.transparency_tracks.is_empty() { + println!("=== transparency (texture-weight) tracks ==="); + for (i, t) in m.transparency_tracks.iter().enumerate() { + println!(" weight {i}: interp {}, keys {:?}", t.interp, t.keys); + } + } + // Bone SCALE keys per sequence — the "how does this element grow" instrument (the cooldown + // star's finish-flash pulse is a bone-scale curve, decision 0263's INTERIM). Scale-keyed + // bones only; effect/UI models keep this tiny. + let seqs = benilla_formats::parse_m2_animations(&data); + let any_scaled = seqs + .iter() + .any(|s| s.bones.iter().any(|b| !b.scale.is_empty())); + if any_scaled { + println!("=== bone scale tracks (per sequence; scale-keyed bones only) ==="); + for (si, s) in seqs.iter().enumerate() { + for (bi, b) in s.bones.iter().enumerate() { + if b.scale.is_empty() { + continue; + } + let keys: Vec = b + .scale + .iter() + .map(|(t, v)| format!("{t:.3}s [{:.3} {:.3} {:.3}]", v[0], v[1], v[2])) + .collect(); + println!(" seq {si} bone {bi}: {}", keys.join(" -> ")); + } + } + } + Ok(()) +} + +/// Dump an M2's bone table: KeyBoneID, flags, parent, pivot, and which sequences key each bone. +pub fn m2bones(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&data[..])) + .with_context(|| format!("parsing M2 '{name}'"))?; + let m = fmt.model(); + let seqs = benilla_formats::parse_m2_animations(&data); + println!("idx keybone flags bb parent pivot keyed (seq[idx] T/R/S counts)"); + for (i, b) in m.bones.iter().enumerate() { + let keyed: Vec = seqs + .iter() + .enumerate() + .filter_map(|(si, s)| { + let bk = s.bones.iter().find(|bk| bk.bone as usize == i)?; + Some(format!( + "seq{si}[T{} R{} S{}]", + bk.translation.len(), + bk.rotation.len(), + bk.scale.len() + )) + }) + .collect(); + println!( + "{i:>3} {:>7} {:#010x} {:>2} {:>6} ({:>7.3}, {:>7.3}, {:>7.3}) {}", + b.key_bone, + b.flags.bits(), + yn(b.is_billboard()), + b.parent, + b.pivot.x, + b.pivot.y, + b.pivot.z, + keyed.join(" "), + ); + // Small tracks get their actual key values — two sequences can share a key COUNT while + // holding different values (the questgiver-marker seq 0 vs 190 lesson: counts alone + // mislabeled them "the same"). + for (si, s) in seqs.iter().enumerate() { + let Some(bk) = s.bones.iter().find(|bk| bk.bone as usize == i) else { + continue; + }; + if !bk.translation.is_empty() && bk.translation.len() <= 8 { + let keys: Vec = bk + .translation + .iter() + .map(|(t, v)| format!("{t:.3}s ({:.3}, {:.3}, {:.3})", v[0], v[1], v[2])) + .collect(); + println!(" seq{si} (anim {}) T: {}", s.anim_id, keys.join(" ")); + } + // Rotation keys as axis-angle (model-space WoW axes, Z up) — the "which way does + // this element actually turn" instrument: an emitter/billboard orientation bug + // needs the spin axis as ground truth, which a bare key COUNT never shows. Small + // tracks print every key; a long track (a swirl/rotor loop) prints first/last plus + // the axis of the first key-to-key increment — the spin axis itself. + if !bk.rotation.is_empty() { + let aa = |q: &[f32; 4]| { + let w = q[3].clamp(-1.0, 1.0); + let angle = 2.0 * w.acos(); + let s = (1.0 - w * w).sqrt(); + let (x, y, z) = if s < 1e-5 { + (0.0, 0.0, 1.0) + } else { + (q[0] / s, q[1] / s, q[2] / s) + }; + format!("{:+.2}°@({x:+.2},{y:+.2},{z:+.2})", angle.to_degrees()) + }; + if bk.rotation.len() <= 8 { + let keys: Vec = bk + .rotation + .iter() + .map(|(t, q)| format!("{t:.3}s {}", aa(q))) + .collect(); + println!(" seq{si} (anim {}) R: {}", s.anim_id, keys.join(" ")); + } else { + let (t0, q0) = &bk.rotation[0]; + let (_t1, q1) = &bk.rotation[1]; + let (tn, qn) = bk.rotation.last().unwrap(); + // increment = q1 · q0⁻¹ — its axis is the track's spin axis. + let inv0 = [-q0[0], -q0[1], -q0[2], q0[3]]; + let inc = [ + q1[3] * inv0[0] + q1[0] * inv0[3] + q1[1] * inv0[2] - q1[2] * inv0[1], + q1[3] * inv0[1] - q1[0] * inv0[2] + q1[1] * inv0[3] + q1[2] * inv0[0], + q1[3] * inv0[2] + q1[0] * inv0[1] - q1[1] * inv0[0] + q1[2] * inv0[3], + q1[3] * inv0[3] - q1[0] * inv0[0] - q1[1] * inv0[1] - q1[2] * inv0[2], + ]; + println!( + " seq{si} (anim {}) R: {} keys first {t0:.3}s {} step {} last {tn:.3}s {}", + s.anim_id, + bk.rotation.len(), + aa(q0), + aa(&inc), + aa(qn), + ); + } + } + } + } + eprintln!("{} bones", m.bones.len()); + Ok(()) +} + +/// Dump an M2's render batches as the renderer sees them. +pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { + let name = normalize(internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let subs = benilla_formats::parse_m2_render_submeshes(&data, dir, &[]) + .with_context(|| format!("parsing M2 render submeshes '{name}'"))?; + println!("{} render batch(es)", subs.len()); + for (i, s) in subs.iter().enumerate() { + let mut flags = Vec::new(); + if s.emissive { + flags.push("emissive"); + } + if s.additive { + flags.push("additive"); + } + if s.two_sided { + flags.push("two-sided"); + } + if s.no_depth_write { + flags.push("no-depth-write"); + } + if s.no_depth_test { + flags.push("no-depth-test"); + } + if s.billboard.is_some() { + flags.push("BILLBOARD"); + } + if s.alpha_anim.is_some() { + flags.push("alpha-anim"); + } + if s.uv_anim.is_some() { + flags.push("uv-anim"); + } + // A character runtime slot (body atlas / hair / object / extra skin) has no embedded path — + // name the slot rather than a misleading bare NONE. + let tex = match (&s.texture, s.char_slot) { + (Some(t), _) => t.clone(), + (None, Some(slot)) => format!(""), + (None, None) => "NONE".into(), + }; + println!( + " batch {i}: geoset {:>4} {:?} {} verts [{}] tex {}", + s.geoset_id, + s.blend, + s.positions.len(), + flags.join(" "), + tex, + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs new file mode 100644 index 00000000..d5b54c3a --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -0,0 +1,390 @@ +//! `benilla-extract` — Phase 1 CLI over WoW 1.12.1 (build 5875) MPQ archives. +//! +//! Implemented: `list`, `extract` (raw bytes), operating on the **patch chain** +//! (later archives override earlier; real names come from patch.MPQ's `(listfile)`). +//! BLP->PNG and DBC->CSV come next. +//! +//! This file is the stable face: the clap [`Cli`]/[`Command`] definitions, the dispatch `match`, +//! and the tiny helpers shared across concerns. The heavier subcommands live in sibling modules by +//! concern: per-model M2 dump printers ([`m2dump`]), corpus-scan reports ([`scan`]), and the spell +//! visual chain ([`spellvis`]). + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use benilla_formats::open_chain; +use clap::{Parser, Subcommand}; + +mod m2dump; +mod scan; +mod spellvis; + +/// Read WoW 1.12.1 asset archives (MPQ). +#[derive(Parser)] +#[command( + name = "benilla-extract", + version, + about, + allow_negative_numbers = true +)] +struct Cli { + /// A single `.MPQ` file, or a vanilla `Data` directory (opens the full patch chain). + path: PathBuf, + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// List files across the chain, sorted by name. + List, + /// Extract one file by its internal path, writing raw bytes to `output`. + Extract { + /// Internal path inside the archive (forward or back slashes accepted). + internal_path: String, + /// Output file to write the extracted bytes to. + output: PathBuf, + }, + /// Decode a BLP texture from the archive and write it as a PNG. + Blp { + /// Internal path to the `.blp` (forward or back slashes accepted). + internal_path: String, + /// Output `.png` file. + output: PathBuf, + }, + /// Dump a DBC table to CSV using our schema for it (headers included). + Dbc { + /// Internal path to the `.dbc` (forward or back slashes accepted). + internal_path: String, + /// Output `.csv` file. + output: PathBuf, + }, + /// Dump a spell's visual chain: spell → SpellVisual stages → each kit's anim/sound (decision + /// 0099's phase-2 instrument; columns per decision 0107). + Spellvis { + /// The `Spell.dbc` id (e.g. 133 = Fireball). + spell_id: u32, + }, + /// Dump an M2's collision hull as the mover collides with it: vertex/triangle counts, the + /// model-space AABB (WoW axes, Z up), and its extents — the "what does walking into this + /// actually hit" instrument (the step-up climb-vs-slide asset question, decision 0195; a + /// placement's own scale still multiplies these numbers). + M2coll { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's animation sequences in file order: `AnimationData.dbc` id, loop/clamp flag, + /// duration, variation frequency, replay range — the variation/replay instrument + /// (decisions 0114/0117; sequences sharing an id are its variation chain). + M2seq { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's animation EVENT keyframes per sequence (`$CSS`/`$CAH`/`$AH0-3`/`$CPP`/`$HIT`…, + /// time + payload) — the event-order instrument (decision 0279: whether `$CPP` precedes the + /// impact tag decides defense-anim vs flinch on the shared swing record). + M2events { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's attachment points (id + bone) — which sheath/held/effect anchors a model + /// actually has (decisions 0072/0122; a placement whose id is absent hangs nothing). + M2attach { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's animation-channel summary: sequence-0 bone motion, global-sequence bone + /// channels, transparency/color track key counts, texture transforms, and — per particle + /// emitter — its full def: shape, blend mode, head/tail, emission-rate keys (burst emitters + /// key `0 → peak → 0`), lifespan/speed/scale, position/area, resolved texture, and the + /// over-life color/alpha/size ramps. The one-command diagnosis for "this effect looks wrong / + /// doesn't show" (decisions 0130/0141); paired with `doodadscan`. + M2anim { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's bone table: KeyBoneID, flags (billboard bits tagged), parent, pivot, and which + /// sequences key each bone (T/R/S key counts) — the bone-attach / billboard-geometry instrument + /// (decisions 0028/0122: which bone a rider actually rides, and whether it faces the camera). + M2bones { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Dump an M2's render batches as the renderer sees them: geoset, blend/flags + /// (emissive/additive/two-sided/billboard/depth), texture, vertex count, and whether an + /// alpha/UV animation baked — the "why doesn't this part of the model draw (or draw wrong)" + /// instrument (the Orgrimmar-bonfire missing-flame diagnosis). + M2batch { + /// Internal path to the `.m2` (forward or back slashes accepted). + internal_path: String, + }, + /// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters (header + /// `0x134`) — the population instrument for the ribbon subsystem (weapon trails, streamers): + /// which content actually authors one, before we build for it. + Ribbonscan, + /// Sweep every `.m2` (optionally under a path prefix) and list the models whose MATERIAL + /// table authors the MULTIPLY blend modes 5 (Mod) / 6 (Mod2x) — the ARMORREFLECT sheen + /// family (decision 0528): the population instrument for the multiply-blend mechanism. + Blendscan { + /// Internal-path prefix filter (e.g. `item\objectcomponents\weapon`), case-insensitive; + /// all models if omitted. + prefix: Option, + }, + /// Sweep every `.m2` (optionally under a path prefix) and classify its BILLBOARD usage: + /// which arms it authors (spherical / lock-X / lock-Y / lock-Z), and whether geometry rides + /// them DIRECTLY (verts skinned to the billboard bone — the per-batch card path) or + /// INHERITED (verts on a descendant — the joint-palette path, decision 0205). The + /// population instrument for the billboard mechanism: which arms real content exercises, so + /// a class of spell visuals is closed by mechanism instead of tested spell-by-spell. + Bbscan { + /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. + prefix: Option, + }, + /// Sweep every `.m2` (optionally under a path prefix) and report models that author flat + /// ground-plane render geometry: batches whose vertices all sit at model-space z≈0 (WoW axes, + /// Z up), which sloped terrain buries (Battle Shout's crescents are the canonical case — 6 + /// batches, each a 4-vert quad, every vertex exactly z=0, each quad skinned 100% to a single + /// bone). Flat batches sub-classify QUAD-1BONE (that exact crescent shape) vs OTHER-FLAT — the + /// population instrument for how general the ground-plane renderer mechanism must be. + Groundscan { + /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. + prefix: Option, + }, + /// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter + /// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x), + /// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the + /// record fields the renderer doesn't parse yet (geometry-model "model particles", + /// recursion-model child emitters, the rate track's interp word) — each with counts, example + /// models, and the spells whose visual chain plays them. The population instrument that turns + /// "this spell looks wrong" reports into a ranked mechanism worklist, closing feature classes + /// corpus-wide instead of spell-by-spell. + Partcensus { + /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. + prefix: Option, + }, + /// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE + /// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the + /// population instrument for particle-flag mechanisms (which content actually authors + /// e.g. the `0x1000` plane-quad leg), so a class of effects is closed by mechanism + /// instead of tested spell-by-spell. + Partscan { + /// The flag mask to match, hex or decimal (e.g. `0x1000`). + mask: String, + /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. + prefix: Option, + }, + /// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 + /// dynamic LIGHT blocks — the population instrument for the mechanism (decision 0016 / wow-re + /// `system/models/scratch/m2-dynamic-lights.md`). Per model: its point (`type==1`, the GL + /// hot-spot caster) vs directional (ambient-feed) light counts, then per point light its + /// bone/position/diffuse colour×intensity/attenuation/visibility-gate. The closing summary — + /// totals, a breakdown by top-level content family (Creature/Item.ObjectComponents/ + /// Character/World/Spells/other), and a diffuse-palette tally — is the real deliverable: + /// benilla only spawns these lights for ADT-placed doodads and WMO props today, so it answers + /// how much of the entity path (creatures, held items, GameObjects) is missing them. + M2lightscan { + /// Internal-path prefix filter (e.g. `item\objectcomponents`), case-insensitive; all + /// models if omitted. + prefix: Option, + }, + /// Dump all 18 `LightIntBand` rows of the `Light.dbc` entry covering a world position at a + /// time of day — the band-semantics instrument (which row holds which colour at which hour; + /// the celestial-diffuse band question, decision 0485). + Lightbands { + /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). + map: u32, + /// World-space X (raw WoW yards; may be negative). + #[arg(allow_hyphen_values = true)] + x: f32, + /// World-space Y. + #[arg(allow_hyphen_values = true)] + y: f32, + /// World-space Z. + #[arg(allow_hyphen_values = true)] + z: f32, + /// Game minute-of-day (0..1440; 720 = noon). + minute: u32, + }, + /// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a + /// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that + /// content animates (see `m2anim`) — the doodad-animation perf/scope instrument. + Doodadscan { + /// Map directory name (e.g. `Azeroth`). + map: String, + /// World-space center X (may be negative). + #[arg(allow_hyphen_values = true)] + center_x: f32, + /// World-space center Y (may be negative). + #[arg(allow_hyphen_values = true)] + center_y: f32, + /// ADT tile radius around the center tile to scan (0 = just the containing tile). + tile_radius: u32, + }, + /// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose + /// model path contains a substring — position, Euler rotation (deg), scale, uniqueId. The + /// "point at a thing in the world, tell me exactly how it's placed" instrument: an + /// orientation bug needs the placement rotation as ground truth, which `doodadscan`'s + /// aggregates never show. + Placescan { + /// Map directory name (e.g. `Azeroth`). + map: String, + /// World-space center X (may be negative). + #[arg(allow_hyphen_values = true)] + center_x: f32, + /// World-space center Y (may be negative). + #[arg(allow_hyphen_values = true)] + center_y: f32, + /// ADT tile radius around the center tile to scan (0 = just the containing tile). + tile_radius: u32, + /// Case-insensitive substring of the model path (e.g. `instanceportal`). + filter: String, + }, +} + +/// MPQ internal paths are backslash-separated; accept `/` for convenience. +fn normalize(path: &str) -> String { + path.replace('/', "\\") +} + +/// The grouping/lookup key for a model path referenced from MDDF/MODD (`doodadscan`'s instance +/// tables): lowercase, `.mdx`/`.mdl` → `.m2`. Mirrors `benilla_formats`' own (crate-private) +/// `model_path` normalization so two placements of the same model authored under different +/// casing/extension collapse to one row instead of double-counting it. +fn model_key(raw: &str) -> String { + let lower = raw.to_ascii_lowercase(); + match lower + .strip_suffix(".mdx") + .or_else(|| lower.strip_suffix(".mdl")) + { + Some(stem) => format!("{stem}.m2"), + None => lower, + } +} + +/// Parse a `0x`-prefixed hex or plain-decimal u32 (`partscan`'s mask argument). +fn parse_u32_maybe_hex(s: &str) -> Result { + match s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + Some(hex) => u32::from_str_radix(hex, 16).context("invalid hex"), + None => s.parse().context("invalid decimal"), + } +} + +/// `"yes"`/`"-"` for a compact table cell. +fn yn(b: bool) -> &'static str { + if b { + "yes" + } else { + "-" + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let mut chain = open_chain(&cli.path)?; + + match cli.command { + Command::List => { + let mut files = chain.list().context("listing chain contents")?; + files.sort_by(|a, b| a.name.cmp(&b.name)); + for entry in &files { + println!("{:>12} {}", entry.size, entry.name); + } + eprintln!("{} files", files.len()); + } + Command::Extract { + internal_path, + output, + } => { + let name = normalize(&internal_path); + // Note which archive the file resolved from (handy for debugging overrides). + let source = chain + .find_file_archive(&name) + .map(|p| p.display().to_string()); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + std::fs::write(&output, &data) + .with_context(|| format!("writing {}", output.display()))?; + match source { + Some(src) => eprintln!( + "wrote {} bytes to {} (from {})", + data.len(), + output.display(), + src + ), + None => eprintln!("wrote {} bytes to {}", data.len(), output.display()), + } + } + Command::Blp { + internal_path, + output, + } => { + let name = normalize(&internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let (w, h) = benilla_formats::blp_to_png(&data, &output) + .with_context(|| format!("decoding BLP '{name}'"))?; + eprintln!("decoded {w}x{h} -> {}", output.display()); + } + Command::Dbc { + internal_path, + output, + } => { + let name = normalize(&internal_path); + let data = chain + .read_file(&name) + .with_context(|| format!("reading '{name}' from chain"))?; + let (records, fields) = benilla_formats::dbc_to_csv(&data, &name, &output)?; + eprintln!( + "wrote {records} records ({fields} fields) -> {}", + output.display() + ); + } + Command::M2coll { internal_path } => m2dump::m2coll(&mut chain, &internal_path)?, + Command::M2seq { internal_path } => m2dump::m2seq(&mut chain, &internal_path)?, + Command::M2events { internal_path } => m2dump::m2events(&mut chain, &internal_path)?, + Command::M2attach { internal_path } => m2dump::m2attach(&mut chain, &internal_path)?, + Command::M2anim { internal_path } => m2dump::m2anim(&mut chain, &internal_path)?, + Command::M2bones { internal_path } => m2dump::m2bones(&mut chain, &internal_path)?, + Command::M2batch { internal_path } => m2dump::m2batch(&mut chain, &internal_path)?, + Command::Ribbonscan => scan::ribbonscan(&mut chain)?, + Command::Blendscan { prefix } => scan::blendscan(&mut chain, prefix.as_deref())?, + Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?, + Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?, + Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, + Command::Partscan { mask, prefix } => { + let mask = parse_u32_maybe_hex(&mask) + .with_context(|| format!("parsing flag mask '{mask}'"))?; + scan::partscan(&mut chain, mask, prefix.as_deref())?; + } + Command::M2lightscan { prefix } => scan::m2lightscan(&mut chain, prefix.as_deref())?, + Command::Lightbands { + map, + x, + y, + z, + minute, + } => { + let catalog = benilla_formats::LightCatalog::load(&mut chain)?; + // `debug_bands` takes the client's half-minute clock (0..2880). + catalog.debug_bands(map, [x, y, z], minute * 2); + } + Command::Placescan { + map, + center_x, + center_y, + tile_radius, + filter, + } => scan::placescan(&mut chain, &map, center_x, center_y, tile_radius, &filter)?, + Command::Doodadscan { + map, + center_x, + center_y, + tile_radius, + } => scan::doodadscan(&mut chain, &map, center_x, center_y, tile_radius)?, + Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?, + } + + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs new file mode 100644 index 00000000..1c167a07 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -0,0 +1,1139 @@ +//! Corpus-scan reports: `ribbonscan` (which models carry ribbon emitters), `groundscan` (which +//! models author flat ground-plane render geometry), and `doodadscan` (how much placed content in +//! a map block actually animates) — the population instruments that sweep many models/placements +//! rather than dumping one. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use anyhow::{Context, Result}; +use benilla_formats::{Chain, M2AnimSummary, M2Light}; + +use crate::{model_key, yn}; + +/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters. +pub fn ribbonscan(chain: &mut Chain) -> Result<()> { + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| n.to_ascii_lowercase().ends_with(".m2")) + .collect(); + let (mut scanned, mut hits) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let n = benilla_formats::m2_ribbon_emitter_count(&bytes); + if n > 0 { + hits += 1; + println!("{n:>2} {name}"); + } + } + eprintln!("{scanned} models scanned, {hits} with ribbons"); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors +/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per +/// matching model: its per-material `(flags, blend)` pairs and path. The raw header read (materials +/// count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}` records) matches `benilla-m2`'s parse. +pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let (mut scanned, mut hits) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let at = |o: usize| -> Option { + Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?)) + }; + let (Some(n), Some(ofs)) = (at(0x84), at(0x88)) else { + continue; + }; + let mats: Vec<(u16, u16)> = (0..n as usize) + .filter_map(|i| { + let o = ofs as usize + i * 4; + let b = bytes.get(o..o + 4)?; + Some(( + u16::from_le_bytes([b[0], b[1]]), + u16::from_le_bytes([b[2], b[3]]), + )) + }) + .collect(); + if mats.iter().any(|&(_, blend)| blend == 5 || blend == 6) { + hits += 1; + println!("{mats:?} {name}"); + } + } + eprintln!("{scanned} models scanned, {hits} with Mod/Mod2x materials"); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and list the models whose particle emitters +/// carry any bit of `mask` in their file flags — see the `Partscan` command doc. One line per +/// matching emitter: its index, full flag word, shape/type, and the model path. +pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let (mut scanned, mut hits, mut emitters) = (0u32, 0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + let mut hit = false; + for (i, d) in defs.iter().enumerate() { + if d.flags & mask != 0 { + hit = true; + emitters += 1; + println!( + "e{i} flags {:#010x} {:?} {} {name}", + d.flags, + d.shape, + match d.head_tail { + 0 => "head", + 1 => "tail", + _ => "head+tail", + }, + ); + } + } + if hit { + hits += 1; + } + } + eprintln!( + "{scanned} models scanned, {hits} with mask {mask:#x} emitters ({emitters} emitters)" + ); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the +/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each +/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone +/// descends from one — the joint-palette path, decision 0205). +pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let arm = |k: benilla_formats::BillboardKind| match k { + benilla_formats::BillboardKind::Spherical => "S", + benilla_formats::BillboardKind::LockX => "X", + benilla_formats::BillboardKind::LockY => "Y", + benilla_formats::BillboardKind::LockZ => "Z", + }; + let (mut scanned, mut hits) = (0u32, 0u32); + // Corpus totals: models exercising each arm, split by how the geometry rides it. + let mut direct_models: HashMap<&'static str, u32> = HashMap::new(); + let mut inherited_models: HashMap<&'static str, u32> = HashMap::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(bytes.as_slice())) else { + continue; + }; + let m = fmt.model(); + let kinds: Vec> = m + .bones + .iter() + .map(|b| benilla_formats::BillboardKind::from_bone_flags(b.flags.bits())) + .collect(); + if kinds.iter().all(|k| k.is_none()) { + continue; + } + hits += 1; + // Nearest billboard ancestor (self included) per bone — the arm whose palette + // replacement a vertex on this bone inherits. Bounded walk (M2 parents precede + // children; the bound is just a malformed-file guard). + let ancestor_arm = |mut i: usize| -> Option<(bool, benilla_formats::BillboardKind)> { + let mut hops = 0; + loop { + if let Some(k) = kinds.get(i).copied().flatten() { + return Some((hops == 0, k)); + } + let p = usize::try_from(*m.bones.get(i).map(|b| &b.parent)?).ok()?; + hops += 1; + if p >= m.bones.len() || hops > m.bones.len() { + return None; + } + i = p; + } + }; + let (mut direct, mut inherited): (HashMap<&str, u32>, HashMap<&str, u32>) = + (HashMap::new(), HashMap::new()); + for v in &m.vertices { + match ancestor_arm(v.bone_indices[0] as usize) { + Some((true, k)) => *direct.entry(arm(k)).or_default() += 1, + Some((false, k)) => *inherited.entry(arm(k)).or_default() += 1, + None => {} + } + } + let fmt_counts = |m: &HashMap<&str, u32>| { + let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect(); + println!( + "{bones:>4} direct[{}] inherited[{}] {name}", + fmt_counts(&direct), + fmt_counts(&inherited) + ); + for k in direct.keys() { + *direct_models + .entry(match *k { + "S" => "S", + "X" => "X", + "Y" => "Y", + _ => "Z", + }) + .or_default() += 1; + } + for k in inherited.keys() { + *inherited_models + .entry(match *k { + "S" => "S", + "X" => "X", + "Y" => "Y", + _ => "Z", + }) + .or_default() += 1; + } + } + let tot = |m: &HashMap<&'static str, u32>| { + let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + eprintln!( + "{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]", + tot(&direct_models), + tot(&inherited_models) + ); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane +/// render geometry — the population instrument for the class of spell effects that lie in the +/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's +/// crescents are the canonical case: 6 batches, each a 4-vert quad, every vertex exactly z=0, each +/// quad skinned 100% to a single bone). Per batch (same batch/geoset resolution `m2batch` uses): +/// FLAT if every vertex has `|z| <= 0.01` in model space; flat batches sub-classify QUAD-1BONE +/// (the crescent shape — [`benilla_formats::RenderSubmesh::ground_quad`], the ground-fx decal +/// lane's own detector) vs OTHER-FLAT (flat but not that shape, staying on the ordinary render +/// path) — which decides how general the renderer mechanism has to be. +pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); + let (mut quad_total, mut other_total) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let total_batches = subs.len(); + let (mut flat_count, mut quad_count, mut other_count) = (0u32, 0u32, 0u32); + let mut blend_modes: Vec = Vec::new(); + for s in &subs { + if s.positions.is_empty() { + continue; + } + if !s.positions.iter().all(|v| v[2].abs() <= 0.01) { + continue; + } + flat_count += 1; + let bm = format!("{:?}", s.blend); + if !blend_modes.contains(&bm) { + blend_modes.push(bm); + } + // The RENDERER's own detector, so this report is exactly what the ground-fx + // decal lane will do with each batch — the instrument can't drift from the + // mechanism it measures. + if s.ground_quad().is_some() { + quad_count += 1; + } else { + other_count += 1; + } + } + if flat_count == 0 { + continue; + } + hits += 1; + quad_total += quad_count; + other_total += other_count; + if flat_count as usize == total_batches { + all_flat += 1; + } else { + mixed += 1; + } + blend_modes.sort(); + println!( + "{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat) blend[{}] {name}", + blend_modes.join(" ") + ); + } + eprintln!( + "{scanned} models scanned, {hits} with flat batches ({all_flat} all-flat, {mixed} mixed); flat batches: {quad_total} QUAD-1BONE, {other_total} OTHER-FLAT" + ); + Ok(()) +} + +/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose model +/// path contains `filter` (case-insensitive) — the per-placement position / Euler rotation / +/// scale / uniqueId ground truth an orientation investigation needs (`doodadscan` only +/// aggregates). +pub fn placescan( + chain: &mut Chain, + map: &str, + center_x: f32, + center_y: f32, + tile_radius: u32, + filter: &str, +) -> Result<()> { + let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) + .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; + eprintln!("{} tile(s) loaded", tiles.len()); + let needle = filter.to_ascii_lowercase(); + let mut seen: HashSet = HashSet::new(); + let mut hits = 0u32; + for (_, tile) in &tiles { + for d in &tile.doodads { + if seen.insert(d.unique_id) && model_key(&d.model).contains(&needle) { + hits += 1; + println!( + "MDDF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) scale {:.3} {}", + d.unique_id, + d.position[0], d.position[1], d.position[2], + d.rotation[0], d.rotation[1], d.rotation[2], + d.scale, + d.model, + ); + } + } + for w in &tile.wmos { + if seen.insert(w.unique_id) && w.model.to_ascii_lowercase().contains(&needle) { + hits += 1; + println!( + "MODF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) {}", + w.unique_id, + w.position[0], w.position[1], w.position[2], + w.rotation[0], w.rotation[1], w.rotation[2], + w.model, + ); + } + } + } + eprintln!("{hits} placement(s) matched '{filter}'"); + Ok(()) +} + +/// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a +/// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that +/// content animates. +pub fn doodadscan( + chain: &mut Chain, + map: &str, + center_x: f32, + center_y: f32, + tile_radius: u32, +) -> Result<()> { + let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) + .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; + eprintln!("{} tile(s) loaded", tiles.len()); + + // Direct M2 placements (MDDF) — deduped by uniqueId, which a tile-straddling doodad + // repeats identically across every tile it touches (decision-0021 terrain streamer's own + // dedup key; see `benilla_formats::Doodad::unique_id`). + let mut seen_doodad_ids: HashSet = HashSet::new(); + let mut m2_instances: HashMap = HashMap::new(); + // WMO placements (MODF) — same dedup, by their own uniqueId. + let mut seen_wmo_ids: HashSet = HashSet::new(); + let mut wmo_instances: HashMap = HashMap::new(); + for (_, tile) in &tiles { + for d in &tile.doodads { + if seen_doodad_ids.insert(d.unique_id) { + *m2_instances.entry(model_key(&d.model)).or_insert(0) += 1; + } + } + for w in &tile.wmos { + if seen_wmo_ids.insert(w.unique_id) { + *wmo_instances.entry(w.model.clone()).or_insert(0) += 1; + } + } + } + let direct_m2_instances: u32 = m2_instances.values().sum(); + eprintln!( + "{direct_m2_instances} MDDF doodad placement(s) across {} unique M2 model(s)", + m2_instances.len() + ); + eprintln!( + "{} MODF WMO placement(s) across {} unique WMO model(s)", + seen_wmo_ids.len(), + wmo_instances.len() + ); + + // Fold each unique WMO's doodad-set-**0** M2 props into the same instance table (set 0 is + // the WMO's always-on global set, per `WmoDoodadSet` doc), each multiplied by that WMO's + // own (deduped) placement count — one building placement = one instance of every set-0 prop + // it carries. + let mut wmo_root_failures = 0u32; + for (wmo_path, &count) in &wmo_instances { + let root_path = wmo_path.to_ascii_lowercase(); // matches `load_wmo`'s own normalization + let root = chain + .read_file(&root_path) + .ok() + .and_then(|bytes| benilla_formats::parse_wmo_root(&bytes).ok()); + let Some(root) = root else { + wmo_root_failures += 1; + continue; + }; + let Some(set0) = root.doodad_sets().first() else { + continue; + }; + let range = set0.start as usize..(set0.start as usize + set0.count as usize); + for wd in root.doodads().get(range).unwrap_or(&[]) { + if !wd.model.is_empty() { + *m2_instances.entry(model_key(&wd.model)).or_insert(0) += count; + } + } + } + if wmo_root_failures > 0 { + eprintln!(" ({wmo_root_failures} WMO root(s) failed to read/parse — skipped)"); + } + + // Per-unique-model animation summary — one parse per model regardless of instance count. + let mut summaries: HashMap = HashMap::new(); + let mut parse_failures: Vec<(String, String)> = Vec::new(); + for model in m2_instances.keys() { + match benilla_formats::load_m2_animation_summary(chain, model) { + Ok(s) => { + summaries.insert(model.clone(), s); + } + Err(e) => parse_failures.push((model.clone(), e.to_string())), + } + } + + let total_instances: u32 = m2_instances.values().sum(); + let total_models = m2_instances.len(); + println!(); + println!( + "=== totals ({total_instances} M2 instance(s), {total_models} unique model(s), {} parse failure(s)) ===", + parse_failures.len() + ); + + type ChannelCheck = (&'static str, fn(&M2AnimSummary) -> bool); + let checks: [ChannelCheck; 9] = [ + ("seq-0 bone motion", |s| s.seq0_has_bone_motion), + ("moving seq0, >1 variation", |s| { + s.seq0_has_bone_motion && s.seq0_variation_count > 1 + }), + ("global-seq bones", |s| !s.global_seq_channels.is_empty()), + ("animated transparency", |s| s.transparency_tracks.1 > 0), + ("animated color", |s| { + s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0 + }), + ("texture transforms", |s| s.texture_transform_count > 0), + ("particles", |s| s.particle_emitter_count > 0), + ("emitter on moving bone", |s| { + s.emitter_bones.iter().any(|e| e.chain_animated()) + }), + ("ribbons", |s| s.ribbon_emitter_count > 0), + ]; + let report_row = |label: &str, pred: &dyn Fn(&M2AnimSummary) -> bool| { + let inst: u32 = m2_instances + .iter() + .filter_map(|(m, &c)| summaries.get(m).filter(|s| pred(s)).map(|_| c)) + .sum(); + let models = summaries.values().filter(|s| pred(s)).count(); + println!( + " {label:22} {inst:>6} instances ({:5.1}%) {models:>4} models ({:5.1}%)", + 100.0 * f64::from(inst) / f64::from(total_instances.max(1)), + 100.0 * models as f64 / total_models.max(1) as f64, + ); + }; + for (label, pred) in checks { + report_row(label, &pred); + } + report_row("NO animated channel", &|s| s.is_fully_static()); + + println!(); + println!("=== top 30 models by instance count ==="); + println!( + "{:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} model", + "count", "seq0", "gseq", "trns", "clr", "txfm", "part", "ribn" + ); + let mut ranked: Vec<(&String, &u32)> = m2_instances.iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in ranked.iter().take(30) { + match summaries.get(*model) { + Some(s) => println!( + "{count:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} {model}", + yn(s.seq0_has_bone_motion), + yn(!s.global_seq_channels.is_empty()), + yn(s.transparency_tracks.1 > 0), + yn(s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0), + yn(s.texture_transform_count > 0), + yn(s.particle_emitter_count > 0), + yn(s.ribbon_emitter_count > 0), + ), + None => println!("{count:>6} {model}"), + } + } + + // The rare material channels by NAME (each is <1% of instances, so the top-30 table + // almost never surfaces them): the exact models the phase-2/3 material-animation work + // verifies against. + println!(); + println!("=== material-channel models (animated transparency / color / UV) ==="); + let mut rare: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries.get(*m).is_some_and(|s| { + s.transparency_tracks.1 > 0 + || s.color_rgb_tracks.1 > 0 + || s.color_alpha_tracks.1 > 0 + || s.texture_transform_count > 0 + }) + }) + .collect(); + rare.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &rare { + let s = &summaries[*model]; + println!( + "{count:>6} trns:{} clr:{} txfm:{} {model}", + s.transparency_tracks.1, + s.color_rgb_tracks.1 + s.color_alpha_tracks.1, + s.texture_transform_count, + ); + } + + // Moving-seq0 models with a variation chain, by NAME (the wow-re §4a random-variation + // arm correction): the exact placed models where variationIdx −1 vs 0 is visible at all. + println!(); + println!("=== moving-seq0 multi-variation models ==="); + let mut varied: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries + .get(*m) + .is_some_and(|s| s.seq0_has_bone_motion && s.seq0_variation_count > 1) + }) + .collect(); + varied.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &varied { + let s = &summaries[*model]; + println!( + "{count:>6} {} variation(s) of seq0 {model}", + s.seq0_variation_count + ); + } + + // Emitters hosted on a moving bone chain, by NAME (0130 phase 4 grounding): the exact + // placed models where emitter bone-follow is visible at all — an emitter on a static + // chain sits at its rest pose whether or not we attach it. + println!(); + println!("=== emitter-on-moving-bone models ==="); + let mut movers: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries + .get(*m) + .is_some_and(|s| s.emitter_bones.iter().any(|e| e.chain_animated())) + }) + .collect(); + movers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &movers { + let s = &summaries[*model]; + let moving = s + .emitter_bones + .iter() + .filter(|e| e.chain_animated()) + .count(); + println!( + "{count:>6} {moving}/{} emitter(s) on moving bones {model}", + s.emitter_bones.len() + ); + } + + if !parse_failures.is_empty() { + println!(); + println!("=== parse failures ({}) ===", parse_failures.len()); + for (model, err) in &parse_failures { + println!(" {model}: {err}"); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// partcensus — the particle FEATURE CENSUS (see the `Partcensus` command doc). +// --------------------------------------------------------------------------- + +/// The census's raw-extras view of one on-disk emitter record: the fields the shipped +/// [`benilla_formats::ParticleEmitterDef`] deliberately does not carry (yet). Read straight off +/// the record bytes (stride/header per the parser's module doc): the two model-filename M2Arrays +/// at `+0x18` (geometry model — 3-D "model particles") and `+0x20` (recursion model — per-particle +/// child emitters), and the emission-rate track's interpolation word (`+0xdc`, 0 = step). +struct RecordExtras { + geometry_model: Option, + recursion_model: Option, + rate_interp: u16, + rate_keys: u32, +} + +/// Read the raw extras for every emitter record in an M2 (empty if not an MD20 or no emitters). +fn record_extras(bytes: &[u8]) -> Vec { + const STRIDE: usize = 0x1f8; + let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); + let str_at = |count: usize, ofs: usize| -> Option { + if count == 0 || ofs == 0 || ofs + count > bytes.len() { + return None; + } + let s = String::from_utf8_lossy(&bytes[ofs..ofs + count]) + .trim_end_matches('\0') + .to_string(); + (!s.is_empty()).then_some(s) + }; + if bytes.len() < 0x144 || &bytes[..4] != b"MD20" { + return Vec::new(); + } + let count = u32_at(0x13c) as usize; + let base = u32_at(0x140) as usize; + if count == 0 || count > 256 || base + count * STRIDE > bytes.len() { + return Vec::new(); + } + (0..count) + .map(|i| { + let e = base + i * STRIDE; + RecordExtras { + geometry_model: str_at(u32_at(e + 0x18) as usize, u32_at(e + 0x1c) as usize), + recursion_model: str_at(u32_at(e + 0x20) as usize, u32_at(e + 0x24) as usize), + rate_interp: u16_at(e + 0xdc), + rate_keys: u32_at(e + 0xdc + 0x14), + } + }) + .collect() +} + +/// model key → the spells whose visual chain plays that model (any kit stage, or the missile). +fn spell_attribution(chain: &mut Chain) -> HashMap> { + let (Ok(spells), Ok(visuals)) = ( + benilla_formats::load_spell_catalog(chain), + benilla_formats::load_spell_visual_catalog(chain), + ) else { + eprintln!("(spell/visual DBCs unavailable — census runs without spell attribution)"); + return HashMap::new(); + }; + let mut map: HashMap> = HashMap::new(); + for (id, sp) in spells.iter() { + let Some(st) = visuals.stages(sp.visual) else { + continue; + }; + let mut push = |effect: u32| { + if effect != 0 { + if let Some(p) = visuals.effect_path(effect) { + map.entry(crate::model_key(p)) + .or_default() + .push((id, sp.name.clone())); + } + } + }; + for kit_id in [st.precast, st.cast, st.impact, st.state, st.channel] { + if kit_id == 0 { + continue; + } + if let Some(kit) = visuals.kit(kit_id) { + for (_, effect) in kit.effects() { + push(effect); + } + } + } + push(st.missile_model); + } + map +} + +/// One census dimension's tally. `model_count` counts every distinct model exactly; +/// `models` keeps the first 64 (sorted) for the example listings. +#[derive(Default)] +struct Tally { + emitters: u32, + model_count: u32, + last_model: String, + models: std::collections::BTreeSet, +} + +impl Tally { + /// The walk visits each model's emitters consecutively, so "new model" is a change of name. + fn hit(&mut self, model: &str) { + self.emitters += 1; + if self.last_model != model { + self.model_count += 1; + self.last_model = model.to_string(); + } + if self.models.len() < 64 { + self.models.insert(model.to_string()); + } + } +} + +/// Sweep every `.m2` (under `prefix`, if given) and census which particle-emitter FEATURES the +/// corpus actually authors — see the `Partcensus` command doc. +pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let attribution = spell_attribution(chain); + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + + let mut tallies: std::collections::BTreeMap<&'static str, Tally> = + std::collections::BTreeMap::new(); + let (mut scanned, mut with_emitters, mut total_emitters) = (0u32, 0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + if defs.is_empty() { + continue; + } + with_emitters += 1; + let extras = record_extras(&bytes); + let key = crate::model_key(&name); + for (i, d) in defs.iter().enumerate() { + total_emitters += 1; + let mut hit = |k: &'static str| tallies.entry(k).or_default().hit(&key); + match d.shape { + benilla_formats::ParticleShape::Plane => hit("shape:plane"), + benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), + benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), + } + match d.head_tail { + 0 => hit("type:head"), + 1 => hit("type:tail"), + _ => hit("type:head+tail"), + } + match d.blend { + benilla_formats::ParticleBlend::Add => hit("blend:add"), + benilla_formats::ParticleBlend::Alpha => hit("blend:alpha"), + benilla_formats::ParticleBlend::Opaque => hit("blend:opaque"), + } + // The raw blend byte disambiguates what the parsed enum folds: 5 = Mod, 6 = Mod2x. + { + const STRIDE: usize = 0x1f8; + let base = u32::from_le_bytes(bytes[0x140..0x144].try_into().unwrap()) as usize; + match bytes[base + i * STRIDE + 0x28] { + 5 => hit("blend:MOD(5)"), + 6 => hit("blend:MOD2X(6)"), + _ => {} + } + } + if d.flags & 0x1 == 0 { + hit("flag:LIT(0x1 clear)"); + } + for (bit, label) in [ + (0x8u32, "flag:0x8 texenv"), + (0x10, "flag:0x10 model-space"), + (0x20, "flag:0x20 scale-by-instance"), + (0x40, "flag:0x40 MOTION-VEL-INHERIT"), + (0x80, "flag:0x80 KILL-OUTBOUND"), + (0x100, "flag:0x100 sphere-up"), + (0x200, "flag:0x200"), + (0x400, "flag:0x400 tail-age-clamp"), + (0x800, "flag:0x800 SPAWN-PATH-SPREAD"), + (0x1000, "flag:0x1000 xy-quad"), + (0x2000, "flag:0x2000 GROUND-SNAP"), + (0x4000, "flag:0x4000 FOLLOW-DELTA"), + (0x8000, "flag:0x8000 burst"), + ] { + if d.flags & bit != 0 { + hit(label); + } + } + if d.flags >> 16 != 0 { + hit("flag:HIGH-BITS(>0xffff)"); + } + if d.spin > 0.0 { + hit("spin:positive"); + } + if d.spin < 0.0 { + hit("spin:negative"); + } + if d.twinkle_percent < 1.0 { + hit("twinkle:percent<1"); + } + if u32::from(d.tile_cols) * u32::from(d.tile_rows) > 1 { + hit("tiles:atlas"); + if !d.tile_cols.is_power_of_two() { + hit("tiles:NON-POW2-COLS"); + } + } + if d.z_source != 0.0 { + hit("kernel:zSource"); + } + if d.gravity < 0.0 { + hit("kernel:negative-gravity"); + } + if d.shape == benilla_formats::ParticleShape::Sphere + && d.vertical_range > 3.0 + && d.horizontal_range == 0.0 + { + hit("kernel:edge-on-ring(lat±π,lon0)"); + } + if let Some(x) = extras.get(i) { + if x.geometry_model.is_some() { + hit("MODEL-PARTICLES(geometry)"); + } + if x.recursion_model.is_some() { + hit("CHILD-EMITTERS(recursion)"); + } + if x.rate_keys > 1 && x.rate_interp != 0 { + hit("rate:LERP-RAMP(interp!=0)"); + // A BURST emitter with a lerp rate track would arm a near-zero count at its + // rising edge — if the corpus authored one, the burst edge law needs a re-look. + if d.burst() { + hit("rate:BURST+LERP(suspect)"); + } + } + } + } + } + + println!( + "== particle feature census prefix={} ({scanned} models scanned, {with_emitters} with emitters, {total_emitters} emitters)", + prefix.unwrap_or(""), + ); + for (k, t) in &tallies { + let ex: Vec<&str> = t.models.iter().take(3).map(|s| s.as_str()).collect(); + println!( + "{:>6} emitters {:>4} models {k} e.g. {}", + t.emitters, + t.model_count, + ex.join(" · ") + ); + } + + // The full model list — with spell attribution — for the dimensions that decide mechanism + // scope (the UPPERCASE keys: unimplemented or folded legs, plus the odd corners). + let detail: &[&str] = &[ + "MODEL-PARTICLES(geometry)", + "CHILD-EMITTERS(recursion)", + "shape:SPLINE", + "blend:MOD(5)", + "blend:MOD2X(6)", + "flag:LIT(0x1 clear)", + "flag:0x40 MOTION-VEL-INHERIT", + "flag:0x80 KILL-OUTBOUND", + "flag:0x800 SPAWN-PATH-SPREAD", + "flag:0x2000 GROUND-SNAP", + "flag:0x4000 FOLLOW-DELTA", + "flag:HIGH-BITS(>0xffff)", + "tiles:NON-POW2-COLS", + "rate:LERP-RAMP(interp!=0)", + ]; + for k in detail { + let Some(t) = tallies.get(k) else { continue }; + println!(); + println!( + "=== {k} ({} emitters, {} models)", + t.emitters, t.model_count + ); + for m in t.models.iter().take(16) { + let spells = attribution.get(m).map_or(String::new(), |v| { + let mut seen = HashSet::new(); + let names: Vec = v + .iter() + .filter(|(id, _)| seen.insert(*id)) + .take(3) + .map(|(id, n)| format!("{id} {n}")) + .collect(); + if names.is_empty() { + String::new() + } else { + format!( + " [{}{}]", + names.join(", "), + if v.len() > 3 { ", …" } else { "" } + ) + } + }); + println!(" {m}{spells}"); + } + if t.model_count as usize > 16 { + println!(" … {} more", t.model_count as usize - 16); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// m2lightscan — the M2 dynamic-LIGHT population instrument (see the `M2lightscan` command doc). +// --------------------------------------------------------------------------- + +/// Capitalize an `Item\ObjectComponents\` path component for consistent family-key display +/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ +/// `Weapon` all collapse to `Weapon`). +fn title_case(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => { + first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() + } + None => String::new(), + } +} + +/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary +/// dimension for "how much content is affected, and of what kind". Derived from the path's first +/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from +/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's +/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything +/// not otherwise named folds into `other`. +fn family_of(name: &str) -> String { + let comps: Vec<&str> = name.split('\\').collect(); + let low = |s: &str| s.to_ascii_lowercase(); + match comps.first().map(|s| low(s)).as_deref() { + Some("creature") => "Creature\\".to_string(), + Some("character") => "Character\\".to_string(), + Some("spells") => "Spells\\".to_string(), + Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => { + match comps.get(2) { + Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)), + None => "Item\\ObjectComponents\\".to_string(), + } + } + Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => { + "World\\Goober\\".to_string() + } + Some("world") => "World\\".to_string(), + _ => "other".to_string(), + } +} + +/// How many rows of the closing colour tally print (the rest are counted, never silently dropped). +const TALLY_ROWS: usize = 20; + +/// Cheap warm/cool/neutral hue classification of a `diffuse_color`, used only to eyeball the +/// colour-tally section of `m2lightscan`'s summary — the warm-torch family vs anything unusual. +fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { + if r >= g && r > b * 1.15 { + "warm" + } else if b > r && b >= g { + "cool" + } else { + "neutral" + } +} + +/// Per-family tally for `m2lightscan`'s summary: how many models in this content family carry +/// lights, how many `type==1` point lights they author in total, how many of those are dark +/// (`visibility_off`), and a handful of example paths. +#[derive(Default)] +struct FamilyStats { + models: u32, + point_lights: u32, + dark: u32, + examples: Vec, +} + +/// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 dynamic +/// LIGHT blocks — the population instrument for the mechanism (decision 0016 / wow-re +/// `system/models/scratch/m2-dynamic-lights.md`). Per model (only models with ≥1 light, printed +/// sorted by path): its `type==1` point-light count vs directional (`type==0`, ambient-feed, not +/// a discrete GL light) count, then per POINT light: bone, model-space position, `diffuse_color × +/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, +/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility +/// key) that keeps a light dark (§9.4). The closing summary is the real deliverable: totals, a +/// breakdown by top-level content family ([`family_of`]) — benilla only spawns these lights for +/// ADT-placed doodads and WMO props today, so this answers how much of the entity path +/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse +/// colour×intensity tally ([`hue_tag`]). +pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect(); + + // Rounded `(r, g, b) × 100` (int-keyed to stay orderable) — a cheap grouping key for the + // authored diffuse×intensity palette across point lights. + type ColorKey = (i32, i32, i32); + + let (mut scanned, mut hits, mut total_point, mut total_dark) = (0u32, 0u32, 0u32, 0u32); + let mut families: BTreeMap = BTreeMap::new(); + // key -> (hit count, one example model). + let mut color_tally: BTreeMap = BTreeMap::new(); + let mut hit_models: Vec<(String, Vec)> = Vec::new(); + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let lights = benilla_formats::parse_m2_lights(&bytes); + if lights.is_empty() { + continue; + } + hits += 1; + let point_count = lights.iter().filter(|l| l.is_point()).count() as u32; + let dark_count = lights + .iter() + .filter(|l| l.is_point() && l.visibility_off) + .count() as u32; + total_point += point_count; + total_dark += dark_count; + + let fam = families.entry(family_of(&name)).or_default(); + fam.models += 1; + fam.point_lights += point_count; + fam.dark += dark_count; + if fam.examples.len() < 8 { + fam.examples.push(name.clone()); + } + + for l in lights.iter().filter(|l| l.is_point()) { + let key = ( + (l.diffuse_color[0] * l.diffuse_intensity * 100.0).round() as i32, + (l.diffuse_color[1] * l.diffuse_intensity * 100.0).round() as i32, + (l.diffuse_color[2] * l.diffuse_intensity * 100.0).round() as i32, + ); + color_tally + .entry(key) + .or_insert_with(|| (0, name.clone())) + .0 += 1; + } + + hit_models.push((name, lights)); + } + + hit_models.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, lights) in &hit_models { + let point_count = lights.iter().filter(|l| l.is_point()).count(); + let dir_count = lights.len() - point_count; + println!("{name} {point_count} point, {dir_count} directional"); + for (i, l) in lights.iter().enumerate() { + if !l.is_point() { + continue; + } + let prod = [ + l.diffuse_color[0] * l.diffuse_intensity, + l.diffuse_color[1] * l.diffuse_intensity, + l.diffuse_color[2] * l.diffuse_intensity, + ]; + println!( + " L{i} bone {:>4} pos ({:>9.3}, {:>9.3}, {:>9.3}) diffuse ({:.3}, {:.3}, {:.3}) x {:.3} = ({:.3}, {:.3}, {:.3}) atten [{:.2}, {:.2}]{}", + l.bone, + l.position[0], l.position[1], l.position[2], + l.diffuse_color[0], l.diffuse_color[1], l.diffuse_color[2], + l.diffuse_intensity, + prod[0], prod[1], prod[2], + l.attenuation_start, l.attenuation_end, + if l.visibility_off { " OFF" } else { "" }, + ); + } + } + + println!(); + println!( + "=== summary === {scanned} models scanned, {hits} with light blocks, {total_point} point lights, {total_dark} dark (visibility_off) point lights" + ); + + println!(); + println!("=== by content family ==="); + for (fam, stats) in &families { + println!( + "{fam:<32} {:>4} models {:>4} point lights {:>3} dark e.g. {}", + stats.models, + stats.point_lights, + stats.dark, + stats.examples.join(" · ") + ); + } + + println!(); + println!("=== diffuse colour x intensity tally (point lights, rounded to 0.01) ==="); + let mut ranked: Vec<(&ColorKey, &(u32, String))> = color_tally.iter().collect(); + ranked.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); + for (key, (count, example)) in ranked.iter().take(TALLY_ROWS) { + let (r, g, b) = ( + key.0 as f32 / 100.0, + key.1 as f32 / 100.0, + key.2 as f32 / 100.0, + ); + let tag = hue_tag(r, g, b); + println!("{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {tag:<5} e.g. {example}"); + } + // Never let the top-20 read as "that's all of them". + if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) { + println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)"); + } + + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs new file mode 100644 index 00000000..896f2f00 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -0,0 +1,70 @@ +//! `spellvis`: dump a spell's visual chain — spell → SpellVisual stages → each kit's anim/sound/ +//! attach effects, plus the missile block — decision 0099's phase-2 instrument (columns per +//! decision 0107). + +use anyhow::Result; +use benilla_formats::Chain; + +/// Dump the spell's visual chain for `spell_id`. +pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { + let spells = benilla_formats::load_spell_catalog(chain)?; + let visuals = benilla_formats::load_spell_visual_catalog(chain)?; + let Some(spell) = spells.get(spell_id) else { + anyhow::bail!("spell {spell_id} not in Spell.dbc"); + }; + println!( + "spell {spell_id} \"{}\" visual={} speed={}", + spell.name, spell.visual, spell.speed + ); + let Some(stages) = visuals.stages(spell.visual) else { + println!(" (no SpellVisual row — a silent cast)"); + return Ok(()); + }; + for (label, kit_id) in [ + ("precast", stages.precast), + ("cast", stages.cast), + ("impact", stages.impact), + ("state", stages.state), + ("channel", stages.channel), + ] { + if kit_id == 0 { + println!(" {label:8} —"); + } else { + match visuals.kit(kit_id) { + Some(kit) => { + println!( + " {label:8} kit {kit_id:<5} anim={:<12} sound={}", + kit.anim_id.map_or("—".into(), |a| a.to_string()), + kit.sound.map_or("—".into(), |s| s.to_string()), + ); + // The kit's attach-point emitter slots (phase 3): tag + effect model. + for (tag, effect) in kit.effects() { + println!( + " attach {tag:#04x} effect {effect:<5} -> {}", + visuals.effect_path(effect).unwrap_or("(MISSING PATH)"), + ); + } + } + None => println!(" {label:8} kit {kit_id} (MISSING ROW)"), + } + } + } + // The missile block (phase 4): the projectile exists whenever Speed > 0; its model is + // field 7's SpellVisualEffectName (else the ammo/ErrorCube fallbacks) and it homes to + // field 9's dest-attach ordinal on a live target. + if spell.speed > 0.0 { + let tag = benilla_formats::MISSILE_ATTACH_TABLE + .get(stages.missile_attach as usize) + .copied(); + println!( + " missile effect {:<5} -> {} dest ordinal {} (attach {})", + stages.missile_model, + visuals + .effect_path(stages.missile_model) + .unwrap_or("(ammo/ErrorCube fallback)"), + stages.missile_attach, + tag.map_or("—".into(), |t| format!("{t:#04x}")), + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/mpqx.rs b/crates/benilla-formats/src/bin/mpqx.rs new file mode 100644 index 00000000..91a2224a --- /dev/null +++ b/crates/benilla-formats/src/bin/mpqx.rs @@ -0,0 +1,45 @@ +//! One-off extraction helper (session tool, not shipped): `mpqx `. +//! Reads through the SAME patch chain as the runtime ([`benilla_formats::Chain`], the full +//! `VANILLA_LOAD_ORDER`) — it used to hand-roll a partial archive list in `benilla-mpq` and silently +//! miss whole archives (`wmo.MPQ`: "not found" for every building/ship). A path the top archive +//! **delete-marks** is reported as DELETED (exit 2) — the client doesn't load it; look for a +//! `Blizzard_*` addon replacement (decision 0246). + +use std::path::Path; + +use benilla_formats::Chain; + +fn main() { + let mut args = std::env::args().skip(1); + let (data, vpath, out) = ( + args.next().expect("data dir"), + args.next().expect("virtual path"), + args.next().expect("out path"), + ); + let chain = Chain::open(Path::new(&data)).expect("open patch chain"); + match chain.read(&vpath) { + Ok(bytes) => { + std::fs::write(&out, &bytes).expect("write"); + let from = chain + .find_file_archive(&vpath) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + println!( + "{} -> {} ({} bytes, from {})", + vpath, + out, + bytes.len(), + from + ); + } + // Chain::read distinguishes the tombstone in its message; surface it with the old exit code. + Err(e) if e.to_string().contains("deleted from patch chain") => { + eprintln!("{e} — the client does not load this path (see decision 0246)"); + std::process::exit(2); + } + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } +} diff --git a/crates/benilla-formats/src/chain.rs b/crates/benilla-formats/src/chain.rs new file mode 100644 index 00000000..9bd13862 --- /dev/null +++ b/crates/benilla-formats/src/chain.rs @@ -0,0 +1,139 @@ +//! The vanilla patch chain — a priority-ordered set of MPQ archives, read through `benilla-mpq`. +//! +//! Replaces `wow-mpq`'s `PatchChain` *and* the old `ChainReader` (decision 0021). Those were two types +//! because `wow-mpq`'s `Archive::open` re-parsed the hash/block (and the useless `(attributes)`) tables +//! on every open, so `ChainReader` bolted a `Mutex>` handle-cache on top to avoid +//! re-paying that per read. `benilla_mpq::Archive` now caches its parsed tables in an `Arc` and reads +//! `&self` (a fresh OS handle per read, no seek-state sharing), so the cache is gone and one `Chain` +//! serves both the `&self` concurrent Bevy `AssetReader` path and the `&mut` streaming-loader path. +//! +//! Later archives override earlier ones for files sharing an internal path (so `patch-2.MPQ` wins); a +//! read resolves a name to the highest-priority archive that holds it. Base content archives carry no +//! `(listfile)`, so resolution is by name **hash**, which works without one. + +use std::collections::HashSet; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use benilla_mpq::Archive; + +use crate::VANILLA_LOAD_ORDER; + +/// One entry from a chain listing: an internal path and its uncompressed size. +pub struct ChainEntry { + pub name: String, + pub size: u64, +} + +/// A priority-ordered patch chain of MPQ archives (`Send + Sync`; reads are `&self` and lock-free). +pub struct Chain { + /// Ascending priority — later archives win. `resolve` scans back-to-front. + archives: Vec, +} + +impl Chain { + /// Open a chain from a vanilla `Data` directory (every archive in [`VANILLA_LOAD_ORDER`] that + /// exists, lowest priority first) or a single `.MPQ` file (just that archive). + pub fn open(path: &Path) -> Result { + let mut archives = Vec::new(); + if path.is_dir() { + for name in VANILLA_LOAD_ORDER { + let mpq = path.join(name); + if mpq.is_file() { + archives.push( + Archive::open(&mpq) + .with_context(|| format!("opening {}", mpq.display()))?, + ); + } + } + if archives.is_empty() { + bail!("no known vanilla MPQs found in {}", path.display()); + } + } else { + archives.push( + Archive::open(path).with_context(|| format!("opening MPQ {}", path.display()))?, + ); + } + Ok(Self { archives }) + } + + /// The highest-priority archive with an *entry* for `name` (readable file **or** delete-marker), + /// if any. Stops at the winning archive — including a tombstone, which correctly shadows any + /// lower-priority copy (decision 0246). Callers that want "readable" must check + /// [`Archive::is_delete_marker`]. + fn resolve(&self, name: &str) -> Option<&Archive> { + self.archives.iter().rev().find(|a| a.contains(name)) + } + + /// Whether the chain holds `name` as a **readable** file (accepts `/` or `\`; case-insensitive). + /// A path whose winning entry is a delete-marker is *not* present — the client deleted it (0246). + pub fn contains(&self, name: &str) -> bool { + self.resolve(name) + .is_some_and(|a| !a.is_delete_marker(name)) + } + + /// The path of the archive `name` resolves to (the winning override) — for debugging / extract. + pub fn find_file_archive(&self, name: &str) -> Option<&Path> { + self.resolve(name).map(|a| a.path()) + } + + /// Read a file by internal path (accepts `/` or `\`), from its winning archive. `&self`: safe to + /// call concurrently (the Bevy `AssetReader` does). + pub fn read(&self, name: &str) -> Result> { + let archive = self + .resolve(name) + .ok_or_else(|| anyhow!("file not in patch chain: {name}"))?; + // A tombstone shadows every lower copy: the path is deleted from the composite, so this is a + // clean "not found", not a fall-through to a stale base version (decision 0246). + if archive.is_delete_marker(name) { + bail!( + "file deleted from patch chain: {name} (tombstoned by {})", + archive.path().display() + ); + } + archive + .read_file(name) + .with_context(|| format!("reading {name} from {}", archive.path().display())) + } + + /// `&mut` alias of [`Chain::read`] — kept so the streaming-loader call sites that thread a + /// `&mut Chain` read exactly as they did against `wow-mpq`'s `PatchChain`. + pub fn read_file(&mut self, name: &str) -> Result> { + self.read(name) + } + + /// List the chain's named files with sizes. Dev/extract use only — files absent from every + /// listfile (most of `texture.MPQ`) are reachable by name but not enumerated. + /// + /// Unions the `(listfile)` of **every** archive that carries one: each archive's listfile names + /// only the files *it* holds, so resolving `(listfile)` like an ordinary overridden file (as this + /// used to) returns just the top patch archive's sliver — 92 names from `patch-2.MPQ` instead of + /// the chain's ~86k. Sizes still resolve per-name to the winning archive. + pub fn list(&self) -> Result> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for archive in &self.archives { + let Ok(listfile) = archive.read_file("(listfile)") else { + continue; + }; + for raw in String::from_utf8_lossy(&listfile).split([';', '\r', '\n']) { + let name = raw.trim(); + // Dedupe across archives the way MPQ hashing compares names: case-insensitive, `/`≡`\`. + if name.is_empty() || !seen.insert(name.replace('/', "\\").to_ascii_lowercase()) { + continue; + } + if let Some(a) = self.resolve(name) { + // A tombstoned path isn't a file in the composite — don't list it (0246). + if a.is_delete_marker(name) { + continue; + } + out.push(ChainEntry { + name: name.to_string(), + size: a.file_size(name).unwrap_or(0) as u64, + }); + } + } + } + Ok(out) + } +} diff --git a/crates/benilla-formats/src/characters/customization.rs b/crates/benilla-formats/src/characters/customization.rs new file mode 100644 index 00000000..3dcb6cdd --- /dev/null +++ b/crates/benilla-formats/src/characters/customization.rs @@ -0,0 +1,742 @@ +//! Character-creation source data (decision 0423): the per-race body displayIds (ChrRaces), the +//! race/class combos (CharBaseInfo), and the five appearance-dial *ranges* per (race, sex) derived +//! from CharSections + CharHairGeosets + CharacterFacialHairStyles. +//! +//! **The ranges are data-derived, never hardcoded** (the RF-0071/73/74 grid law). Each dial cycles +//! index `0..count-1`; the counts come from: +//! - **skin color** = distinct `ColorIndex` of CharSections `SECTION_SKIN` (variation 0) for (race, sex); +//! - **face** = distinct `VariationIndex` of `SECTION_FACE`; +//! - **hair style** = distinct `VariationID` of **CharHairGeosets** for (race, sex), `max(1, …)` (the +//! client's `0x478540` count fetcher — bald is style 0); +//! - **hair color** = distinct `ColorIndex` of `SECTION_HAIR`; +//! - **facial hair** = distinct `VariationID` of **CharacterFacialHairStyles** for (race, sex). +//! +//! By construction every `(skin, face, hairStyle, hairColor, facialHair)` tuple in the product of a +//! (race, sex)'s ranges passes vmangos's `Player::ValidateAppearance` — the server checks, in order, +//! that CharSections rows exist for `SKIN(0, skin)`, `FACE(face, skin)` (face is keyed by *skinColor*), +//! `HAIR(hairStyle, hairColor)`, and — unless the (race, sex) is facial-hair-excluded (Tauren, or a +//! female that isn't Night Elf / Undead) — `FACIAL_HAIR(facialHair, hairColor)`, plus a +//! CharacterFacialHairStyles row for `facialHair`. The real 5875 grids are *complete* over each +//! coupled pair, so the independent per-dial counts never produce an invalid tuple — asserted +//! exhaustively against the transcribed predicate in the real-data test. + +use std::collections::{HashMap, HashSet}; + +use anyhow::{bail, Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const CHR_RACES: &str = "DBFilesClient\\ChrRaces.dbc"; +const CHAR_BASE_INFO: &str = "DBFilesClient\\CharBaseInfo.dbc"; +const CHAR_START_OUTFIT: &str = "DBFilesClient\\CharStartOutfit.dbc"; +const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; +const CHAR_FACIAL_HAIR_STYLES: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; +const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; + +// CharSections `SectionType` values the range derivation reads (wow-re RF-0074; the same constants +// `sections.rs` uses — kept local so the two concerns don't couple). `SECTION_FACIAL_HAIR` (2) isn't +// here: the facial-hair *count* comes from CharacterFacialHairStyles, not CharSections (the test's +// ValidateAppearance transcription defines it there, where it's used). +const SECTION_SKIN: u8 = 0; +const SECTION_FACE: u8 = 1; +const SECTION_HAIR: u8 = 3; + +/// A CharSections availability key: `(race, sex, sectionType, variation, color)`. +type SectionKey = (u8, u8, u8, u8, u8); + +/// The 8 playable races (ChrRaces ids 1–8); higher rows (Goblin = 9) aren't character-creatable. +const PLAYABLE_RACES: [u8; 8] = [1, 2, 3, 4, 5, 6, 7, 8]; + +/// The 8 playable races' ChrRaces client fileStrings (col 15), a **frozen fact** of the build — the +/// load-time misparse guard for the string-column positions in [`load_races`] (if col 15 drifted, +/// these wouldn't match), and the GlueStrings key stems (`RACE_INFO_SCOURGE`, not `…_UNDEAD`). +const KNOWN_FILES: [(u8, &str); 8] = [ + (1, "Human"), + (2, "Orc"), + (3, "Dwarf"), + (4, "NightElf"), + (5, "Scourge"), + (6, "Tauren"), + (7, "Gnome"), + (8, "Troll"), +]; + +/// The **raw** 1.12 `CharBaseInfo` content, race → classes (a frozen fact of the file; class ids: +/// Warrior 1, Paladin 2, Hunter 3, Rogue 4, Priest 5, Shaman 7, Mage 8, Warlock 9, Druid 11). Used +/// only as the load-time misparse guard (proves the 2-byte parse landed) — note it is NOT the +/// playable set: the file carries one dead pair, Dwarf-Mage, which the real client never offers +/// (see [`UNUSED_COMBOS`]). +const KNOWN_COMBOS: [(u8, &[u8]); 8] = [ + (1, &[1, 2, 4, 5, 8, 9]), // Human + (2, &[1, 3, 4, 7, 9]), // Orc + (3, &[1, 2, 3, 4, 5, 8]), // Dwarf — 8 (Mage) is the dead row, stripped after the guard + (4, &[1, 3, 4, 5, 11]), // Night Elf + (5, &[1, 4, 5, 8, 9]), // Undead + (6, &[1, 3, 7, 11]), // Tauren + (7, &[1, 4, 8, 9]), // Gnome + (8, &[1, 3, 4, 5, 7, 8]), // Troll +]; + +/// The `CharBaseInfo` pairs the real client never offers on the create screen — leftovers in the +/// data, not creatable content. Dwarf-Mage (3, 8) sits fully populated in both CharBaseInfo *and* +/// CharStartOutfit, yet the real client hardcodes the skip: its class-list builder (`0x4706b0`) +/// iterates CharBaseInfo and drops rows matching the literal `race == 3 && class == 8` — two x86 +/// immediates, the only such pair binary-wide, byte-confirmed by wow-5875-re §5 (decisions +/// 0549/0550). Stripped here right after the raw-parse guard — the same mechanism, one layer down — +/// so `allows`/`classes_for_race` answer what the real client offers. +const UNUSED_COMBOS: [(u8, u8); 1] = [(3, 8)]; + +/// One renderable item of a class's level-1 starting outfit (CharStartOutfit.dbc, decision 0527): +/// the item's **ItemDisplayInfo** id — the same kind of id the char-enum equipment carries, so it +/// feeds the equipment pipeline directly with no Item.dbc/template hop — and its InventoryType (the +/// worn slot). Non-worn entries (bags, food, hearthstone — InventoryType 0) are dropped at load. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StartOutfitItem { + pub display_id: u32, + pub inv_type: u8, +} + +/// The five appearance-dial counts for one (race, sex); each dial cycles index `0..count-1`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DialRanges { + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, +} + +/// Character-creation source data: race body displayIds, race/class combos, and per-(race, sex) +/// appearance-dial ranges. Loaded once from the DBCs ([`Self::load`]). +pub struct CharCreateCatalog { + /// race → (male displayId, female displayId), from ChrRaces cols 4/5. + displays: HashMap, + /// race → the ChrRaces client fileString (col 15: `"Human"`, `"Scourge"`, …) — the stem the glue + /// screens key GlueStrings entries on (`RACE_INFO_`, `ABILITY_INFO_`). + files: HashMap, + /// race → (facial-hair customization token per sex [male, female] — cols 26/27 — and the hair + /// customization token — col 28). The glue tokens behind the per-race dial labels + /// (`HAIR__STYLE`, `FACIAL_HAIR_`); a facial token of `"NONE"` hides that dial. + custom_tokens: HashMap, + /// The (race, class) combos CharBaseInfo permits. + combos: HashSet<(u8, u8)>, + /// (race, sex) → the five dial counts. + ranges: HashMap<(u8, u8), DialRanges>, + /// (race, class, sex) → the level-1 starting-outfit items the create preview dresses in + /// (CharStartOutfit, decision 0527) — worn slots only, DisplayId + InventoryType. + start_outfits: HashMap<(u8, u8, u8), Vec>, +} + +impl CharCreateCatalog { + /// The body model displayId for a (race, sex) — resolved through the ordinary CreatureDisplayInfo + /// chain, the same as any streamed unit (decision 0041). `None` for a non-playable race. + pub fn body_display(&self, race: u8, sex: u8) -> Option { + self.displays + .get(&race) + .map(|&(m, f)| if sex == 0 { m } else { f }) + } + + /// Whether a race may be created as a class (CharBaseInfo). + pub fn allows(&self, race: u8, class: u8) -> bool { + self.combos.contains(&(race, class)) + } + + /// The ChrRaces client fileString for a race (`"Human"`, `"Scourge"`, …) — the GlueStrings key + /// stem. `None` for a non-playable race. + pub fn race_file(&self, race: u8) -> Option<&str> { + self.files.get(&race).map(String::as_str) + } + + /// The hair-customization glue token for a race (`"NORMAL"`, `"HORNS"`) — the dial-label key + /// (`HAIR__STYLE` / `HAIR__COLOR`). + pub fn hair_customization(&self, race: u8) -> Option<&str> { + self.custom_tokens.get(&race).map(|(_, h)| h.as_str()) + } + + /// The facial-hair-customization glue token for a (race, sex) (`"NORMAL"`, `"MARKINGS"`, …) — + /// the dial-label key (`FACIAL_HAIR_`); `"NONE"` means the dial is hidden (the ref's + /// `GetFacialHairCustomization() == "NONE"` branch). + pub fn facial_hair_customization(&self, race: u8, sex: u8) -> Option<&str> { + self.custom_tokens + .get(&race) + .map(|(f, _)| f[(sex as usize).min(1)].as_str()) + } + + /// The classes a race may be created as, ascending by class id. The real client's grid order is + /// CharBaseInfo *row* order (its builder `0x4706b0` walks the table, decision 0550) — which in + /// the 5875 file is ascending class id for all 8 races (verified byte fact), so the sort here is + /// order-equivalent on the real data. + pub fn classes_for_race(&self, race: u8) -> Vec { + let mut cs: Vec = self + .combos + .iter() + .filter(|&&(r, _)| r == race) + .map(|&(_, c)| c) + .collect(); + cs.sort_unstable(); + cs + } + + /// The five appearance-dial counts for a (race, sex). `None` for a non-playable race. + pub fn ranges(&self, race: u8, sex: u8) -> Option { + self.ranges.get(&(race, sex)).copied() + } + + /// The renderable starting-outfit items for a (race, class, sex) — the level-1 gear the create + /// screen dresses the preview in (decision 0527). Empty for a combo with no CharStartOutfit row. + pub fn start_outfit(&self, race: u8, class: u8, sex: u8) -> &[StartOutfitItem] { + self.start_outfits + .get(&(race, class, sex)) + .map_or(&[], Vec::as_slice) + } + + /// Load the character-creation catalog from the patch chain (ChrRaces + CharBaseInfo + + /// CharSections + CharHairGeosets + CharacterFacialHairStyles). Two load-time self-checks fail + /// loudly on a misparse: the parsed combos must equal the frozen 1.12 table (guards the 2-byte + /// CharBaseInfo layout), and every playable (race, sex) must have a body display + all five + /// ranges nonzero. + pub fn load(chain: &mut Chain) -> Result { + let (displays, files, custom_tokens) = load_races(chain)?; + let combos = load_combos(chain)?; + let start_outfits = load_start_outfits(chain)?; + let avail = load_available_sections(chain)?; + let hair_geo = load_hair_geosets(chain)?; + let facial = load_facial_hair_styles(chain)?; + + let mut ranges = HashMap::new(); + for race in PLAYABLE_RACES { + for sex in [0u8, 1] { + ranges.insert( + (race, sex), + derive_ranges(race, sex, &avail, &hair_geo, &facial), + ); + } + } + + let mut catalog = Self { + displays, + files, + custom_tokens, + combos, + ranges, + start_outfits, + }; + catalog.self_check()?; + for combo in UNUSED_COMBOS { + catalog.combos.remove(&combo); + } + Ok(catalog) + } + + /// Load-time misparse guards (see [`Self::load`]). + fn self_check(&self) -> Result<()> { + for (race, classes) in KNOWN_COMBOS { + let got = self.classes_for_race(race); + if got != classes { + bail!( + "CharBaseInfo misparse: race {race} classes {got:?} != known {classes:?} \ + (check the 2-byte race/class layout)" + ); + } + } + for (race, file) in KNOWN_FILES { + if self.race_file(race) != Some(file) { + bail!( + "ChrRaces misparse: race {race} fileString {:?} != known {file:?} \ + (check the string-column positions in load_races)", + self.race_file(race) + ); + } + } + // CharStartOutfit byte-layout guard (like the CharBaseInfo one): Human(1) Warrior(1) male(0) + // wears the recruit set — shirt disp 9891 (inv 4/BODY), pants 9892 (7/LEGS), boots 10141 + // (8/FEET), plus the worn shortsword 1542 (21/MAINHAND) and small shield 18730 (14/SHIELD). + // If the packed race/class/gender head or the DisplayId/InventoryType array offsets drifted, + // these (display, inv) pairs wouldn't line up. Bags/food (InventoryType 0) are dropped, so + // the count is exactly these five worn items. + let hw = self.start_outfit(1, 1, 0); + for &(disp, inv) in &[ + (9891u32, 4u8), + (9892, 7), + (10141, 8), + (1542, 21), + (18730, 14), + ] { + if !hw + .iter() + .any(|it| it.display_id == disp && it.inv_type == inv) + { + bail!( + "CharStartOutfit misparse: Human Warrior male missing display {disp} inv {inv} \ + (got {hw:?}) — check the 41-field / 152-byte packed layout" + ); + } + } + for race in PLAYABLE_RACES { + for sex in [0u8, 1] { + if self.body_display(race, sex).unwrap_or(0) == 0 { + bail!("ChrRaces: race {race} sex {sex} has no body displayId"); + } + if self.facial_hair_customization(race, sex).is_none() + || self.hair_customization(race).is_none() + { + bail!("ChrRaces: race {race} has no customization tokens"); + } + let r = self + .ranges(race, sex) + .with_context(|| format!("no ranges for race {race} sex {sex}"))?; + if r.skin == 0 + || r.face == 0 + || r.hair_style == 0 + || r.hair_color == 0 + || r.facial_hair == 0 + { + bail!("customization ranges: race {race} sex {sex} has a zero dial ({r:?})"); + } + } + } + Ok(()) + } +} + +/// ChrRaces → per playable race: the (male, female) displayIds, the client fileString, and the glue +/// customization tokens. 29 fields in build 5875 (record size 116); we read cols 0 (RaceID), +/// 4 (MaleDisplayId), 5 (FemaleDisplayId), 15 (ClientFileString), 26/27 (FacialHairCustomization +/// male/female) and 28 (HairCustomization) — the rest (name strings + spell/sound ids) are declared +/// `UInt32` (a string column is a 4-byte offset, read and ignored). The string-column positions are +/// guarded by [`CharCreateCatalog::self_check`]'s frozen fileString table. +#[allow(clippy::type_complexity)] // one pass over one DBC → the catalog's three race-keyed maps +fn load_races( + chain: &mut Chain, +) -> Result<( + HashMap, + HashMap, + HashMap, +)> { + let bytes = chain + .read_file(CHR_RACES) + .with_context(|| format!("reading {CHR_RACES}"))?; + let mut schema = Schema::new("ChrRaces"); + for i in 0..29 { + let ty = match i { + 15 | 26 | 27 | 28 => FieldType::String, + _ => FieldType::UInt32, + }; + schema.add_field(SchemaField::new(format!("f{i}"), ty)); + } + let rs = parse(&bytes, schema, "ChrRaces")?; + let mut displays = HashMap::new(); + let mut files = HashMap::new(); + let mut tokens = HashMap::new(); + for r in rs.records() { + let Some(race) = u32_at(r, 0) else { continue }; + let race = race as u8; + if !PLAYABLE_RACES.contains(&race) { + continue; + } + if let (Some(male), Some(female)) = (u32_at(r, 4), u32_at(r, 5)) { + displays.insert(race, (male, female)); + } + if let Some(file) = str_at(&rs, r, 15) { + files.insert(race, file); + } + if let (Some(fm), Some(ff), Some(hair)) = + (str_at(&rs, r, 26), str_at(&rs, r, 27), str_at(&rs, r, 28)) + { + tokens.insert(race, ([fm, ff], hair)); + } + } + Ok((displays, files, tokens)) +} + +/// CharBaseInfo → the (race, class) combos. A **special** DBC: 2 columns of **one byte each** +/// (record size 2, not 8), so the generic 4-byte-field reader can't touch it — parsed by hand off the +/// raw record block. Byte 0 = RaceID, byte 1 = ClassID (verified against the 5875 file + the known +/// combo table). +fn load_combos(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file(CHAR_BASE_INFO) + .with_context(|| format!("reading {CHAR_BASE_INFO}"))?; + if bytes.len() < 20 || &bytes[0..4] != b"WDBC" { + bail!("CharBaseInfo: not a WDBC file"); + } + let record_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + let field_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let record_size = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + if field_count != 2 || record_size != 2 { + bail!("CharBaseInfo: unexpected layout (fields {field_count}, record size {record_size}; expected 2/2)"); + } + let data = &bytes[20..]; + let mut combos = HashSet::new(); + for i in 0..record_count { + let off = i * record_size; + if off + 2 > data.len() { + bail!("CharBaseInfo: record {i} runs past the file"); + } + combos.insert((data[off], data[off + 1])); + } + Ok(combos) +} + +/// CharStartOutfit → (race, class, sex) → the worn starting items. A **special** DBC (like +/// CharBaseInfo): 41 columns / 152 bytes with a byte-packed race/class/gender/unused head, so the +/// generic 4-byte-field reader can't touch it — parsed by hand off the raw record block. Layout, +/// byte-verified against the 5875 file: `ID` u32 @0; `Race`/`Class`/`Gender`/unused u8 @4/5/6/7; +/// `ItemId[12]` i32 @8 (dropped — we render by display); **`DisplayId[12]` i32 @56**; +/// **`InventoryType[12]` i32 @104**. Each row keeps the (DisplayId, InventoryType) pair of every +/// **worn** slot (both ≥ 1 — this drops the empty `-1` tail and the InventoryType-0 bag/food/hearth +/// entries, which never render). DisplayId is already an ItemDisplayInfo id (the char-enum +/// equipment convention), so it feeds the equipment pipeline with no template hop. +fn load_start_outfits(chain: &mut Chain) -> Result>> { + let bytes = chain + .read_file(CHAR_START_OUTFIT) + .with_context(|| format!("reading {CHAR_START_OUTFIT}"))?; + if bytes.len() < 20 || &bytes[0..4] != b"WDBC" { + bail!("CharStartOutfit: not a WDBC file"); + } + let record_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize; + let field_count = u32::from_le_bytes(bytes[8..12].try_into().unwrap()); + let record_size = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + if field_count != 41 || record_size != 152 { + bail!( + "CharStartOutfit: unexpected layout (fields {field_count}, record size {record_size}; \ + expected 41/152)" + ); + } + let data = &bytes[20..]; + let i32_at = |rec: &[u8], off: usize| i32::from_le_bytes(rec[off..off + 4].try_into().unwrap()); + let mut map: HashMap<(u8, u8, u8), Vec> = HashMap::new(); + for i in 0..record_count { + let off = i * record_size; + if off + record_size > data.len() { + bail!("CharStartOutfit: record {i} runs past the file"); + } + let rec = &data[off..off + record_size]; + let (race, class, sex) = (rec[4], rec[5], rec[6]); + let mut items = Vec::new(); + for slot in 0..12 { + let display = i32_at(rec, 56 + slot * 4); + let inv = i32_at(rec, 104 + slot * 4); + if display >= 1 && inv >= 1 { + items.push(StartOutfitItem { + display_id: display as u32, + inv_type: inv as u8, + }); + } + } + map.insert((race, class, sex), items); + } + Ok(map) +} + +/// CharSections → the set of *available* `(race, sex, sectionType, variation, color)` keys — the rows +/// a player may select (`Flags & 0x1 == 0`; the `0x1`-flagged rows are `SECTION_FLAG_UNAVAILABLE`, +/// the late `…_100` extras the compositor already skips). This is exactly the domain vmangos's +/// `GetCharSectionEntry` searches, so range derivation + validation read the same predicate. +fn load_available_sections(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file(CHAR_SECTIONS) + .with_context(|| format!("reading {CHAR_SECTIONS}"))?; + // 10 fields (ID, Race, Sex, SectionType, Variation, Color, Tex0-2, Flags); we read the ints. + let rs = parse(&bytes, all_u32_schema("CharSections", 10), "CharSections")?; + let mut avail = HashSet::new(); + for r in rs.records() { + if let (Some(race), Some(sex), Some(ty), Some(var), Some(color), Some(flags)) = ( + u32_at(r, 1), + u32_at(r, 2), + u32_at(r, 3), + u32_at(r, 4), + u32_at(r, 5), + u32_at(r, 9), + ) { + if flags & 0x1 == 0 { + avail.insert((race as u8, sex as u8, ty as u8, var as u8, color as u8)); + } + } + } + Ok(avail) +} + +/// CharHairGeosets → (race, sex) → the set of hair-style `VariationID`s. 6 fields (ID, Race, Sex, +/// Variation, GeosetID, unused); we key on cols 1/2/3 (wow-re RF-0073). +fn load_hair_geosets(chain: &mut Chain) -> Result>> { + let bytes = chain + .read_file(CHAR_HAIR_GEOSETS) + .with_context(|| format!("reading {CHAR_HAIR_GEOSETS}"))?; + let rs = parse( + &bytes, + all_u32_schema("CharHairGeosets", 6), + "CharHairGeosets", + )?; + let mut map: HashMap<(u8, u8), HashSet> = HashMap::new(); + for r in rs.records() { + if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3)) { + map.entry((race as u8, sex as u8)) + .or_default() + .insert(var as u8); + } + } + Ok(map) +} + +/// CharacterFacialHairStyles → (race, sex) → the set of facial-hair `VariationID`s. 9 fields; the +/// keys are cols 0/1/2 (Race, Sex, Variation — wow-re RF-0073, note col 0 is Race here, not an ID). +fn load_facial_hair_styles(chain: &mut Chain) -> Result>> { + let bytes = chain + .read_file(CHAR_FACIAL_HAIR_STYLES) + .with_context(|| format!("reading {CHAR_FACIAL_HAIR_STYLES}"))?; + let rs = parse( + &bytes, + all_u32_schema("CharacterFacialHairStyles", 9), + "CharacterFacialHairStyles", + )?; + let mut map: HashMap<(u8, u8), HashSet> = HashMap::new(); + for r in rs.records() { + if let (Some(race), Some(sex), Some(var)) = (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) { + map.entry((race as u8, sex as u8)) + .or_default() + .insert(var as u8); + } + } + Ok(map) +} + +/// Count distinct values on one axis of a (race, sex, sectionType) group in `avail`. +fn section_axis_count( + avail: &HashSet, + race: u8, + sex: u8, + ty: u8, + variation_axis: bool, +) -> u8 { + avail + .iter() + .filter(|&&(r, s, t, _, _)| r == race && s == sex && t == ty) + .map(|&(_, _, _, v, c)| if variation_axis { v } else { c }) + .collect::>() + .len() as u8 +} + +/// Derive the five dial counts for a (race, sex) from the section domain + the two geoset tables. +fn derive_ranges( + race: u8, + sex: u8, + avail: &HashSet, + hair_geo: &HashMap<(u8, u8), HashSet>, + facial: &HashMap<(u8, u8), HashSet>, +) -> DialRanges { + DialRanges { + skin: section_axis_count(avail, race, sex, SECTION_SKIN, false), + face: section_axis_count(avail, race, sex, SECTION_FACE, true), + // The hair-style count is the client's CharHairGeosets fetcher (`max(1, …)` — a race/sex with + // no rows still has the bald style), NOT the CharSections HAIR variation count (Orc female has + // one extra HAIR variation with no geoset; offering it would be a dead style). + hair_style: hair_geo + .get(&(race, sex)) + .map_or(1, |v| v.len().max(1) as u8), + hair_color: section_axis_count(avail, race, sex, SECTION_HAIR, false), + facial_hair: facial.get(&(race, sex)).map_or(0, |v| v.len() as u8), + } +} + +/// A schema of `n` `UInt32` fields named `f0..fn` — for the DBCs we read purely by index (the field +/// *names* are irrelevant; string columns are 4-byte offsets read as ints and ignored). The parser +/// requires the count to match the file header, which is the alignment guard. +fn all_u32_schema(name: &str, n: usize) -> Schema { + let mut s = Schema::new(name); + for i in 0..n { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + /// CharSections `SECTION_TYPE_FACIAL_HAIR` — used only by the predicate transcription (the runtime + /// facial-hair count comes from CharacterFacialHairStyles, so the lib never references it). + const SECTION_FACIAL_HAIR: u8 = 2; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// vmangos's `Player::ValidateAppearance`, transcribed verbatim (`Player.cpp:326`): the exact + /// predicate the server runs on `CMSG_CHAR_CREATE`. `GetCharSectionEntry` is an available-row + /// lookup (`avail` already excludes the `0x1`-flagged rows), and face is keyed by *skinColor*. + #[allow(clippy::too_many_arguments)] // mirrors vmangos's 7-param signature + the two data tables + fn validate_appearance( + avail: &HashSet, + facial: &HashMap<(u8, u8), HashSet>, + race: u8, + sex: u8, + skin: u8, + face: u8, + hair: u8, + hair_color: u8, + facial_hair: u8, + ) -> bool { + let has = |ty, var, color| avail.contains(&(race, sex, ty, var, color)); + if !has(SECTION_SKIN, 0, skin) { + return false; + } + if !has(SECTION_FACE, face, skin) { + return false; + } + if !has(SECTION_HAIR, hair, hair_color) { + return false; + } + // Tauren, and any female that isn't Night Elf (4) or Undead (5), have no FACIAL_HAIR sections. + let excluded = race == 6 || (sex == 1 && race != 4 && race != 5); + if !excluded && !has(SECTION_FACIAL_HAIR, facial_hair, hair_color) { + return false; + } + facial + .get(&(race, sex)) + .is_some_and(|vs| vs.contains(&facial_hair)) + } + + /// The whole Phase-2 contract on the **real** 5875 DBCs: the 16 body displayIds resolve to a + /// character body model; the parsed combos equal the frozen 1.12 table; the derived ranges are + /// dense `0..n-1` on every dial; and — the load-bearing guarantee — every tuple in the product of + /// a (race, sex)'s ranges passes the transcribed `ValidateAppearance`. Skips without client data. + #[test] + fn char_create_catalog_matches_the_5875_dbcs() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = CharCreateCatalog::load(&mut chain).expect("load char-create catalog"); + let creatures = crate::load_creature_catalog(&mut chain).expect("load creatures"); + + // 1 · The 16 body displayIds resolve through the CreatureDisplayInfo chain to a body model. + for race in PLAYABLE_RACES { + for sex in [0u8, 1] { + let display = cat.body_display(race, sex).expect("has display"); + let model = creatures.model(display).unwrap_or_else(|| { + panic!("display {display} (race {race} sex {sex}) resolves") + }); + assert!( + model + .model_path + .to_ascii_lowercase() + .starts_with("character\\"), + "race {race} sex {sex} display {display} → {:?} (expected a Character body)", + model.model_path + ); + } + } + + // 2 · The catalog answers the *playable* sets: the frozen raw table (guarded at load) + // minus the dead pairs the real client never offers (UNUSED_COMBOS — Dwarf-Mage). + for (race, classes) in KNOWN_COMBOS { + let playable: Vec = classes + .iter() + .copied() + .filter(|&c| !UNUSED_COMBOS.contains(&(race, c))) + .collect(); + assert_eq!( + cat.classes_for_race(race), + playable, + "combos for race {race}" + ); + } + assert_eq!( + cat.classes_for_race(3), + vec![1, 2, 3, 4, 5], + "Dwarf offers exactly Warrior/Paladin/Hunter/Rogue/Priest — no Mage (the dead \ + CharBaseInfo row must not reach the create screen)" + ); + + // 2b · The glue customization tokens land in the right columns (spot-checks pin both the + // column positions and the male/female order of cols 26/27 against the dumped 5875 rows: + // Tauren cycle horns, males normal facial hair, Human females piercings, Night Elf + // females markings, trolls tusks both sexes). Note: no 5875 row is "NONE" — every + // (race, sex) has a facial dial in this build. + assert_eq!(cat.hair_customization(6), Some("HORNS"), "Tauren hair tok"); + assert_eq!(cat.hair_customization(1), Some("NORMAL"), "Human hair tok"); + assert_eq!( + cat.facial_hair_customization(1, 0), + Some("NORMAL"), + "Human male facial tok" + ); + assert_eq!( + cat.facial_hair_customization(1, 1), + Some("PIERCINGS"), + "Human female facial tok" + ); + assert_eq!( + cat.facial_hair_customization(4, 1), + Some("MARKINGS"), + "Night Elf female facial tok" + ); + assert_eq!( + cat.facial_hair_customization(8, 0), + Some("TUSKS"), + "Troll male facial tok" + ); + + // 2c · CharStartOutfit (decision 0527): every creatable (race, class, sex) combo dresses + // in ≥1 worn item, and the InventoryType semantics land — the Human Warrior recruit set + // (guarded at load too) and the Human Mage's robe (InventoryType 20, chest column). + // (Creatable = the playable sets; the dead Dwarf-Mage pair also has a populated outfit + // row, but nothing can ever wear it.) + for (race, classes) in KNOWN_COMBOS { + for &class in classes + .iter() + .filter(|&&c| !UNUSED_COMBOS.contains(&(race, c))) + { + for sex in [0u8, 1] { + let out = cat.start_outfit(race, class, sex); + assert!( + !out.is_empty(), + "race {race} class {class} sex {sex} has no start outfit" + ); + assert!( + out.iter().all(|it| it.display_id >= 1 && it.inv_type >= 1), + "race {race} class {class} sex {sex} outfit has a 0/empty entry: {out:?}" + ); + } + } + } + let mage = cat.start_outfit(1, 8, 0); + assert!( + mage.iter() + .any(|it| it.inv_type == 20 && it.display_id == 12647), + "Human Mage male should wear a robe (inv 20, disp 12647): {mage:?}" + ); + + // 3 + 4 · Ranges dense; every producible tuple passes ValidateAppearance. + let avail = load_available_sections(&mut chain).expect("sections"); + let facial = load_facial_hair_styles(&mut chain).expect("facial hair"); + for race in PLAYABLE_RACES { + for sex in [0u8, 1] { + let r = cat.ranges(race, sex).expect("ranges"); + for sk in 0..r.skin { + for fa in 0..r.face { + for hs in 0..r.hair_style { + for hc in 0..r.hair_color { + for fh in 0..r.facial_hair { + assert!( + validate_appearance( + &avail, &facial, race, sex, sk, fa, hs, hc, fh + ), + "race {race} sex {sex} tuple \ + skin={sk} face={fa} hair={hs} hairColor={hc} facial={fh} \ + fails ValidateAppearance" + ); + } + } + } + } + } + } + } + } +} diff --git a/crates/benilla-formats/src/characters/geosets.rs b/crates/benilla-formats/src/characters/geosets.rs new file mode 100644 index 00000000..5e466d5f --- /dev/null +++ b/crates/benilla-formats/src/characters/geosets.rs @@ -0,0 +1,423 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +const CHAR_HAIR_GEOSETS: &str = "DBFilesClient\\CharHairGeosets.dbc"; +const CHAR_FACIAL_HAIR: &str = "DBFilesClient\\CharacterFacialHairStyles.dbc"; +const HELMET_GEOSET_VIS: &str = "DBFilesClient\\HelmetGeosetVisData.dbc"; + +/// The default 16 region-base geosets (`cc+0x144`, RF-0038) — increments of 100 (group 7's base is the +/// outlier 702). Entries 0–3 (hair + the 3 facial-hair groups) are overwritten by the customization; +/// 4–15 (the equipment-group bases: glove/boot/sleeve/…/cloak) stay as these bare-skin defaults. +const REGION_BASES: [u16; 16] = [ + 1, 101, 201, 301, 401, 501, 601, 702, 801, 901, 1001, 1101, 1201, 1301, 1401, 1501, +]; + +/// The customization → geoset tables, resolving an appearance to the set of visible geosets. +pub struct CharacterGeosets { + /// (race, sex, hairStyle) → hair `GeosetID` (group 0). + hair: HashMap<(u8, u8, u8), u32>, + /// (race, sex, facialHair) → the 3 facial-hair geoset variations (DBC fields gA/gB/gC, in file + /// order — gA→group 1 (+100), gB→group 3 (+300), gC→group 2 (+200), per RF-0073's `0x478660`). + facial: HashMap<(u8, u8, u8), [u32; 3]>, + /// HelmetGeosetVisData row id → its 5 **race** bitmasks (VERIFIED wow-re RF-0083, consumer + /// `0x4799a0`): a set bit `1 << race` in column *c* forces one region-base slot back to its + /// group base — hiding the styled hair/facial geoset (and the ears) under a worn helm. + helmet_vis: HashMap, +} + +/// The worn geoset selectors the RF-0038 equipment branches read (decision 0074): per bodyslot 2–9 +/// (shirt, chest, belt, pants, boots, wrist, gloves, tabard) the item's `geosetGroup[0..2]` (`None` +/// = the slot is empty), plus the cloak's `geosetGroup[0]`, plus the worn helm's +/// `HelmetGeosetVisData` row pair (`[male, female]` — ItemDisplayInfo cols 12/13). Default = naked. +#[derive(Default, Clone, Copy)] +pub struct EquipGeosets { + pub bodyslots: [Option<[u32; 3]>; 8], + pub cloak: Option, + pub helm_vis: Option<[u32; 2]>, +} + +impl CharacterGeosets { + /// The geoset IDs a character of this appearance renders — RF-0038's opening block (the naked + /// set) plus the equipment branches B1–B8 (decision 0074 — implemented from the transcription's + /// *arithmetic*; its prose labels are known-mislabeled). A body submesh whose `skinSectionId` is + /// in this set is drawn; all others hidden. A branch's gate is its `geosetGroup` field being + /// non-zero ("section present"); the recorded disables are the ranges shown — the boot/kneepad + /// enables deliberately leave their naked defaults on, as the client does. + pub fn visible_geosets( + &self, + race: u8, + sex: u8, + hair_style: u8, + facial_hair: u8, + equip: &EquipGeosets, + ) -> Vec { + let mut set = REGION_BASES.to_vec(); + set.push(0); // geoset 0 (the body), enabled unconditionally + // Entry 0 (group 0 = hair): the chosen hairstyle's geoset, clamped ≥1 (`0x478540` + // returns `max(1, geosetId)`, so a bald style still resolves to geoset 1, the scalp). + if let Some(&g) = self.hair.get(&(race, sex, hair_style)) { + set[0] = g.max(1) as u16; + } + // Entries 1–3 (groups 1/2/3 = facial hair): DBC variation + the group base (`0x478660` adds + // 100/300/200 to fields gA/gB/gC respectively). Absent row ⇒ the default bases 101/201/301 stand. + if let Some(&[a, b, c]) = self.facial.get(&(race, sex, facial_hair)) { + set[1] = (a + 100) as u16; // gA → group 1 + set[3] = (b + 300) as u16; // gB → group 3 + set[2] = (c + 200) as u16; // gC → group 2 + } + // A worn helm's vis row (VERIFIED wow-re RF-0083, `0x4799a0`): each of the 5 columns is a + // **race** bitmask; a set `1 << race` bit forces one region-base slot back to its group + // base, dropping the styled geoset the customization just selected — hair to the bare scalp + // (1), the facial groups to their bases, and the ears to **701** (over the 702 default no + // customization touches). Runs after the customization overwrites, exactly the client's + // order; the slots {0,1,2,3,7} are disjoint from every equipment branch below. + if let Some(rows) = equip.helm_vis { + let row = rows[usize::from(sex == 1)]; + if let Some(masks) = self.helmet_vis.get(&row) { + const FORCED: [(usize, u16); 5] = [(0, 1), (1, 101), (2, 201), (3, 301), (7, 701)]; + let bit = 1u32 << (race & 0x1f); + for (mask, (slot, forced)) in masks.iter().zip(FORCED) { + if mask & bit != 0 { + set[slot] = forced; + } + } + } + } + // The equipment branches. `g(slot, sub)` = the slot's geosetGroup[sub], gated non-zero. + // EquipGeosets bodyslot indices: 0 shirt · 1 chest · 2 belt · 3 pants · 4 boots · 5 wrist · + // 6 gloves · 7 tabard. + let g = |slot: usize, sub: usize| { + equip.bodyslots[slot] + .map(|groups| groups[sub]) + .filter(|v| *v != 0) + }; + let disable = |set: &mut Vec, lo: u16, hi: u16| set.retain(|id| *id < lo || *id > hi); + // The robe bit: chest or pants geosetGroup[2] (B4's guard) — it also suppresses the tabard + // and pant-leg branches below. + let robe = g(1, 2).or_else(|| g(3, 2)); + // B1: gloves replace the glove group (401+v, own range disabled); else the chest's sleeves. + if let Some(v) = g(6, 0) { + disable(&mut set, 401, 499); + set.push(401 + v as u16); + } else if let Some(v) = g(1, 0) { + set.push(801 + v as u16); + } + // B3: shirt sleeves, only with no chest item over them. + if equip.bodyslots[1].is_none() { + if let Some(v) = g(0, 0) { + set.push(801 + v as u16); + } + } + // B4: a robe hides boots/kneepads/pant-legs/trousers and shows its skirt (1301+v); else + // boots enable kneepad base + their boot geoset; else the pants' kneepads. + if let Some(v) = robe { + disable(&mut set, 501, 599); + disable(&mut set, 902, 999); + disable(&mut set, 1100, 1199); + disable(&mut set, 1300, 1399); + set.push(1301 + v as u16); + } else if let Some(v) = g(4, 0) { + set.push(501 + v as u16); // the naked 501 stays on, as recorded + } else if let Some(v) = g(3, 1) { + set.push(901 + v as u16); + } + // B5: the tabard flap (robes hide it). + if robe.is_none() { + if let Some(v) = g(7, 0) { + set.push(1201 + v as u16); + } + } + // B7: the shirt's doublet + the pants' leg geoset (the recorded 1102 base — not 1101). + if let Some(v) = g(0, 1) { + set.push(1001 + v as u16); + } + if robe.is_none() { + if let Some(v) = g(3, 0) { + set.push(1102 + v as u16); + } + } + // B8: a cloak replaces the cloak group (1501+v, range disabled). + if let Some(v) = equip.cloak.filter(|v| *v != 0) { + disable(&mut set, 1500, 1599); + set.push(1501 + v as u16); + } + set + } + + /// Load both customization DBCs from the patch chain. + pub fn load(chain: &mut Chain) -> Result { + let hair = { + let bytes = chain + .read_file(CHAR_HAIR_GEOSETS) + .with_context(|| format!("reading {CHAR_HAIR_GEOSETS}"))?; + let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + // fields: ID, RaceID, SexID, VariationID(hairStyle), GeosetID, ShowScalp. + if let (Some(race), Some(sex), Some(var), Some(geoset)) = + (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) + { + m.insert((race as u8, sex as u8, var as u8), geoset); + } + } + m + }; + let facial = { + let bytes = chain + .read_file(CHAR_FACIAL_HAIR) + .with_context(|| format!("reading {CHAR_FACIAL_HAIR}"))?; + let rs = parse( + &bytes, + char_facial_hair_schema(), + "CharacterFacialHairStyles", + )?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + // fields: RaceID, SexID, VariationID(facialHair), 3×unused, gA, gB, gC (no ID column). + if let (Some(race), Some(sex), Some(var)) = + (u32_at(r, 0), u32_at(r, 1), u32_at(r, 2)) + { + let g = [ + u32_at(r, 6).unwrap_or(0), + u32_at(r, 7).unwrap_or(0), + u32_at(r, 8).unwrap_or(0), + ]; + m.insert((race as u8, sex as u8, var as u8), g); + } + } + m + }; + // HelmetGeosetVisData — the helm hide-masks (RF-0083). Soft-optional: a missing/undecodable + // table just means helms never hide hair (the pre-0083 behavior), like the other soft-fails. + let helmet_vis = match chain.read_file(HELMET_GEOSET_VIS) { + Ok(bytes) => { + let rs = parse(&bytes, helmet_vis_schema(), "HelmetGeosetVisData")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + m.insert(id, std::array::from_fn(|i| u32_at(r, 1 + i).unwrap_or(0))); + } + } + m + } + Err(_) => HashMap::new(), + }; + Ok(Self { + hair, + facial, + helmet_vis, + }) + } +} + +/// CharHairGeosets.dbc — 6 fields in build 5875 (verified against the file header). +fn char_hair_geosets_schema() -> Schema { + let mut s = Schema::new("CharHairGeosets"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("RaceID", FieldType::UInt32), + ("SexID", FieldType::UInt32), + ("VariationID", FieldType::UInt32), + ("GeosetID", FieldType::UInt32), + ("ShowScalp", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +/// HelmetGeosetVisData.dbc — 6 fields in build 5875 (verified: the loader `0x546f00` asserts +/// fieldCount 6 / recordSize 0x18; the 5 mask columns are **race** bitmasks, RF-0083). +fn helmet_vis_schema() -> Schema { + let mut s = Schema::new("HelmetGeosetVisData"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("HideHair", FieldType::UInt32), + ("HideFacial1", FieldType::UInt32), + ("HideFacial2", FieldType::UInt32), + ("HideFacial3", FieldType::UInt32), + ("HideEars", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +/// CharacterFacialHairStyles.dbc — 9 fields in build 5875 (verified); no ID column (keyed race/sex/var). +fn char_facial_hair_schema() -> Schema { + let mut s = Schema::new("CharacterFacialHairStyles"); + for (name, ty) in [ + ("RaceID", FieldType::UInt32), + ("SexID", FieldType::UInt32), + ("VariationID", FieldType::UInt32), + ("Unused3", FieldType::UInt32), + ("Unused4", FieldType::UInt32), + ("Unused5", FieldType::UInt32), + ("Geoset100", FieldType::UInt32), // gA → group 1 (+100) + ("Geoset300", FieldType::UInt32), // gB → group 3 (+300) + ("Geoset200", FieldType::UInt32), // gC → group 2 (+200) + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The equipment geoset branches (decision 0074, from the RF-0038 arithmetic): gloves/robe/cloak + /// replace their groups, boots stack over the naked default, and a robe suppresses the tabard + + /// pant-leg branches. + #[test] + fn equipment_geoset_branches() { + let cg = CharacterGeosets { + hair: HashMap::new(), + facial: HashMap::new(), + helmet_vis: HashMap::new(), + }; + let naked = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); + assert!(naked.contains(&401) && naked.contains(&1101) && naked.contains(&1501)); + + // Gloves v=1: the glove group is replaced (401 out, 402 in). + let mut eq = EquipGeosets::default(); + eq.bodyslots[6] = Some([1, 0, 0]); + let set = cg.visible_geosets(1, 0, 0, 0, &eq); + assert!(set.contains(&402) && !set.contains(&401)); + + // Boots v=2: 503 stacks; the naked 501 deliberately stays (the recorded no-disable). + let mut eq = EquipGeosets::default(); + eq.bodyslots[4] = Some([2, 0, 0]); + let set = cg.visible_geosets(1, 0, 0, 0, &eq); + assert!(set.contains(&503) && set.contains(&501)); + + // A robe (chest group[2]=1) shows its skirt (1302) and hides boots/pant-legs/trousers — + // including a worn tabard's flap and the pants' own branches. + let mut eq = EquipGeosets::default(); + eq.bodyslots[1] = Some([1, 0, 1]); + eq.bodyslots[3] = Some([2, 1, 0]); + eq.bodyslots[4] = Some([2, 0, 0]); + eq.bodyslots[7] = Some([1, 0, 0]); + let set = cg.visible_geosets(1, 0, 0, 0, &eq); + assert!(set.contains(&1302), "robe skirt on"); + assert!( + !set.contains(&1101) && !set.contains(&1301), + "pant legs off" + ); + assert!(!set.contains(&501) && !set.contains(&503), "boot group off"); + assert!(!set.contains(&1202), "tabard suppressed under a robe"); + assert!(set.contains(&802), "the chest's sleeves still show"); + + // Cloak v=4: the cloak group is replaced (1501 out, 1505 in). + let eq = EquipGeosets { + cloak: Some(4), + ..Default::default() + }; + let set = cg.visible_geosets(1, 0, 0, 0, &eq); + assert!(set.contains(&1505) && !set.contains(&1501)); + } + + /// The helm-vis force (VERIFIED wow-re RF-0083): each vis column is a **race** bitmask; a set + /// `1 << race` bit forces the slot back to its group base — hair to the bare scalp (1), facial + /// groups to 101/201/301, ears to 701 — dropping the styled geosets. The row is sex-selected + /// (ItemDisplayInfo col 12 male / 13 female); a mask without our race bit leaves the style. + #[test] + fn helm_vis_forces_group_bases_by_race() { + let mut hair = HashMap::new(); + hair.insert((1, 0, 1), 5u32); // styled hair → geoset 5 + let mut facial = HashMap::new(); + facial.insert((1, 0, 1), [2u32, 3, 4]); // styled facial → 102/303/204 + let mut helmet_vis = HashMap::new(); + helmet_vis.insert(368, [u32::MAX; 5]); // hide-everything row (real id, dumped) + helmet_vis.insert(245, [0u32; 5]); // hide-nothing row (real id, dumped) + let cg = CharacterGeosets { + hair, + facial, + helmet_vis, + }; + let helm = EquipGeosets { + helm_vis: Some([368, 245]), + ..Default::default() + }; + // Male (sex 0) picks row 368: everything forced to base. + let set = cg.visible_geosets(1, 0, 1, 1, &helm); + assert!(set.contains(&1) && !set.contains(&5), "hair → bare scalp"); + assert!(set.contains(&101) && !set.contains(&102)); + assert!(set.contains(&201) && !set.contains(&204)); + assert!(set.contains(&301) && !set.contains(&303)); + assert!(set.contains(&701) && !set.contains(&702), "ears → 701"); + // The female column of this pair is the hide-nothing row: styles survive. + let mut hair = HashMap::new(); + hair.insert((1, 1, 1), 5u32); + let cg2 = CharacterGeosets { + hair, + facial: HashMap::new(), + helmet_vis: cg.helmet_vis.clone(), + }; + let set = cg2.visible_geosets(1, 1, 1, 1, &helm); + assert!( + set.contains(&5) && set.contains(&702), + "row 245 hides nothing" + ); + // A mask covering only race 2 leaves a race-1 wearer styled. + let mut helmet_vis = HashMap::new(); + helmet_vis.insert(300, [1u32 << 2; 5]); + let cg3 = CharacterGeosets { + hair: cg2.hair.clone(), + facial: HashMap::new(), + helmet_vis, + }; + let masked = EquipGeosets { + helm_vis: Some([300, 300]), + ..Default::default() + }; + let set = cg3.visible_geosets(1, 1, 1, 1, &masked); + assert!(set.contains(&5), "race bit miss → style kept"); + } + + /// The naked selection for a known Human-male appearance (rows dumped from the build-5875 files): + /// hairStyle 1 → GeosetID 2; facialHair 1 → gA/gB/gC = 1/2/1. So the set carries the body (0), the + /// hair geoset (2), the three facial geosets (101 / 201 / 302), and the default group bases. + #[test] + fn naked_human_male_selection() { + let mut hair = HashMap::new(); + hair.insert((1, 0, 1), 2u32); + let mut facial = HashMap::new(); + facial.insert((1, 0, 1), [1u32, 2, 1]); + let cg = CharacterGeosets { + hair, + facial, + helmet_vis: HashMap::new(), + }; + let set = cg.visible_geosets(1, 0, 1, 1, &EquipGeosets::default()); + + assert!(set.contains(&0), "body geoset 0 always on"); + assert!(set.contains(&2), "hair: max(1, GeosetID 2)"); + assert!(set.contains(&101), "facial group 1: gA 1 + 100"); + assert!(set.contains(&302), "facial group 3: gB 2 + 300"); + assert!(set.contains(&201), "facial group 2: gC 1 + 200"); + // Default equipment-group bases (entries 4–15) stand for a naked body. + assert!(set.contains(&401) && set.contains(&702) && set.contains(&1501)); + // A non-selected hair variant is hidden (geoset 5 is hairStyle 4, not chosen). + assert!(!set.contains(&5), "unselected hair variant hidden"); + } + + /// A bald hairstyle (GeosetID 0) still resolves to geoset 1 (the scalp), never 0-the-body-collision. + #[test] + fn bald_resolves_to_scalp_geoset_one() { + let mut hair = HashMap::new(); + hair.insert((1, 0, 0), 0u32); // variation 0 → GeosetID 0 + let cg = CharacterGeosets { + hair, + facial: HashMap::new(), + helmet_vis: HashMap::new(), + }; + let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); + assert!(set.contains(&1), "max(1, 0) = scalp geoset 1"); + } +} diff --git a/crates/benilla-formats/src/characters/mod.rs b/crates/benilla-formats/src/characters/mod.rs new file mode 100644 index 00000000..0d98b064 --- /dev/null +++ b/crates/benilla-formats/src/characters/mod.rs @@ -0,0 +1,28 @@ +//! Character customization render data (decision 0041, Milestone B) — what a player's appearance +//! selects: which **geosets** the body shows ([`CharacterGeosets`]) and which **skin textures** the +//! body wears ([`CharSections`]). +//! +//! **Geosets.** A character model contains *every* hairstyle, facial-hair piece, and body-option geoset; +//! the real client renders only the selected ones. The selection is the compositor's geoset dispatch +//! `0x477520` (wow-re charactermodel RF-0038). For a character with **no equipment** the 8 per-item +//! branches all no-op (their `ItemDisplayInfo` records are null), leaving only the unconditional opening +//! block: disable every geoset, enable geoset 0, then enable the 16 region-base entries of `cc+0x144` — +//! entries 0–3 overwritten by the customization DBCs (the chosen hair + 3 facial-hair geosets), entries +//! 4–15 the default group bases. We render an M2 submesh iff its `skinSectionId` is in that set. +//! +//! **Skin textures.** A character body's `M2TextureType::Other(1)` (body skin) batches have no embedded +//! texture — the client supplies a runtime composite keyed on the appearance ([`CharSections::composite_body`], +//! decision 0044). The **base skin** (`sectionType 0`) is a single full 256² body-layout BLP per (race, +//! sex, skinColor); the face / facial-hair / hair / underwear overlays (`sectionTypes 1–4`) are region +//! BLPs blended on top at fixed atlas tiles (the RF-0062 partition + RF-0067/0074 section→tile map). +//! +//! DBC field maps byte-verified in wow-re charactermodel (RF-0073 geosets, RF-0042 CharSections); field +//! counts + row semantics cross-checked against the build-5875 files. + +mod customization; +mod geosets; +mod sections; + +pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem}; +pub use geosets::{CharacterGeosets, EquipGeosets}; +pub use sections::CharSections; diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs new file mode 100644 index 00000000..fefa7e46 --- /dev/null +++ b/crates/benilla-formats/src/characters/sections.rs @@ -0,0 +1,651 @@ +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::items::ItemDisplay; +use crate::{read_texture_mip_chain, BlpMipChain, Chain}; + +const CHAR_SECTIONS: &str = "DBFilesClient\\CharSections.dbc"; + +/// CharSections `sectionType` values (wow-re RF-0074): the layer a row supplies. `skin` is the full +/// base atlas; the rest are region overlays the body composite blends on top (decision 0044). +const SECTION_SKIN: u8 = 0; +const SECTION_FACE: u8 = 1; +const SECTION_FACIAL_HAIR: u8 = 2; +const SECTION_HAIR: u8 = 3; +const SECTION_UNDERWEAR: u8 = 4; + +/// The hair variation the client's type-6 binder substitutes when the selected style resolves no +/// texture — a **literal 1** in the binary at two of its three call sites (`0x478445`, `0x4786f2`), +/// not a search. See [`CharSections::hair_mesh_texture`]; wow-re `rf84`, decision 0536. +const HAIR_SUBSTITUTE_VARIATION: u8 = 1; + +/// An atlas rect `(x, y, w, h)` in pixels — a composite destination tile. +type Tile = (u32, u32, u32, u32); + +/// The body-atlas tiles the head + pelvis overlays composite into — three of the RF-0062 static 256² +/// partition's 10 rects. g8/g9 are the head strip (left column, Y 160–256: g8 the 32-tall upper band, g9 +/// the 64-tall lower band); g5 is the pelvis (right column, Y 96–160). +const TILE_G8: Tile = (0, 160, 128, 32); +const TILE_G9: Tile = (0, 192, 128, 64); +const TILE_G5: Tile = (128, 96, 128, 64); + +/// The eight equipment tiles g0–g7 (RF-0062), in **layer order** — the load-bearing identity of +/// decision 0074: ItemDisplayInfo texture column *i* = compositor layer *i* = tile g*i*. Note g5 == +/// [`TILE_G5`] (equipment LegUpper shares the pelvis tile; underwear blits first, so it sits under). +const EQUIP_TILES: [Tile; 8] = [ + (0, 0, 128, 64), // g0 ArmUpper + (0, 64, 128, 64), // g1 ArmLower + (0, 128, 128, 32), // g2 Hand + (128, 0, 128, 64), // g3 TorsoUpper + (128, 64, 128, 32), // g4 TorsoLower + (128, 96, 128, 64), // g5 LegUpper + (128, 160, 128, 64), // g6 LegLower + (128, 224, 128, 32), // g7 Foot +]; + +/// The `Item\TextureComponents\` region directory per layer/column (decision 0074; empirically +/// suffix-matched — the on-disk dirs are exactly these eight). +const EQUIP_TEX_DIRS: [&str; 8] = [ + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", +]; + +/// The `[0x803bf8]` bodyslot×layer stacking table (decision 0074), rows = bodyslots 2–9 (shirt, +/// chest, belt, pants, boots, wrist, gloves, tabard), columns = layers 0–7: the priority a slot's +/// contribution stacks at within the layer's tile (ascending blits later ⇒ on top), `-1` = this slot +/// never touches the layer. Transcribed from the RE'd const table; every column's ordering is +/// art-coherent (pants under robe under belt; shirt under chest under wrist under glove; …). +const EQUIP_LAYER_PRIORITY: [[i8; 8]; 8] = [ + [0, 0, -1, 0, 0, -1, -1, -1], // shirt + [1, 1, -1, 1, 1, 1, 1, -1], // chest (robes reach the legs) + [-1, -1, -1, -1, -1, 2, -1, -1], // belt + [-1, -1, -1, -1, -1, 0, 0, -1], // pants + [-1, -1, -1, -1, -1, -1, 2, 0], // boots + [-1, 2, -1, -1, -1, -1, -1, -1], // wrist + [-1, 3, 0, -1, -1, -1, -1, -1], // gloves + [-1, -1, -1, 3, 4, -1, -1, -1], // tabard +]; + +/// CharSections texture lookup: (race, sex, sectionType, variation, colorIndex) → that row's up-to-3 +/// `TextureName` columns (empty strings for absent columns). Feeds both the base body skin +/// (`sectionType 0`) and the head/pelvis region overlays the body composite blends on top (decision 0044). +pub struct CharSections { + sections: HashMap<(u8, u8, u8, u8, u8), [String; 3]>, +} + +impl CharSections { + /// The base body-skin BLP for an appearance — CharSections `sectionType 0`, the single full 256² + /// body-layout texture keyed by skinColor. `None` if the row is absent. + pub fn skin_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { + self.tex(race, sex, SECTION_SKIN, 0, skin_color, 0) + } + + /// The standalone **extra-skin** BLP for an appearance — CharSections `sectionType 0`, + /// `TextureName[1]`, keyed by skinColor (`…Skin00_NN_Extra.blp`). The texture the client binds to a + /// character body's M2 texture type 8 batches, loaded plain — never composited (the client's extra + /// loader is a bare TextureCreate). Only fur races author the column: tauren bind their head/leg fur + /// to it; every other race's rows leave it empty (and their models carry no type-8 batch). + pub fn skin_extra_texture(&self, race: u8, sex: u8, skin_color: u8) -> Option<&str> { + self.tex(race, sex, SECTION_SKIN, 0, skin_color, 1) + } + + /// The hair-**mesh** texture for an appearance — CharSections `sectionType 3`, `TextureName[0]`, + /// keyed by hairStyle (variation) + hairColor. The single BLP the client binds to the hair geometry's + /// M2 texture type 6 (decision 0045); the colour is baked into the chosen file, not a runtime tint. + /// `None` when the row/column is empty (e.g. a bald style, variation 0 — its columns are blank). + /// (`TextureName[1]/[2]` of the same row are the scalp-on-skin overlays the body composite blends into + /// the head atlas — those go through [`Self::composite_body`], not here.) + pub fn hair_texture(&self, race: u8, sex: u8, hair_style: u8, hair_color: u8) -> Option<&str> { + self.tex(race, sex, SECTION_HAIR, hair_style, hair_color, 0) + } + + /// The texture to bind to a character body's M2 **type 6** batches — the hair sheet, which on + /// several races dresses the *facial* hair geometry (beard/mustache geosets) as well as the + /// scalp hair. Prefer this over [`Self::hair_texture`] anywhere a mesh is being textured; + /// `hair_texture` is the raw row accessor, and a bald row is genuinely blank. + /// + /// **The mechanism** (byte-verified, wow-re `rf84-hair-texture-type6-resolution.md`; decision + /// 0536). The client has no fallback *lookup*. `0x478220(cc, variationIdx)` is the sole type-6 + /// binder, it always reads `TextureName[0]` (never column-indexed), and an **empty name is a + /// no-op that leaves the slot untouched** (`0x47827d cmp BYTE PTR [ecx],0x0` → `je 0x4782d8`) — + /// not a null bind. Three sites call it, in build order skin → hairStyle → facialHair: + /// + /// 1. `0x478445` — variation **literal 1**, gated `sex==0 && hairStyle==0 && ChrRaces.Flags & 8` + /// 2. `0x478450` — variation `hairStyle`, unconditional + /// 3. `0x4786f2` — variation **literal 1**, gated on the selected beard having geoset geometry + /// and the hairStyle row missing a detail column; no sex/race/hairStyle test + /// + /// Because no site ever clears the slot, the result is simply the **last site that resolved a + /// non-empty name** — which reduces to: take the hairStyle row, and when it is blank take + /// **variation 1** at the same colour. That is the fixpoint of the client's incremental apply + /// pipeline, and it is what this implements; we resolve a whole look at once rather than + /// re-applying per dial, so emulating the three sites separately would add machinery without + /// changing an output. + /// + /// The literal 1 is load-bearing where a race authors several sheets per colour: Human male + /// resolves `Hair03_.blp` specifically, not an arbitrary non-blank row. + /// + /// **Where this can diverge** (verified inert on 1.12.1 data, kept honest rather than dropped): + /// site 3's trigger is broader than "the hairStyle row is blank" — it also fires for Dwarf M + /// {0,2,4,9}, Scourge M {1,3,4,8} and Orc F {7}, i.e. 14 of 145 variation groups rather than the + /// 5 blank ones. In every one of those the variation-1 sheet is byte-identical to what the real + /// hairStyle already bound, so the reduction above is exact on the shipped build. It would only + /// part company with the client on data where a site-3 group's variation-1 sheet differs from + /// its own — which build 5875 does not contain. + pub fn hair_mesh_texture( + &self, + race: u8, + sex: u8, + hair_style: u8, + hair_color: u8, + ) -> Option<&str> { + self.hair_texture(race, sex, hair_style, hair_color) + .or_else(|| self.hair_texture(race, sex, HAIR_SUBSTITUTE_VARIATION, hair_color)) + } + + /// One `TextureName` column of a section row, or `None` if the row/column is absent or empty. + fn tex(&self, race: u8, sex: u8, ty: u8, var: u8, color: u8, col: usize) -> Option<&str> { + self.sections + .get(&(race, sex, ty, var, color)) + .map(|t| t[col].as_str()) + .filter(|s| !s.is_empty()) + } + + /// Composite a character's full body-skin atlas: the base 256² skin with the face / facial-hair / + /// hair / underwear region overlays blended in at their fixed atlas tiles, returned as one mip + /// pyramid ready to upload (decision 0044). `Ok(None)` if the base skin row is absent; a missing or + /// undecodable overlay is skipped (best-effort, like the rest of the asset pipeline). + /// + /// Each overlay is read from its own origin and source-over blitted onto the base at the verified + /// tile (RF-0062 bbox table + RF-0067 per-layer src rect + RF-0074 head-section map). The overlay + /// BLP's own alpha is the blend control, so an opaque overlay (`alphaDepth 0` → the real client's + /// REPLACE: face, pelvis) overwrites the base and an alpha one (facial hair) blends — at full 8-bit + /// precision, not the client's 16-bit RGB565 / 2-bit-coverage memory format (the modern-client + /// choice; the decision has the why). Composited per **authored** mip level — no gamma-byte CPU + /// downsample, the same C2 rule [`read_texture_mip_chain`]/the world-art upload follow. + /// + /// `equipment` is the dressed extension (decision 0074): the worn ItemDisplayInfo rows by + /// **bodyslot − 2** (shirt, chest, belt, pants, boots, wrist, gloves, tabard; `None` = the slot + /// is empty). Their region textures blit into the eight equipment tiles after the skin sections + /// (so underwear sits under the pants), stacked per layer by the client's priority table. + #[allow(clippy::too_many_arguments)] + pub fn composite_body( + &self, + chain: &mut Chain, + race: u8, + sex: u8, + skin: u8, + face: u8, + facial_hair: u8, + hair_style: u8, + hair_color: u8, + equipment: [Option<&ItemDisplay>; 8], + ) -> Result> { + let Some(base_path) = self.skin_texture(race, sex, skin) else { + return Ok(None); + }; + let mut atlas = read_texture_mip_chain(chain, base_path) + .with_context(|| format!("reading base skin '{base_path}'"))?; + // (sectionType, variation, color, texColumn, destTile) — the verified fan-out (RF-0067 §"section + // → cell core" + RF-0074 head map). Within a tile, order matters (later overwrites/blends over + // earlier): base skin (already the canvas) → face → facial hair → hair. The pelvis (g5) is its own + // tile. Note the columns differ by section: face/facial-hair use TextureName[0]/[1] (lower/upper), + // hair uses [1]/[2]; underwear is TextureName[0]. Hair is blank for e.g. Human male (its texid + // columns are empty), so those reads no-op there; included so the path is correct for races whose + // hairline does composite into the head. + let overlays: [(u8, u8, u8, usize, Tile); 7] = [ + (SECTION_FACE, face, skin, 0, TILE_G9), + (SECTION_FACE, face, skin, 1, TILE_G8), + (SECTION_FACIAL_HAIR, facial_hair, hair_color, 0, TILE_G9), + (SECTION_FACIAL_HAIR, facial_hair, hair_color, 1, TILE_G8), + (SECTION_HAIR, hair_style, hair_color, 1, TILE_G9), + (SECTION_HAIR, hair_style, hair_color, 2, TILE_G8), + (SECTION_UNDERWEAR, 0, skin, 0, TILE_G5), + ]; + for (ty, var, color, col, tile) in overlays { + let Some(path) = self.tex(race, sex, ty, var, color, col) else { + continue; + }; + if let Ok(overlay) = read_texture_mip_chain(chain, path) { + blit_over(&mut atlas, &overlay, tile); + } + } + // The equipment layers (decision 0074): per layer, the worn contributions stacked by the + // priority table, each read gendered-first (`_M`/`_F` by the wearer's sex, `_U` fallback). + for layer in 0..8 { + let mut contributions: Vec<(i8, &str)> = equipment + .iter() + .enumerate() + .filter_map(|(slot, d)| { + let prio = EQUIP_LAYER_PRIORITY[slot][layer]; + let name = d.as_ref()?.region_textures[layer].as_deref()?; + (prio >= 0).then_some((prio, name)) + }) + .collect(); + contributions.sort_by_key(|(prio, _)| *prio); + for (_, name) in contributions { + if let Some(overlay) = read_equip_region(chain, layer, name, sex) { + blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]); + } + } + } + Ok(Some(atlas)) + } + + /// Load CharSections.dbc from the patch chain. + pub fn load(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(CHAR_SECTIONS) + .with_context(|| format!("reading {CHAR_SECTIONS}"))?; + let rs = parse(&bytes, char_sections_schema(), "CharSections")?; + let mut sections = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + // fields: ID, Race, Sex, SectionType, Variation, Color, Tex0, Tex1, Tex2, Flags. + if let (Some(race), Some(sex), Some(ty), Some(var), Some(color)) = ( + u32_at(r, 1), + u32_at(r, 2), + u32_at(r, 3), + u32_at(r, 4), + u32_at(r, 5), + ) { + let texs = [ + str_at(&rs, r, 6).unwrap_or_default(), + str_at(&rs, r, 7).unwrap_or_default(), + str_at(&rs, r, 8).unwrap_or_default(), + ]; + let key = (race as u8, sex as u8, ty as u8, var as u8, color as u8); + // The standard player sections are `flags == 0`. Some (race,sex,type,color) keys ALSO have + // an `EXTRA`-flagged row (`flags & 0x1`) sharing the same colorIndex — e.g. human-male + // skinColor 0/1 carry both `HumanMaleSkin00_00` (flags 0) and the late `…_100` (flags 1). + // Those are not the chargen-selectable skins; a player with skinColor 0/1 must get `…_00`. + // So a standard row always wins; an extra row only fills a key no standard row provides + // (order-independent — robust to the extras appearing before or after the standards). + let extra = u32_at(r, 9).unwrap_or(0) & 0x1 != 0; + if extra { + sections.entry(key).or_insert(texs); + } else { + sections.insert(key, texs); + } + } + } + Ok(Self { sections }) + } +} + +/// Read one equipment region texture off the chain: `Item\TextureComponents\\_.blp`, +/// the gender letter by the wearer's sex (`M`/`F`), falling back to `_U` (unisex — the majority of +/// the shipped files) then the bare name (defensive; nothing shipped is bare). `None` when no +/// variant decodes — the layer is skipped, best-effort like the skin overlays. +fn read_equip_region(chain: &mut Chain, layer: usize, name: &str, sex: u8) -> Option { + let dir = EQUIP_TEX_DIRS[layer]; + let letter = if sex == 1 { 'F' } else { 'M' }; + for suffix in [format!("_{letter}"), "_U".into(), String::new()] { + let path = format!("Item\\TextureComponents\\{dir}\\{name}{suffix}.blp"); + if let Ok(mips) = read_texture_mip_chain(chain, &path) { + return Some(mips); + } + } + None +} + +/// Source-over composite of one region overlay's authored mip pyramid onto the body atlas at a fixed +/// tile. Per mip level `i` the destination tile is `(x>>i, y>>i, w>>i, h>>i)` and the overlay's level-`i` +/// pixels are read from its **own origin** — the RF-0067 overlay src rect (`src = (0,0)`, `dst = tile.xy`, +/// extent `tile.wh`), so the overlay BLP is authored exactly tile-sized. Straight 8-bit source-over per +/// channel (`out = src·a + dst·(1−a)`, `out_a = a + dst_a·(1−a)`): an opaque overlay (`a == 255`) is a +/// plain copy (the client's REPLACE), an alpha one blends. Levels past either pyramid's end, and the +/// sub-pixel remainder of a degenerate deep-mip tile, are clamped/skipped. +fn blit_over(dst: &mut BlpMipChain, src: &BlpMipChain, tile: Tile) { + let (tx, ty, tw, th) = tile; + let levels = dst.mips.len().min(src.mips.len()); + for i in 0..levels { + let dw = (dst.width >> i).max(1) as usize; + let dh = (dst.height >> i).max(1) as usize; + let sw = (src.width >> i).max(1) as usize; + let sh = (src.height >> i).max(1) as usize; + let (ox, oy) = ((tx >> i) as usize, (ty >> i) as usize); + // Copy extent: the tile size at this level, clamped to what both the source and the dest hold. + let cw = ((tw >> i).max(1) as usize) + .min(sw) + .min(dw.saturating_sub(ox)); + let ch = ((th >> i).max(1) as usize) + .min(sh) + .min(dh.saturating_sub(oy)); + let (d, s) = (&mut dst.mips[i], &src.mips[i]); + for row in 0..ch { + for col in 0..cw { + let si = (row * sw + col) * 4; + let di = ((oy + row) * dw + (ox + col)) * 4; + if si + 4 > s.len() || di + 4 > d.len() { + continue; + } + let a = s[si + 3] as u32; + if a == 0 { + continue; // fully transparent overlay texel — leave the base + } + if a == 255 { + d[di..di + 4].copy_from_slice(&s[si..si + 4]); + continue; + } + let ia = 255 - a; + for c in 0..3 { + d[di + c] = ((s[si + c] as u32 * a + d[di + c] as u32 * ia + 127) / 255) as u8; + } + d[di + 3] = (a + (d[di + 3] as u32 * ia + 127) / 255).min(255) as u8; + } + } + } +} + +/// CharSections.dbc — 10 fields in build 5875 (verified); 3 string columns are texture-name offsets. +fn char_sections_schema() -> Schema { + let mut s = Schema::new("CharSections"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("RaceID", FieldType::UInt32), + ("SexID", FieldType::UInt32), + ("SectionType", FieldType::UInt32), + ("VariationIndex", FieldType::UInt32), + ("ColorIndex", FieldType::UInt32), + ("TextureName0", FieldType::String), + ("TextureName1", FieldType::String), + ("TextureName2", FieldType::String), + ("Flags", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The base skin resolves the full-body BLP for (race, sex, skinColor) — the path the renderer + /// applies to the body-skin batches (value = the real build-5875 row for Human male skinColor 3). + #[test] + fn base_skin_texture_resolves() { + let mut sections = HashMap::new(); + sections.insert( + (1, 0, SECTION_SKIN, 0, 3), + [ + "Character\\Human\\Male\\HumanMaleSkin00_03.blp".to_string(), + String::new(), + String::new(), + ], + ); + let cs = CharSections { sections }; + assert_eq!( + cs.skin_texture(1, 0, 3), + Some("Character\\Human\\Male\\HumanMaleSkin00_03.blp") + ); + assert_eq!(cs.skin_texture(1, 0, 99), None, "absent color → no texture"); + } + + /// A single-level RGBA helper for the blit test. + fn chain(width: u32, height: u32, px: Vec) -> BlpMipChain { + BlpMipChain { + width, + height, + mips: vec![px], + } + } + + /// `blit_over`: an opaque overlay REPLACEs the tile; an alpha overlay source-over blends; a fully + /// transparent texel leaves the base; pixels outside the tile are untouched. + #[test] + fn blit_over_replaces_blends_and_clamps() { + // 2×2 mid-grey opaque base (4 px of RGBA [128,128,128,255]). + let mut dst = chain(2, 2, [128, 128, 128, 255].repeat(4)); + + // 1×1 opaque red at tile (0,0,1,1) → replaces pixel 0, leaves the rest. + blit_over(&mut dst, &chain(1, 1, vec![255, 0, 0, 255]), (0, 0, 1, 1)); + assert_eq!( + &dst.mips[0][0..4], + &[255, 0, 0, 255], + "opaque overlay replaces" + ); + assert_eq!( + &dst.mips[0][4..8], + &[128, 128, 128, 255], + "neighbour untouched" + ); + + // 1×1 half-alpha blue at (1,1) → blends 50/50 with grey: R≈64, B≈191, stays opaque. + blit_over(&mut dst, &chain(1, 1, vec![0, 0, 255, 128]), (1, 1, 1, 1)); + let px = &dst.mips[0][12..16]; + assert!((px[0] as i32 - 64).abs() <= 1, "blended R ≈ 128·(1−a)"); + assert!( + (px[2] as i32 - 191).abs() <= 1, + "blended B ≈ 255·a + 128·(1−a)" + ); + assert_eq!(px[3], 255, "over an opaque base the result stays opaque"); + + // A fully transparent overlay texel is a no-op (the base shows through). + let before = dst.mips[0].clone(); + blit_over(&mut dst, &chain(1, 1, vec![1, 2, 3, 0]), (0, 0, 1, 1)); + assert_eq!(dst.mips[0], before, "transparent texel is a no-op"); + } + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end regression on the **real** build-5875 files: compositing a Human-male body must yield + /// a 256² mip pyramid whose head (g8/g9) + pelvis (g5) tiles are overlaid from the base, while a + /// control tile that carries no naked-body overlay (g3) is untouched. Guards the section→tile map + + /// the CharSections schema against a silent break (a wrong tile or a shifted column would move/lose + /// these diffs). Skips when the client data isn't present. + #[test] + fn composite_body_overlays_land_on_real_human_male() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cs = CharSections::load(&mut chain).expect("load CharSections"); + let base = + read_texture_mip_chain(&mut chain, "Character\\Human\\Male\\HumanMaleSkin00_03.blp") + .expect("read base skin"); + let comp = cs + .composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8]) + .expect("composite ok") + .expect("base skin row present"); + + assert_eq!((comp.width, comp.height), (256, 256), "atlas is 256²"); + assert_eq!(comp.mips.len(), base.mips.len(), "keeps the base mip count"); + + // Count mip-0 pixels in a tile that differ from the base atlas. + let changed = |t: Tile| { + let (x, y, w, h) = t; + let (b, c) = (&base.mips[0], &comp.mips[0]); + (y..y + h) + .flat_map(|row| (x..x + w).map(move |col| (row, col))) + .filter(|&(row, col)| { + let i = ((row * 256 + col) * 4) as usize; + b[i..i + 4] != c[i..i + 4] + }) + .count() + }; + // Face replaces the head tiles; pelvis replaces g5 — each should change most of its tile. + assert!(changed(TILE_G9) > 4000, "face lower overlaid into g9"); + assert!(changed(TILE_G8) > 2000, "face upper overlaid into g8"); + assert!(changed(TILE_G5) > 4000, "pelvis overlaid into g5"); + // A right-column tile with no naked-body overlay must be byte-identical to the base. + assert_eq!(changed((128, 0, 128, 64)), 0, "g3 carries no naked overlay"); + + // Hair-mesh texture (decision 0045): a real hairstyle resolves a `Hair…` BLP; the bald style + // (variation 0) has none. Guards the `SECTION_HAIR` constant + the type-3 row keying. + let hair = cs + .hair_texture(1, 0, 1, 0) + .expect("hairStyle 1 has a hair texture"); + assert!( + hair.contains("Hair"), + "hair texture is a Hair BLP, got {hair:?}" + ); + assert_eq!( + cs.hair_texture(1, 0, 0, 0), + None, + "bald style has no hair texture" + ); + + // The type-6 MESH resolver's substitute (decision 0536, byte-verified wow-re `rf84`): when the + // selected style resolves nothing, the client's binder has already bound variation **1** and an + // empty name leaves that slot untouched. A bald orc/gnome male still wears a beard, and on those + // races the beard is geometry on the hair unit — so the blank bald row must not leave it + // untextured (the flat-white bug: decision 0157's fallback showing through). + for (race, sex, name) in [(2u8, 0u8, "Orc"), (7, 0, "Gnome")] { + let styled = cs + .hair_mesh_texture(race, sex, 1, 0) + .unwrap_or_else(|| panic!("{name} hairStyle 1 has a hair sheet")); + let bald = cs + .hair_mesh_texture(race, sex, 0, 0) + .unwrap_or_else(|| panic!("{name} bald must still resolve a sheet for the beard")); + assert_eq!( + bald, styled, + "{name} carries the style in the geometry — one sheet per colour" + ); + assert!( + bald.contains("Hair00_00"), + "{name} sheet is Hair00_00, got {bald:?}" + ); + // The fallback tracks hair COLOUR (the beard is hair-coloured), not just any row. + let bald_c2 = cs + .hair_mesh_texture(race, sex, 0, 2) + .unwrap_or_else(|| panic!("{name} bald at colour 2 resolves")); + assert!( + bald_c2.contains("Hair00_02"), + "{name} colour 2 sheet, got {bald_c2:?}" + ); + assert_ne!(bald, bald_c2, "{name} colour must change the sheet"); + } + + // Human authors TWO distinct sheets per colour, which is exactly where the literal-1 substitute + // is load-bearing: a bald human male resolves variation 1's sheet specifically, not "whichever + // non-blank row we happened to find". The earlier interim searched for a unique non-blank + // variation and returned None here — behaviourally inert (a bald human's facial hair is a + // painted overlay, so there is no type-6 geometry to texture) but not the client's mechanism. + let human_bald = cs + .hair_mesh_texture(1, 0, 0, 0) + .expect("the substitute resolves variation 1 even where a race authors several sheets"); + assert_eq!( + Some(human_bald), + cs.hair_texture(1, 0, HAIR_SUBSTITUTE_VARIATION, 0), + "the substitute is variation 1, not an arbitrary non-blank row" + ); + assert!( + human_bald.contains("Hair03_00"), + "human male variation 1 is the Hair03 sheet, got {human_bald:?}" + ); + // A real style is never touched by the substitute, on any race. + assert_eq!( + cs.hair_mesh_texture(1, 0, 1, 0), + cs.hair_texture(1, 0, 1, 0) + ); + + // Extra-skin texture (the tauren fur, M2 type 8): the type-0 row's SECOND column. Tauren male + // skinColor 0 resolves the real build-5875 `_Extra` BLP; a race that doesn't author the column + // (human) yields None — its models carry no type-8 batch. + assert_eq!( + cs.skin_extra_texture(6, 0, 0), + Some("Character\\Tauren\\Male\\TaurenMaleSkin00_00_Extra.blp"), + "tauren male extra skin resolves" + ); + assert_eq!( + cs.skin_extra_texture(1, 0, 0), + None, + "human male authors no extra skin" + ); + + // skinColor 0/1 must resolve the standard chargen skin, NOT the EXTRA-flagged `…_100/_101` row + // sharing the same colorIndex (the flags-precedence fix). + assert_eq!( + cs.skin_texture(1, 0, 0), + Some("Character\\Human\\Male\\HumanMaleSkin00_00.blp"), + "skinColor 0 is the standard skin, not the EXTRA-flagged _100" + ); + assert_eq!( + cs.skin_texture(1, 0, 1), + Some("Character\\Human\\Male\\HumanMaleSkin00_01.blp") + ); + } + + /// Equipment layers on the **real** files (decision 0074): dressing the Human male in One's + /// starter kit (shirt 9891 / pants 9892 / boots 10141) must repaint exactly the tiles those + /// displays' region columns name — torso from the shirt, LegUpper from the pants, Foot from the + /// boots — and leave a tile no item touches (g2 Hand) byte-identical to the naked composite. + /// Also pins the boots-over-pants stacking on the shared LegLower tile (both contribute; the + /// priority table puts the boot layer on top, so g6 must differ from the pants-only composite). + #[test] + fn composite_body_equipment_layers_land_on_real_human_male() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cs = CharSections::load(&mut chain).expect("load CharSections"); + let items = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo"); + let (shirt, pants, boots) = ( + items.get(9891).expect("shirt display"), + items.get(9892).expect("pants display"), + items.get(10141).expect("boots display"), + ); + let mut compose = |equipment: [Option<&ItemDisplay>; 8]| { + cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment) + .expect("composite ok") + .expect("base skin row present") + }; + let naked = compose([None; 8]); + // bodyslot-2 indexing: shirt = slot 2 (idx 0), pants = slot 5 (idx 3), boots = slot 6 (idx 4). + let mut equipment: [Option<&ItemDisplay>; 8] = [None; 8]; + equipment[0] = Some(shirt); + equipment[3] = Some(pants); + let pants_only = compose(equipment); + equipment[4] = Some(boots); + let dressed = compose(equipment); + + let changed = |a: &BlpMipChain, b: &BlpMipChain, t: Tile| { + let (x, y, w, h) = t; + (y..y + h) + .flat_map(|row| (x..x + w).map(move |col| (row, col))) + .filter(|&(row, col)| { + let i = ((row * 256 + col) * 4) as usize; + a.mips[0][i..i + 4] != b.mips[0][i..i + 4] + }) + .count() + }; + assert!( + changed(&naked, &dressed, EQUIP_TILES[3]) > 2000, + "shirt repaints TorsoUpper (g3)" + ); + assert!( + changed(&naked, &dressed, EQUIP_TILES[5]) > 2000, + "pants repaint LegUpper (g5)" + ); + assert!( + changed(&naked, &dressed, EQUIP_TILES[7]) > 1000, + "boots repaint Foot (g7)" + ); + assert_eq!( + changed(&naked, &dressed, EQUIP_TILES[2]), + 0, + "nothing touches Hand (g2)" + ); + assert!( + changed(&pants_only, &dressed, EQUIP_TILES[6]) > 1000, + "boots stack over the pants' LegLower (g6)" + ); + } +} diff --git a/crates/benilla-formats/src/creature_sound.rs b/crates/benilla-formats/src/creature_sound.rs new file mode 100644 index 00000000..83dbf22f --- /dev/null +++ b/crates/benilla-formats/src/creature_sound.rs @@ -0,0 +1,213 @@ +//! Creature voice: **CreatureDisplayInfo.SoundID → CreatureSoundData.dbc** (decision 0070 +//! slice 3) — the per-display voice kit set (exertion/wound/death/aggro/fidgets…). +//! +//! Layouts — VERIFIED against build 5875 (header + row decode, 2026-07-02): +//! `CreatureSoundData` **406 × 30 × 120 B**: `ID, ExertionID, ExertionCriticalID, InjuryID, +//! InjuryCriticalID, InjuryCrushingBlowID, DeathID, StunID, StandID, FootstepID (a +//! FootstepTerrainLookup **class**, NOT a kit), AggroID, WingFlapID, WingGlideID, AlertID, +//! Fidget[4], CustomAttack[4], NPCSoundID, LoopSoundID, CreatureImpactType (0 flesh · 1 stone · +//! 2 wood · 3 ethereal), JumpStartID, JumpEndID, PetAttackID, PetOrderID, PetDismissID`. +//! Spot-check row 26: exertion 312/313, injury 315/316/0, death 314, stun 690, stand 317, +//! footstep class 8, aggro 694, alert 1107 — all coherent kit-id ranges. +//! The chain is `UNIT_FIELD_DISPLAYID` → `CreatureDisplayInfo.SoundID`, **falling back to +//! `CreatureModelData.SoundID`** (col 13 of the 430 × 16 × 64 B table) when the display's own FK +//! is 0 — the client's generic resolution (wow-re `benilla-pins.md` B11b; the earlier "no model +//! fallback in 1.12" note here was wrong). The fallback is load-bearing: 10 261 of 10 534 +//! displays carry SoundID 0, and with the model link 10 533/10 534 resolve a row (byte-census +//! 2026-07-03) — character displays reach footstep class 7 this way, as data, not client logic. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +/// One `CreatureSoundData` row — every field is a SoundEntries kit id (0 = none) except where +/// noted. +pub struct CreatureVoice { + /// Attack grunt `[normal, critical]`. + pub exertion: [u32; 2], + /// Wound vocal `[normal, critical, crushing]`. + pub injury: [u32; 3], + pub death: u32, + pub stun: u32, + /// Fired by the `$FDX` anim event. + pub stand: u32, + /// `FootstepTerrainLookup.CreatureFootstepID` — the creature's footstep **class**, joined + /// against the terrain type (NOT a kit id). + pub footstep_class: u32, + pub aggro: u32, + /// Fired by `$WNG` / `$WGG`. + pub wing_flap: u32, + pub wing_glide: u32, + pub alert: u32, + /// Fired by `$FD1`..`$FD4`. + pub fidget: [u32; 4], + /// Fired by `$AH0`..`$AH3`. + pub custom_attack: [u32; 4], + /// A looping body sound (SoundEntries type 27). + pub loop_sound: u32, + /// Melee impact material: 0 flesh · 1 stone · 2 wood · 3 ethereal (WeaponImpactSounds slot + /// class — consumed with combat impacts). + pub impact_type: u32, + pub jump_start: u32, + pub jump_end: u32, +} + +/// display id → voice rows, joined through `CreatureDisplayInfo.SoundID`. +pub struct CreatureVoiceCatalog { + display_to_sound: HashMap, + rows: HashMap, +} + +impl CreatureVoiceCatalog { + /// The voice set for a creature **display id** (the `NetEntity.display_id` the wire gives us). + pub fn for_display(&self, display_id: u32) -> Option<&CreatureVoice> { + self.rows.get(self.display_to_sound.get(&display_id)?) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +fn csd_schema() -> Schema { + let mut s = Schema::new("CreatureSoundData"); + for i in 0..30 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +fn cdi_schema() -> Schema { + let mut s = Schema::new("CreatureDisplayInfo"); + for i in 0..12 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +fn cmd_schema() -> Schema { + let mut s = Schema::new("CreatureModelData"); + for i in 0..16 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +/// Read both tables off the patch chain into the joined catalog. +pub fn load_creature_voice_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\CreatureSoundData.dbc") + .context("reading CreatureSoundData.dbc")?; + let rs = parse(&bytes, csd_schema(), "CreatureSoundData")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let g = |i: usize| u32_at(r, i).unwrap_or(0); + rows.insert( + id, + CreatureVoice { + exertion: [g(1), g(2)], + injury: [g(3), g(4), g(5)], + death: g(6), + stun: g(7), + stand: g(8), + footstep_class: g(9), + aggro: g(10), + wing_flap: g(11), + wing_glide: g(12), + alert: g(13), + fidget: [g(14), g(15), g(16), g(17)], + custom_attack: [g(18), g(19), g(20), g(21)], + loop_sound: g(23), + impact_type: g(24), + jump_start: g(25), + jump_end: g(26), + }, + ); + } + + let bytes = chain + .read_file("DBFilesClient\\CreatureModelData.dbc") + .context("reading CreatureModelData.dbc")?; + let rs = parse(&bytes, cmd_schema(), "CreatureModelData")?; + let mut model_to_sound = HashMap::new(); + for r in rs.records() { + if let (Some(id), Some(sound)) = (u32_at(r, 0), u32_at(r, 13)) { + if sound != 0 { + model_to_sound.insert(id, sound); + } + } + } + + let bytes = chain + .read_file("DBFilesClient\\CreatureDisplayInfo.dbc") + .context("reading CreatureDisplayInfo.dbc")?; + let rs = parse(&bytes, cdi_schema(), "CreatureDisplayInfo")?; + let mut display_to_sound = HashMap::new(); + for r in rs.records() { + let (Some(id), Some(sound), Some(model)) = (u32_at(r, 0), u32_at(r, 2), u32_at(r, 1)) + else { + continue; + }; + // The display's own FK wins; 0 falls back to the model's (module docs — B11b). + let sound = if sound != 0 { + Some(sound) + } else { + model_to_sound.get(&model).copied() + }; + if let Some(sound) = sound { + display_to_sound.insert(id, sound); + } + } + Ok(CreatureVoiceCatalog { + display_to_sound, + rows, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The join works on the real 5875 tables: display 26 resolves the byte-decoded row (death + /// kit 314, footstep class 8), and a majority of sound-linked displays resolve to a real row. + #[test] + fn real_creature_voice_resolves() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_creature_voice_catalog(&mut chain).expect("load creature voices"); + assert_eq!(cat.len(), 406, "all CreatureSoundData rows load"); + + let v = cat.for_display(26).expect("display 26 has a voice"); + assert_eq!(v.death, 314); + assert_eq!(v.exertion, [312, 313]); + assert_eq!(v.footstep_class, 8); + assert_eq!(v.aggro, 694); + + // The CreatureModelData fallback (B11b): the Elwynn wolf display (903) has + // CreatureDisplayInfo.SoundID 0 and resolves through its model's SoundID (43); + // the human-male character display (49) reaches footstep class 7 the same way. + let wolf = cat.for_display(903).expect("wolf resolves via the model"); + assert_eq!(wolf.footstep_class, 8); + let human = cat + .for_display(49) + .expect("human male resolves via the model"); + assert_eq!(human.footstep_class, 7); + } +} diff --git a/crates/benilla-formats/src/creature_types.rs b/crates/benilla-formats/src/creature_types.rs new file mode 100644 index 00000000..470777fc --- /dev/null +++ b/crates/benilla-formats/src/creature_types.rs @@ -0,0 +1,100 @@ +//! CreatureType.dbc — the 13-row creature-class table (Beast, Humanoid, Critter, Totem, …). +//! +//! The one consumer today: the TAB-target scan's critter rejection (wow-re +//! `targeting-nearest-and-autoacquire.md`: the scorer `0x494200` looks the candidate's creature +//! type up in the cached table `[0xc0de2c]` and rejects when `row[+0x28] & 1` — **flag bit 0**). +//! In the shipped 1.12 data only **Critter (8)** carries the bit (the "critter/totem/non-combat +//! pet" gloss is later-era; see the real-chain test). A unit's creature type itself comes off +//! the wire (`SMSG_CREATURE_QUERY_RESPONSE`), cached with its name. +//! +//! Record layout (1.12, 11 × u32-slot fields): id@0, name(8 locales + flags = 9 slots)@1..9, +//! flags@10. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +const CREATURE_TYPE: &str = "DBFilesClient\\CreatureType.dbc"; + +/// creature-type id → the row's flags dword. Query through [`CreatureTypeFlags::no_tab_target`]. +#[derive(Debug, Default, Clone)] +pub struct CreatureTypeFlags(HashMap); + +impl CreatureTypeFlags { + /// Whether this creature type is excluded from TAB/nearest-enemy targeting — the client's + /// `flags & 1` test. In the shipped **1.12** data exactly ONE row carries the bit: + /// **Critter (8)** (real-chain verified below — Totem 11 does NOT; the "can't tab totems" + /// lore is a later-era flag change, not 5875 data). An unknown or missing type is targetable + /// (the client's out-of-range index skips the check). + pub fn no_tab_target(&self, creature_type: u32) -> bool { + self.0.get(&creature_type).is_some_and(|f| f & 1 != 0) + } + + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +fn creature_type_schema() -> Schema { + let mut s = Schema::new("CreatureType"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + for i in 0..8 { + s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String)); + } + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + s.add_field(SchemaField::new("Flags", FieldType::UInt32)); + s +} + +/// Load CreatureType.dbc from the patch chain. +pub fn load_creature_type_flags(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(CREATURE_TYPE) + .with_context(|| format!("reading {CREATURE_TYPE}"))?; + let rs = parse(&bytes, creature_type_schema(), "CreatureType")?; + let mut flags = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + flags.insert(id, u32_at(r, 10).unwrap_or(0)); + } + } + Ok(CreatureTypeFlags(flags)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data test skips when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The shipped 1.12 data, read through the real chain when the install is present: **11** + /// rows (ids 1–11), and exactly one carries flag bit 0 — **Critter (8)**. Totem (11) does + /// NOT (verified against the real file; the critter/totem gloss from later expansions doesn't + /// hold for 5875 data). Beast (1) and Humanoid (7) are targetable. + #[test] + fn shipped_flags_mark_only_critter() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let flags = load_creature_type_flags(&mut chain).expect("load CreatureType.dbc"); + assert_eq!(flags.len(), 11, "1.12 ships 11 creature types"); + assert!(flags.no_tab_target(8), "Critter (8) must be un-TAB-able"); + for id in [1, 7, 11] { + assert!(!flags.no_tab_target(id), "type {id} must be targetable"); + } + assert!(!flags.no_tab_target(999), "unknown type is targetable"); + } +} diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs new file mode 100644 index 00000000..6c944f3b --- /dev/null +++ b/crates/benilla-formats/src/creatures.rs @@ -0,0 +1,415 @@ +//! Creature display resolution: `displayId` → M2 model + skin textures + scale. +//! +//! An NPC's `UNIT_FIELD_DISPLAYID` indexes **CreatureDisplayInfo.dbc**, which gives a `ModelID` +//! (into **CreatureModelData.dbc**, the `.mdx` path), a per-display scale, and up to three skin +//! texture names. Creature M2s leave their `Monster1/2/3` texture slots blank and pull the skin +//! from these names — the texture lives **in the same directory as the model** (wowdev.wiki). The +//! effective render scale is `CreatureModelData.modelScale * CreatureDisplayInfo.creatureModelScale`. +//! +//! **Character-model NPCs.** A humanoid NPC (guard, questgiver, townsfolk) uses a `Character\…` body +//! M2 — the same model a player wears — but its appearance is *not* on the wire (as a player's is); +//! it lives in **CreatureDisplayInfoExtra.dbc**, reached via `CreatureDisplayInfo.ExtendedDisplayInfoID` +//! (0 for a plain beast). That row supplies race/sex + the customization selectors and, in field 18, a +//! **bake name** — a pre-composited body atlas the client ships under `Textures\BakedNpcTextures\` and +//! loads directly (rather than compositing live like the local player). We surface it as +//! [`NpcAppearance`]; the beast skin path ([`CreatureModel::textures`]) is untouched. +//! +//! Layouts verified against build 5875 (field counts from the file header, cross-checked with +//! wowdev.wiki + vmangos `DBCStructure.h`): CreatureModelData = 16 fields (ID@0, ModelName@2, +//! ModelScale@4); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4, +//! TextureVariation@6/7/8); CreatureDisplayInfoExtra = 19 fields (ID@0, Race@1, Sex@2, Skin@3, Face@4, +//! HairStyle@5, HairColor@6, FacialHair@7, Equipment@8..17, BakeName@18). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; + +const CREATURE_MODEL_DATA: &str = "DBFilesClient\\CreatureModelData.dbc"; +const CREATURE_DISPLAY_INFO: &str = "DBFilesClient\\CreatureDisplayInfo.dbc"; +const CREATURE_DISPLAY_INFO_EXTRA: &str = "DBFilesClient\\CreatureDisplayInfoExtra.dbc"; + +/// A resolved creature: model path + effective scale + its (up to three) skin texture names, plus — +/// for a character-model NPC — the [`NpcAppearance`] that skins its body. +#[derive(Debug, Clone)] +pub struct CreatureModel { + /// `.mdx` path (the M2 loader normalizes to `.m2`), e.g. `Creature\Basilisk\Basilisk.mdx`. + pub model_path: String, + /// `CreatureModelData.modelScale * CreatureDisplayInfo.creatureModelScale`. + pub scale: f32, + /// `textureVariation[0..2]` — bare names (no dir/extension); `None` where empty. The renderer + /// resolves a used one to `\.blp` for the model's `Monster1/2/3` slots. + /// For a character-model NPC the body skin comes from [`Self::npc_appearance`]'s baked atlas + /// regardless; these slots are empty on ~98% of such rows and unused for the body on the rest + /// (wow-re-confirmed — a `Monster`-slot binding on a character M2 is a separate, untraced mechanism). + pub textures: [Option; 3], + /// A character-model NPC's body appearance (from CreatureDisplayInfoExtra, via the display's + /// `ExtendedDisplayInfoID`). `None` for a plain beast/monster (ExtendedDisplayInfoID 0) — those + /// skin from [`Self::textures`], not here. + pub npc_appearance: Option, + /// The resolved **UnitBloodLevels** key for the melee blood spurt (decision 0137 phase 3, + /// wow-re `melee-blood-spurt.md`): `CreatureDisplayInfo.BloodLevel` when nonzero, else + /// `CreatureModelData.BloodID`; `≤ 0` = a bloodless model (elementals, ghosts — the client's + /// negative-id skip). Consumed via [`crate::BloodCatalog::effect_id`]. + pub blood: i32, +} + +/// A character-model NPC's appearance, from **CreatureDisplayInfoExtra.dbc**. A humanoid NPC wears a +/// `Character\…` body M2 whose skin is not on the wire; this carries what the client needs to render +/// it: race/sex + the customization selectors (for the hair mesh + the geoset selection), the ten worn +/// **equipment** display ids (the armor geosets + the helm/shoulder attach models), and a `bake_name` +/// — the pre-composited body atlas the client ships under `Textures\BakedNpcTextures\` and loads +/// directly. `skin`/`face` are already baked into that atlas; they're kept for completeness and for the +/// live-composite fallback when a row carries no bake name. +#[derive(Debug, Clone)] +pub struct NpcAppearance { + pub race: u8, + pub sex: u8, + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, + /// The ten worn-equipment `ItemDisplayInfo` display ids (fields 8..17), **bodyslot-indexed**: + /// `0` head · `1` shoulder · `2` shirt · `3` chest · `4` belt · `5` pants · `6` boots · `7` wrist · + /// `8` gloves · `9` tabard (no cloak column — the row stops at bodyslot 9). `0` = the slot is + /// empty. Direct display ids (not item entries — no template round-trip): the head/shoulder ids + /// drive the helm/pauldron attach sub-models and the shirt..tabard ids drive the equipment geosets, + /// through the same `ItemDisplayInfo` catalog + geoset machinery the player wire path uses. + pub equipment: [u32; 10], + /// The pre-baked body-atlas file name (bare, no dir) under `Textures\BakedNpcTextures\`; `None` + /// when field 18 is empty (then the body composites live from the fields above, like a player). + pub bake_name: Option, +} + +/// One CreatureDisplayInfo row (the parts we use). +#[derive(Debug, Clone)] +struct DisplayRow { + model_id: u32, + /// `ExtendedDisplayInfoID` — the CreatureDisplayInfoExtra key for a character-model NPC; 0 = none. + extended_id: u32, + scale: f32, + textures: [Option; 3], + /// `BloodLevel` (field 10) — a per-display UnitBloodLevels override; 0 = fall through to the + /// model's `BloodID`. + blood_level: u32, +} + +/// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`]. +pub struct CreatureCatalog { + /// CreatureDisplayInfo: displayId → row. + display: HashMap, + /// CreatureModelData: modelId → (model path, model scale, BloodID — see [`CreatureModel::blood`]). + models: HashMap, + /// CreatureDisplayInfoExtra: extendedDisplayInfoId → character-model NPC appearance. + extra: HashMap, +} + +impl CreatureCatalog { + /// A display's own `creatureModelScale` column alone — the MOUNT scale law (byte-verified, + /// wow-re `mount-composition.md` / `0x613ef0`: a rendered mount = `OBJECT_FIELD_SCALE_X × + /// CreatureDisplayInfo.creatureModelScale`; `CreatureModelData.modelScale` does NOT multiply + /// in, unlike [`CreatureModel::scale`]'s spawned-creature product). `None` when the display + /// id misses. + pub fn display_scale(&self, display_id: u32) -> Option { + self.display.get(&display_id).map(|r| r.scale) + } + + /// Resolve an NPC display id to its model, or `None` if either DBC lookup misses. + pub fn model(&self, display_id: u32) -> Option { + let row = self.display.get(&display_id)?; + let (model_path, model_scale, model_blood) = self.models.get(&row.model_id)?; + // A non-zero ExtendedDisplayInfoID that resolves to an extra row ⇒ a character-model NPC. + let npc_appearance = (row.extended_id != 0) + .then(|| self.extra.get(&row.extended_id).cloned()) + .flatten(); + Some(CreatureModel { + model_path: model_path.clone(), + scale: model_scale * row.scale, + textures: row.textures.clone(), + npc_appearance, + blood: if row.blood_level != 0 { + row.blood_level as i32 + } else { + *model_blood + }, + }) + } + + /// Number of display entries (for logging/diagnostics). + pub fn len(&self) -> usize { + self.display.len() + } + + /// Whether the catalog has no display entries. + pub fn is_empty(&self) -> bool { + self.display.is_empty() + } + + /// Number of character-model NPC appearance rows loaded (for logging/diagnostics). Zero here means + /// CreatureDisplayInfoExtra failed to load ⇒ humanoid NPCs stay untextured. + pub fn extra_len(&self) -> usize { + self.extra.len() + } +} + +/// CreatureModelData.dbc — 16 fields in build 5875 (no `mountHeight`). We read ID, ModelName, scale. +fn creature_model_data_schema() -> Schema { + let mut s = Schema::new("CreatureModelData"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("Flags", FieldType::UInt32), + ("ModelName", FieldType::String), + ("SizeClass", FieldType::UInt32), + ("ModelScale", FieldType::Float32), + ("BloodID", FieldType::UInt32), + ("FootprintTextureID", FieldType::UInt32), + ("FootprintTextureLength", FieldType::Float32), + ("FootprintTextureWidth", FieldType::Float32), + ("FootprintParticleScale", FieldType::Float32), + ("FoleyMaterialID", FieldType::UInt32), + ("FootstepShakeSize", FieldType::UInt32), + ("DeathThudShakeSize", FieldType::UInt32), + ("SoundID", FieldType::UInt32), + ("CollisionWidth", FieldType::Float32), + ("CollisionHeight", FieldType::Float32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +/// CreatureDisplayInfo.dbc — 12 fields in build 5875. We read ID, ModelID, scale, 3 skin textures. +fn creature_display_info_schema() -> Schema { + let mut s = Schema::new("CreatureDisplayInfo"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("ModelID", FieldType::UInt32), + ("SoundID", FieldType::UInt32), + ("ExtendedDisplayInfoID", FieldType::UInt32), + ("CreatureModelScale", FieldType::Float32), + ("CreatureModelAlpha", FieldType::UInt32), + ("TextureVariation0", FieldType::String), + ("TextureVariation1", FieldType::String), + ("TextureVariation2", FieldType::String), + ("PortraitTextureName", FieldType::String), + ("BloodLevel", FieldType::UInt32), + // Labeled BloodID in some third-party maps, but the 5875 values (33..188, dense) are the + // NPC sound-kit range, not blood ids — the blood override is BloodLevel above. + ("NPCSoundID", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + s +} + +/// CreatureDisplayInfoExtra.dbc — 19 fields in build 5875 (`19 × 4 == 76`-byte records; field map +/// cross-checked with vmangos `DBCStructure.h`). We read the appearance selectors, the 10 equipment +/// columns (8..17 — `ItemDisplayInfo` display ids for the worn armor geosets + helm/shoulder attach), +/// and the bake name. +fn creature_display_info_extra_schema() -> Schema { + let mut s = Schema::new("CreatureDisplayInfoExtra"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("Race", FieldType::UInt32), + ("Sex", FieldType::UInt32), + ("SkinColor", FieldType::UInt32), + ("FaceType", FieldType::UInt32), + ("HairStyle", FieldType::UInt32), + ("HairColor", FieldType::UInt32), + ("FacialHair", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + for i in 0..10 { + s.add_field(SchemaField::new(format!("Equipment{i}"), FieldType::UInt32)); + } + s.add_field(SchemaField::new("BakeName", FieldType::String)); + s +} + +/// Load the creature DBCs from the patch chain into a [`CreatureCatalog`]. +pub fn load_creature_catalog(chain: &mut Chain) -> Result { + let models = { + let bytes = chain + .read_file(CREATURE_MODEL_DATA) + .with_context(|| format!("reading {CREATURE_MODEL_DATA}"))?; + let rs = parse(&bytes, creature_model_data_schema(), "CreatureModelData")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 2)) { + // BloodID (field 5) reads signed: −1 marks a bloodless model in the real data. + let blood = u32_at(r, 5).map_or(0, |v| v as i32); + m.insert(id, (name, f32_at(r, 4).unwrap_or(1.0), blood)); + } + } + m + }; + + let display = { + let bytes = chain + .read_file(CREATURE_DISPLAY_INFO) + .with_context(|| format!("reading {CREATURE_DISPLAY_INFO}"))?; + let rs = parse( + &bytes, + creature_display_info_schema(), + "CreatureDisplayInfo", + )?; + let mut d = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(model_id)) = (u32_at(r, 0), u32_at(r, 1)) { + d.insert( + id, + DisplayRow { + model_id, + extended_id: u32_at(r, 3).unwrap_or(0), + scale: f32_at(r, 4).unwrap_or(1.0), + textures: [str_at(&rs, r, 6), str_at(&rs, r, 7), str_at(&rs, r, 8)], + blood_level: u32_at(r, 10).unwrap_or(0), + }, + ); + } + } + d + }; + + // CreatureDisplayInfoExtra — the character-model NPC appearance table. Best-effort: a plain beast + // catalog is still useful without it (only humanoid NPCs need it), so a load failure degrades to an + // empty map (humanoid NPCs stay untextured) rather than sinking the whole catalog. The caller logs + // `extra_len()` so a `0` (unexpected — it ships in patch.MPQ) is visible. + let extra = load_creature_display_info_extra(chain).unwrap_or_default(); + + Ok(CreatureCatalog { + display, + models, + extra, + }) +} + +/// Load CreatureDisplayInfoExtra.dbc → `extendedDisplayInfoId` → [`NpcAppearance`]. +fn load_creature_display_info_extra(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file(CREATURE_DISPLAY_INFO_EXTRA) + .with_context(|| format!("reading {CREATURE_DISPLAY_INFO_EXTRA}"))?; + let rs = parse( + &bytes, + creature_display_info_extra_schema(), + "CreatureDisplayInfoExtra", + )?; + let mut e = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + e.insert( + id, + NpcAppearance { + race: u32_at(r, 1).unwrap_or(0) as u8, + sex: u32_at(r, 2).unwrap_or(0) as u8, + skin: u32_at(r, 3).unwrap_or(0) as u8, + face: u32_at(r, 4).unwrap_or(0) as u8, + hair_style: u32_at(r, 5).unwrap_or(0) as u8, + hair_color: u32_at(r, 6).unwrap_or(0) as u8, + facial_hair: u32_at(r, 7).unwrap_or(0) as u8, + // fields 8..17 — the ten worn-equipment ItemDisplayInfo display ids, bodyslot-indexed + // (0 head · 1 shoulder · 2 shirt … 9 tabard); `0` = the slot is empty. + equipment: std::array::from_fn(|i| u32_at(r, 8 + i).unwrap_or(0)), + // field 18 — the baked body-atlas name. + bake_name: str_at(&rs, r, 18), + }, + ); + } + } + Ok(e) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end on the **real** build-5875 DBCs: the `ExtendedDisplayInfoID` chain resolves + /// character-model NPCs (guards/townsfolk) to a CreatureDisplayInfoExtra appearance whose body is a + /// `Character\` M2 skinned by a pre-baked atlas that actually ships under `Textures\BakedNpcTextures\`. + /// Guards the extra schema (a shifted column would misread the bake name), the display→extra join, and + /// the baked-texture-path convention. Skips when the client data isn't present. + #[test] + fn character_model_npcs_resolve_a_shipped_baked_atlas() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_creature_catalog(&mut chain).expect("load creature catalog"); + assert!( + cat.extra_len() > 1000, + "CreatureDisplayInfoExtra loaded ({} rows)", + cat.extra_len() + ); + + // Every display that resolves both an appearance and a bake name is definitively a character + // model — confirm the body path + that its baked atlas is a real, readable file. + let mut verified = 0; + for (&disp, row) in &cat.display { + if row.extended_id == 0 { + continue; + } + let Some(m) = cat.model(disp) else { continue }; + let Some(bake) = m.npc_appearance.as_ref().and_then(|a| a.bake_name.as_ref()) else { + continue; + }; + assert!( + m.model_path.to_ascii_lowercase().starts_with("character\\"), + "an extended-display NPC wears a Character\\ body, got {}", + m.model_path + ); + let path = format!("Textures\\BakedNpcTextures\\{bake}"); + assert!( + chain.read_file(&path).is_ok(), + "the baked body atlas ships: {path}" + ); + verified += 1; + if verified >= 20 { + break; + } + } + assert!( + verified >= 5, + "found + verified several baked character-model NPCs (got {verified})" + ); + } + + /// The worn-equipment columns (fields 8..17) decode in bodyslot order, anchored on live + /// server-truth: the **Stormwind City Guard** (display 3167) carries a plate helm (slot 0), a + /// pauldron pair (slot 1), and boot/glove/tabard geosets (slots 6/8/9), with empty chest/wrist + /// (slots 3/7). These ids are the real `CreatureDisplayInfoExtra` values read off the build-5875 + /// DBC; a shifted column or a wrong field offset would misread them. Skips without the client data. + #[test] + fn stormwind_guard_equipment_columns_decode_in_bodyslot_order() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_creature_catalog(&mut chain).expect("load creature catalog"); + let guard = cat + .model(3167) + .expect("Stormwind City Guard display 3167 resolves"); + let npc = guard + .npc_appearance + .expect("display 3167 is a character-model NPC with an appearance row"); + // 0 head · 1 shoulder · 2 shirt · 3 chest · 4 belt · 5 pants · 6 boots · 7 wrist · 8 gloves · 9 tabard. + assert_eq!( + npc.equipment, + [14964, 7541, 7223, 0, 7224, 7225, 7255, 0, 7698, 6255], + "SW Guard worn-equipment ids in bodyslot order" + ); + } +} diff --git a/crates/benilla-formats/src/dbc.rs b/crates/benilla-formats/src/dbc.rs new file mode 100644 index 00000000..d3fffe64 --- /dev/null +++ b/crates/benilla-formats/src/dbc.rs @@ -0,0 +1,85 @@ +//! Small shared helpers for our typed DBC catalog loaders (creatures, game objects). +//! +//! DBC files carry no column types, so each loader supplies a [`Schema`] (field count must match the +//! file header) and reads fields by index. Layouts are verified clean-room against build 5875 (the +//! file header's `fieldCount × 4 == recordSize`, cross-checked with wowdev.wiki). + +use std::collections::HashMap; +use std::io::Cursor; + +use anyhow::{anyhow, Context, Result}; +use benilla_dbc::{DbcParser, FieldType, Record, RecordSet, Schema, SchemaField, StringRef, Value}; + +use crate::Chain; + +/// Parse a DBC's bytes with `schema` into an owned record set (`what` names it for error messages). +pub(crate) fn parse(bytes: &[u8], schema: Schema, what: &str) -> Result { + let parser = DbcParser::parse(&mut Cursor::new(bytes)) + .map_err(|e| anyhow!("parsing {what} header: {e}"))?; + let parser = parser + .with_schema(schema) + .map_err(|e| anyhow!("applying {what} schema (field-count mismatch?): {e}"))?; + parser + .parse_records() + .map_err(|e| anyhow!("parsing {what} records: {e}")) +} + +/// An unsigned field (accepts the int variants). +pub(crate) fn u32_at(r: &Record, i: usize) -> Option { + match r.get_value(i)? { + Value::UInt32(v) => Some(*v), + Value::Int32(v) => Some(*v as u32), + _ => None, + } +} + +/// A float field. +pub(crate) fn f32_at(r: &Record, i: usize) -> Option { + match r.get_value(i)? { + Value::Float32(v) => Some(*v), + _ => None, + } +} + +/// A signed field (accepts either int variant, mirroring [`u32_at`]'s leniency: the schema tag +/// just picks how the parser decodes the same 4 raw bytes, so a column read as `UInt32` still +/// round-trips through here bit-for-bit). +pub(crate) fn i32_at(r: &Record, i: usize) -> Option { + match r.get_value(i)? { + Value::Int32(v) => Some(*v), + Value::UInt32(v) => Some(*v as i32), + _ => None, + } +} + +/// A non-empty string field, resolved through the record set's string block. +pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option { + match r.get_value(i)? { + Value::StringRef(StringRef(off)) => { + let s = rs.get_string(StringRef(*off)).ok()?; + (!s.is_empty()).then(|| s.to_string()) + } + _ => None, + } +} + +/// `SpellIcon.dbc`: id → texture path (`Interface\Icons\…`, extensionless). 1033 records × 2 +/// fields (`ID(0)`, `TextureFilename(1, str)`) — verified against build 5875 (`spells.rs`'s own +/// module doc). Shared by [`crate::spells`] (the action-bar catalog) and [`crate::skill_lines`] +/// (a skill line's own tab icon) — one load, not two, since both just want id → path. +pub(crate) fn load_spell_icon_map(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file("DBFilesClient\\SpellIcon.dbc") + .context("reading SpellIcon.dbc")?; + let mut schema = Schema::new("SpellIcon"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("TextureFilename", FieldType::String)); + let set = parse(&bytes, schema, "SpellIcon.dbc")?; + let mut icons = HashMap::new(); + for r in set.records() { + if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&set, r, 1)) { + icons.insert(id, path); + } + } + Ok(icons) +} diff --git a/crates/benilla-formats/src/durability.rs b/crates/benilla-formats/src/durability.rs new file mode 100644 index 00000000..f7e2d82c --- /dev/null +++ b/crates/benilla-formats/src/durability.rs @@ -0,0 +1,185 @@ +//! `DurabilityCosts.dbc` + `DurabilityQualities.dbc` — the client-side repair-cost tables. +//! +//! The 5875 client computes the *displayed* repair price itself (`0x5da330`, wrapped by +//! `0x4faf30` — §5-verified in wow-re `system/ui/scratch/repair-machinery.md`): +//! +//! ```text +//! perItem = round_nearest_even( max(1, round_half_away( +//! (maxDurability − durability) +//! · DurabilityQualities[2·Quality + 1].mult (float) +//! · DurabilityCosts[ItemLevel].column (int) )) +//! · (1.0 − vendorReputationDiscount) ) +//! ``` +//! +//! The `DurabilityCosts` column is picked by item class/subclass: WEAPON (class 2) reads the 21 +//! weapon columns (fields 1–21, byte `0x04 + subclass·4`), ARMOR (class 4) the 8 armor columns +//! (fields 22–29, byte `0x58 + subclass·4`). Anything else costs nothing (never has durability). +//! +//! ⚠ The client's `2·Quality + 1` quality-row key differs from vmangos's `(Quality+1)·2` — the +//! server may *charge* a different amount than the client *displays* (flagged INFERRED-boundary +//! in the wow-re note; a live A/B at repair bring-up settles whether the DBC rows make them +//! equivalent). We implement the client's verified key — benilla displays what 5875 displays. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{f32_at, parse, u32_at}; + +const DURABILITY_COSTS: &str = "DBFilesClient\\DurabilityCosts.dbc"; +const DURABILITY_QUALITIES: &str = "DBFilesClient\\DurabilityQuality.dbc"; + +/// The two repair tables, id-keyed. +pub struct DurabilityTables { + /// Row id (= item level) → the 29 per-point cost columns (21 weapon, then 8 armor). + costs: HashMap>, + /// Row id → the quality multiplier (the client keys this with `2·Quality + 1`). + qualities: HashMap, +} + +impl DurabilityTables { + /// The per-point integer cost column for an item's class/subclass at `item_level`, or `None` + /// when the item can't be repaired (not a weapon/armor, subclass out of the column range, or + /// the level row is missing). + fn column(&self, item_level: u32, class: u32, subclass: u32) -> Option { + let row = self.costs.get(&item_level)?; + let idx = match class { + 2 if subclass < 21 => subclass as usize, + 4 if subclass < 8 => 21 + subclass as usize, + _ => return None, + }; + row.get(idx).copied() + } + + /// The displayed repair cost for one item, in copper — the client's exact arithmetic (see the + /// module doc). `points_lost` = max − current durability; 0 lost (or an unrepairable item) + /// costs 0. `discount` is the vendor reputation discount (0.0 until benilla models + /// reputation), applied with the client's final round-to-nearest-even. + pub fn repair_cost( + &self, + points_lost: u32, + item_level: u32, + quality: u32, + class: u32, + subclass: u32, + discount: f32, + ) -> u32 { + if points_lost == 0 { + return 0; + } + let Some(col) = self.column(item_level, class, subclass) else { + return 0; + }; + let Some(&mult) = self.qualities.get(&(2 * quality + 1)) else { + return 0; + }; + let base = f64::from(points_lost) * f64::from(mult) * f64::from(col); + // round_half_away for a positive value = floor(x + 0.5) (the client's ±0.5 + _ftol), + // then the max(1, …) floor. + let per = ((base + 0.5).floor() as i64).max(1); + // The reputation-discount multiply ends in round-to-nearest-even. + let discounted = per as f64 * f64::from(1.0 - discount); + let f = discounted.floor(); + let frac = discounted - f; + // Exact .5 goes to the even neighbour; anything else to the nearer. + let up = frac > 0.5 || (frac == 0.5 && (f as i64) % 2 != 0); + let rounded = if up { f + 1.0 } else { f }; + rounded.max(0.0) as u32 + } +} + +fn costs_schema() -> Schema { + let mut s = Schema::new("DurabilityCosts"); + s.add_field(SchemaField::new("id", FieldType::UInt32)); + s.add_field(SchemaField::new_array("weapon", FieldType::UInt32, 21)); + s.add_field(SchemaField::new_array("armor", FieldType::UInt32, 8)); + s.set_key_field("id"); + s +} + +fn qualities_schema() -> Schema { + let mut s = Schema::new("DurabilityQuality"); + s.add_field(SchemaField::new("id", FieldType::UInt32)); + s.add_field(SchemaField::new("mult", FieldType::Float32)); + s.set_key_field("id"); + s +} + +/// Load both durability tables off the patch chain. +pub fn load_durability_tables(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(DURABILITY_COSTS) + .with_context(|| format!("reading {DURABILITY_COSTS}"))?; + let rs = parse(&bytes, costs_schema(), "DurabilityCosts")?; + let mut costs = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let row: Vec = (1..30).map(|i| u32_at(r, i).unwrap_or(0)).collect(); + costs.insert(id, row); + } + + let bytes = chain + .read_file(DURABILITY_QUALITIES) + .with_context(|| format!("reading {DURABILITY_QUALITIES}"))?; + let rs = parse(&bytes, qualities_schema(), "DurabilityQuality")?; + let mut qualities = HashMap::new(); + for r in rs.records() { + let (Some(id), Some(mult)) = (u32_at(r, 0), f32_at(r, 1)) else { + continue; + }; + qualities.insert(id, mult); + } + + Ok(DurabilityTables { costs, qualities }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tables() -> DurabilityTables { + let mut costs = HashMap::new(); + // Item level 23: weapon subclass 7 (sword) costs 4/point, armor subclass 3 (mail) 2/point. + let mut row = vec![0u32; 29]; + row[7] = 4; + row[21 + 3] = 2; + costs.insert(23, row); + let mut qualities = HashMap::new(); + qualities.insert(3, 0.6f32); // the client's row for quality 1 (2·1+1) + DurabilityTables { costs, qualities } + } + + #[test] + fn per_item_cost_is_the_clients_arithmetic() { + let t = tables(); + // 10 points lost on a common (q1) sword at level 23: 10 · 0.6 · 4 = 24. + assert_eq!(t.repair_cost(10, 23, 1, 2, 7, 0.0), 24); + // Mail gloves, 7 points: 7 · 0.6 · 2 = 8.4 → round-half-away → 8. + assert_eq!(t.repair_cost(7, 23, 1, 4, 3, 0.0), 8); + // The max(1, …) floor: 1 point · 0.6 · … → 2.4 rounds to 2; a fractional sub-1 base + // still charges 1 (1 point on the 2/point mail with a tiny mult). + let mut t2 = tables(); + t2.qualities.insert(3, 0.1); + assert_eq!(t2.repair_cost(1, 23, 1, 4, 3, 0.0), 1, "min 1 copper"); + // Undamaged or unrepairable: free. + assert_eq!(t.repair_cost(0, 23, 1, 2, 7, 0.0), 0); + assert_eq!(t.repair_cost(10, 23, 1, 15, 0, 0.0), 0, "not weapon/armor"); + assert_eq!(t.repair_cost(10, 99, 1, 2, 7, 0.0), 0, "no level row"); + } + + #[test] + fn discount_rounds_to_nearest_even() { + let mut t = tables(); + t.qualities.insert(3, 1.0); + let mut row = vec![0u32; 29]; + row[7] = 1; // 1 copper/point so points == the pre-discount price + t.costs.insert(23, row); + // A 50% discount (exactly representable): 41 → 20.5, the tie rounds to EVEN 20; + // 43 → 21.5 → 22; 42 → 21.0, no tie. + assert_eq!(t.repair_cost(41, 23, 1, 2, 7, 0.5), 20); + assert_eq!(t.repair_cost(43, 23, 1, 2, 7, 0.5), 22); + assert_eq!(t.repair_cost(42, 23, 1, 2, 7, 0.5), 21); + } +} diff --git a/crates/benilla-formats/src/elevators.rs b/crates/benilla-formats/src/elevators.rs new file mode 100644 index 00000000..95ab7e13 --- /dev/null +++ b/crates/benilla-formats/src/elevators.rs @@ -0,0 +1,278 @@ +//! `TransportAnimation.dbc` — the type-11 TRANSPORT (elevator/lift/tram) keyframe paths, and the +//! client's own cycle evaluator. Pure math, no Bevy, **WoW coordinates throughout**. +//! +//! A type-11 GameObject never streams a position: the server sends one anchor (the create +//! movement block's `UPDATE_FLAG_TRANSPORT` u32, its `time-since-GO-create % period` clock) and +//! the client animates the car itself — `target = (anchor + local_elapsed) % period`, bracket the +//! two keyframes around `target`, lerp their **local offsets**, rotate the offset by the spawn's +//! `GAMEOBJECT_ROTATION` quaternion, and add the stationary spawn position. The mechanism is +//! byte-verified in `wow-5875-re` (`gameobject_path_eval` `0x5f6280` + the type-11 tick +//! `0x5f5f10`, `system/object-layer/object-layer.md` §RF-0051 / `scratch/w2c1.md` §Q2-Q3; +//! transcribed bit-exact in `crates/object-layer/src/gameobject.rs`); vmangos's +//! `ElevatorTransport::Update` (`Transport.cpp:396-437`) is the same math on the server side, +//! which is what keeps the two in sync. This module implements the *mechanism* idiomatically +//! (f32, binary-search bracket); the x87 spill-pattern fidelity lives in wow-re. +//! +//! **7 fields:** `ID(0), TransportID(1), TimeIndex(2), PosX(3), PosY(4), PosZ(5), SequenceID(6)` +//! — `TransportID` is the **gameobject_template entry** (not a display or path id), `TimeIndex` +//! is cumulative ms, and the last frame's `TimeIndex` IS the cycle period (`0x5f6280` reads +//! `segments[count-1].time` as its modulus; vmangos `TotalTime` likewise). `SequenceID` names the +//! car M2's animation per span (162 moving / 164 stationary on the live rows) — not consumed +//! here (deferred polish; the car still renders its idle). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; +use crate::Chain; + +const TRANSPORT_ANIMATION: &str = "DBFilesClient\\TransportAnimation.dbc"; + +/// One `TransportAnimation.dbc` row — a keyframe on a type-11 transport's authored local path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ElevatorKeyframe { + /// Cumulative cycle time, ms. The path's last frame's `time_ms` is the cycle period. + pub time_ms: u32, + /// Local offset from the spawn point (WoW axes), **before** the spawn-rotation is applied. + pub pos: [f32; 3], +} + +/// `TransportAnimation.dbc` grouped by `TransportID` (= `gameobject_template` entry), each +/// path's frames sorted by `TimeIndex`. +pub struct ElevatorPaths { + by_entry: HashMap>, +} + +impl ElevatorPaths { + /// A template entry's keyframes in time order, or `None` for a GO with no authored path. + /// A returned slice is never empty and always starts at `time_ms == 0` (load guarantees). + pub fn entry(&self, template_entry: u32) -> Option<&[ElevatorKeyframe]> { + self.by_entry.get(&template_entry).map(Vec::as_slice) + } + + /// Number of distinct transport entries carrying a path. + pub fn len(&self) -> usize { + self.by_entry.len() + } + + pub fn is_empty(&self) -> bool { + self.by_entry.is_empty() + } +} + +/// 7 fields per the module doc. +fn schema() -> Schema { + let mut s = Schema::new("TransportAnimation"); + for name in ["ID", "TransportID", "TimeIndex"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for name in ["PosX", "PosY", "PosZ"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + s.add_field(SchemaField::new("SequenceID", FieldType::UInt32)); + s +} + +/// Read `TransportAnimation.dbc` off the patch chain into an [`ElevatorPaths`]. Paths that could +/// not drive a cycle (fewer than 2 frames, a zero period, or a first frame not at `t=0`) are +/// dropped with the same silence the client affords them — a GO whose entry isn't here simply +/// renders frozen at its spawn point. +pub fn load_elevator_paths(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(TRANSPORT_ANIMATION) + .context("reading TransportAnimation.dbc")?; + let rs = parse(&bytes, schema(), "TransportAnimation")?; + let mut by_entry: HashMap> = HashMap::new(); + for r in rs.records() { + let Some(entry) = u32_at(r, 1) else { continue }; + let Some(time_ms) = u32_at(r, 2) else { + continue; + }; + let (Some(x), Some(y), Some(z)) = (f32_at(r, 3), f32_at(r, 4), f32_at(r, 5)) else { + continue; + }; + by_entry.entry(entry).or_default().push(ElevatorKeyframe { + time_ms, + pos: [x, y, z], + }); + } + for frames in by_entry.values_mut() { + frames.sort_by_key(|f| f.time_ms); + } + by_entry.retain(|_, f| f.len() >= 2 && f[0].time_ms == 0 && f[f.len() - 1].time_ms > 0); + Ok(ElevatorPaths { by_entry }) +} + +/// The path's cycle period, ms — the last keyframe's cumulative time (`0x5f6280`'s modulus). +pub fn elevator_period_ms(frames: &[ElevatorKeyframe]) -> u32 { + frames.last().map_or(1, |f| f.time_ms).max(1) +} + +/// The client's type-11 cycle evaluator (`gameobject_path_eval` `0x5f6280`, mechanism form): +/// world position of the car at `cycle_ms ∈ [0, period)`, given the spawn's stationary position +/// and its `GAMEOBJECT_ROTATION` quaternion `(x, y, z, w)`. Also reports whether the bracketing +/// span is in motion (the dock/depart edge for consumers' instruments). +/// +/// The rotation is a plain quaternion rotation of the local offset — the binary builds the +/// standard 3×3 of `q` row-major and combines transposed, which IS `R(q)·d` (wow-re +/// `gameobject.rs:106-137`). vmangos reproduces it as `(d * q)` + a y sign flip +/// (`Transport.cpp:426-428` "magical sign flip but it works"); the two agree on every live 1.12 +/// row with `x = y = 0` offsets and on pure-yaw spawn quats — the tests pin one worked case. +pub fn elevator_sample( + frames: &[ElevatorKeyframe], + spawn_pos: [f32; 3], + spawn_quat: [f32; 4], + cycle_ms: u32, +) -> ([f32; 3], bool) { + debug_assert!(frames.len() >= 2 && frames[0].time_ms == 0); + let period = elevator_period_ms(frames); + let target = cycle_ms % period; + + // Bracket: the last frame with `time_ms <= target` and its successor. `partition_point` on + // the sorted times replaces the binary's ring-cursor scan (same bracket, stateless). + let hi = frames.partition_point(|f| f.time_ms <= target); + let (prev, next) = (frames[hi - 1], frames[hi.min(frames.len() - 1)]); + + let local = if prev.pos == next.pos || next.time_ms == prev.time_ms { + prev.pos + } else { + let frac = (target - prev.time_ms) as f32 / (next.time_ms - prev.time_ms) as f32; + [ + prev.pos[0] + (next.pos[0] - prev.pos[0]) * frac, + prev.pos[1] + (next.pos[1] - prev.pos[1]) * frac, + prev.pos[2] + (next.pos[2] - prev.pos[2]) * frac, + ] + }; + + let rotated = rotate_by_quat(local, spawn_quat); + ( + [ + spawn_pos[0] + rotated[0], + spawn_pos[1] + rotated[1], + spawn_pos[2] + rotated[2], + ], + prev.pos != next.pos, + ) +} + +/// `R(q)·v` — rotate a vector by a unit quaternion `(x, y, z, w)`. +fn rotate_by_quat(v: [f32; 3], q: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = q; + // t = 2·(q.xyz × v); v' = v + w·t + q.xyz × t + let t = [ + 2.0 * (y * v[2] - z * v[1]), + 2.0 * (z * v[0] - x * v[2]), + 2.0 * (x * v[1] - y * v[0]), + ]; + [ + v[0] + w * t[0] + (y * t[2] - z * t[1]), + v[1] + w * t[1] + (z * t[0] - x * t[2]), + v[2] + w * t[2] + (x * t[1] - y * t[0]), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frames() -> Vec { + vec![ + ElevatorKeyframe { + time_ms: 0, + pos: [0.0, 0.0, 0.0], + }, + ElevatorKeyframe { + time_ms: 5000, + pos: [0.0, 0.0, 0.0], + }, + ElevatorKeyframe { + time_ms: 10000, + pos: [0.0, 0.0, -10.0], + }, + ElevatorKeyframe { + time_ms: 30000, + pos: [0.0, 0.0, 0.0], + }, + ] + } + + const IDENT: [f32; 4] = [0.0, 0.0, 0.0, 1.0]; + + #[test] + fn dwell_then_lerp_then_wrap() { + let f = frames(); + let base = [100.0, 200.0, 50.0]; + // Dwell window: both bracketing frames at the same offset -> parked, not moving. + let (p, moving) = elevator_sample(&f, base, IDENT, 2500); + assert_eq!(p, base); + assert!(!moving); + // Mid-descent: t=7500 is halfway through the 5000->10000 span -> z offset -5. + let (p, moving) = elevator_sample(&f, base, IDENT, 7500); + assert_eq!(p, [100.0, 200.0, 45.0]); + assert!(moving); + // The cycle wraps at period (30000): cycle 30000 IS cycle 0. + let (p0, _) = elevator_sample(&f, base, IDENT, 0); + let (pw, _) = elevator_sample(&f, base, IDENT, 30000); + assert_eq!(p0, pw); + // Just before wrap: nearly back at the spawn offset. + let (p, moving) = elevator_sample(&f, base, IDENT, 29999); + assert!(moving); + assert!((p[2] - 50.0).abs() < 0.01, "z = {}", p[2]); + } + + /// One worked rotation case: a pure-yaw spawn quat (the only kind on live 1.12 spawn rows) + /// turning a lateral offset. q = 90° about +Z: (x, y) -> (-y, x). + #[test] + fn spawn_yaw_rotates_the_local_offset() { + let half = std::f32::consts::FRAC_PI_4; // 90°/2 + let q = [0.0, 0.0, half.sin(), half.cos()]; + let f = vec![ + ElevatorKeyframe { + time_ms: 0, + pos: [10.0, 0.0, 0.0], + }, + ElevatorKeyframe { + time_ms: 1000, + pos: [10.0, 0.0, 0.0], + }, + ]; + let (p, _) = elevator_sample(&f, [0.0; 3], q, 500); + assert!( + (p[0] - 0.0).abs() < 1e-4 && (p[1] - 10.0).abs() < 1e-4, + "{p:?}" + ); + } + + /// The real 5875 table: the Mesa Elevator pair + the Undercity elevator prove layout and the + /// load guarantees (sorted, t0 = 0, period = last frame). Skips without client data. + #[test] + fn real_transport_animation_layout_sanity() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.exists() { + eprintln!("skipping: no client data at {}", data.display()); + return; + } + let mut chain = Chain::open(&data).expect("open patch chain"); + let paths = load_elevator_paths(&mut chain).expect("load TransportAnimation.dbc"); + // The two Thunder Bluff Mesa Elevator cars (gameobject_template 4170/4171) and the + // Undercity elevator (152614) — verified against the extracted table this session. + let top = paths.entry(4170).expect("Mesa Elevator 4170"); + assert_eq!(elevator_period_ms(top), 30033); + assert_eq!(top[0].time_ms, 0); + assert_eq!(top[0].pos, [0.0, 0.0, 0.0]); + assert!(top.windows(2).all(|w| w[0].time_ms <= w[1].time_ms)); + // The car travels on z only — x/y are authoring noise at ~2e-6 yd (the rotation + // question is moot for pure-vertical paths). Full descent: 61.24 yd. + assert!(top + .iter() + .all(|f| f.pos[0].abs() < 1e-3 && f.pos[1].abs() < 1e-3)); + assert!((top.iter().map(|f| f.pos[2]).fold(0.0f32, f32::min) + 61.244).abs() < 1e-3); + let bottom = paths.entry(4171).expect("Mesa Elevator 4171"); + assert_eq!(elevator_period_ms(bottom), 30000); + assert!(paths.entry(152614).is_some(), "Undercity elevator"); + // A no-path entry answers None (999999 is no template). + assert!(paths.entry(999_999).is_none()); + } +} diff --git a/crates/benilla-formats/src/emotes.rs b/crates/benilla-formats/src/emotes.rs new file mode 100644 index 00000000..9e0ec53c --- /dev/null +++ b/crates/benilla-formats/src/emotes.rs @@ -0,0 +1,247 @@ +//! Emote audio data (decision 0070 slice 4): **EmotesText** (the `/wave` command table) joined +//! to **EmotesTextSound** (the race/sex voice kits) and **Emotes** (anim emotes + their +//! `EventSoundID`). +//! +//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-07-02): +//! - `EmotesText` **169 × 19 × 76 B**: `ID, Name(str, e.g. "WAVE"), EmoteID (→Emotes), +//! EmoteText[16]`. Spot-check: WAVE = id 101, emote 3. +//! - `EmotesTextSound` **418 × 5 × 20 B**: `ID, EmotesTextID, RaceID, SexID (0 male / 1 female), +//! SoundID (→SoundEntries)`. +//! - `Emotes` **78 × 7 × 28 B**: `ID, SlashCommand(str), AnimID, EmoteFlags, EmoteSpecProc, +//! EmoteSpecProcParam, EventSoundID (→SoundEntries)`. Spot-check row 2: ONESHOT_BOW, anim 66 +//! (VERIFIED against vmangos `SharedDefines.h`'s `Emote` enum: `EMOTE_ONESHOT_BOW = 2`). +//! +//! `AnimID` (column 2) feeds both the one-shot anim-emote path (`SMSG_EMOTE`'s `Emotes.dbc` id) and +//! the looping state-emote idle (`UNIT_NPC_EMOTESTATE`, the same id space) — [`EmoteSoundCatalog::anim`]. +//! +//! `EmoteFlags` (column 3) feeds the **send-side posture-eligibility gate** — the only `EmoteFlags` +//! consumer, byte-verified at the real client's `CheckEmoteEligible` (`0x47db40`, called from +//! `DoEmote` `0x5ef560`): before `CMSG_TEXT_EMOTE` goes out, bit `0x0001` combined with a non-zero +//! stand-state aborts the *entire* emote (no packet, no anim — a seated `/bow` does nothing). +//! `wow-5875-re`'s `system/object-layer/scratch/emote-posture-gate.md` (commit `f9584b45`) is the +//! authority; [`EmoteSoundCatalog::emote_flags`] promotes the raw bits for `crate::chat`'s gate. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +/// The joined emote-audio tables. +pub struct EmoteSoundCatalog { + /// Uppercased `EmotesText.Name` ("WAVE") → text-emote id — the `/command` send key. + by_name: HashMap, + /// text-emote id → `Emotes.dbc` id (the anim the emote plays; 0 = chat-only). + text_emote: HashMap, + /// `(text-emote id, race, sex)` → voice kit. + voice: HashMap<(u32, u32, u32), u32>, + /// `Emotes.dbc` id → its `EventSoundID` kit (0 = none). + event_sound: HashMap, + /// `Emotes.dbc` id → its `AnimID` (`AnimationData.dbc` id; 0 = none). + anim: HashMap, + /// `Emotes.dbc` id → its `EmoteFlags` (the send-side posture-eligibility gate bits). + emote_flags: HashMap, + /// `Emotes.dbc` id → its `EmoteSpecProc` (column 4). `2` marks a looping STATE emote whose + /// `EventSoundID` the `$ESD` anim event rings (the client's `row[+0x10] == 2` gate at the + /// `$ESD` handler `0x6239f0` — wow-re `sound/scratch/gather-sound-anim-events.md`). + spec_proc: HashMap, +} + +impl EmoteSoundCatalog { + /// Resolve a `/command` name (case-insensitive) to its text-emote id. + pub fn text_id(&self, name: &str) -> Option { + self.by_name.get(&name.to_ascii_uppercase()).copied() + } + + /// The voice kit for a performer of the given race/sex (`sex`: 0 male, 1 female). + pub fn voice(&self, text_id: u32, race: u32, sex: u32) -> Option { + self.voice.get(&(text_id, race, sex)).copied() + } + + /// The anim emote a text emote plays (0/none for chat-only emotes). + pub fn text_emote(&self, text_id: u32) -> Option { + self.text_emote.get(&text_id).copied().filter(|&e| e != 0) + } + + /// An anim emote's event-sound kit. + pub fn event_sound(&self, emote_id: u32) -> Option { + self.event_sound.get(&emote_id).copied().filter(|&k| k != 0) + } + + /// An `Emotes.dbc` id's `AnimID` (the `AnimationData.dbc` id it plays; `0`/absent = none). + /// Shared by the one-shot anim-emote path (`SMSG_EMOTE`) and the looping state-emote idle + /// (`UNIT_NPC_EMOTESTATE`) — both carry an `Emotes.dbc` id in the same id space. + pub fn anim(&self, emote_id: u32) -> Option { + self.anim.get(&emote_id).copied().filter(|&a| a != 0) + } + + /// An `Emotes.dbc` id's raw `EmoteFlags` bits (`None` when the id isn't in the catalog; `0` is a + /// real, meaningful value — "no gate bits set" — so unlike [`Self::anim`]/[`Self::event_sound`] + /// it is *not* filtered out). Feeds `benilla::chat`'s send-side posture-eligibility gate — see + /// the module doc. + pub fn emote_flags(&self, emote_id: u32) -> Option { + self.emote_flags.get(&emote_id).copied() + } + + /// An `Emotes.dbc` id's `EmoteSpecProc` (`None` when the id isn't in the catalog; `0` is a real + /// value, so not filtered). `2` = a looping state emote — the `$ESD` event-sound gate (see the + /// field doc). + pub fn spec_proc(&self, emote_id: u32) -> Option { + self.spec_proc.get(&emote_id).copied() + } + + pub fn len(&self) -> usize { + self.by_name.len() + } + + pub fn is_empty(&self) -> bool { + self.by_name.is_empty() + } +} + +fn schema(name: &str, n: usize, strings: &[usize]) -> Schema { + let mut s = Schema::new(name); + for i in 0..n { + let ty = if strings.contains(&i) { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("f{i}"), ty)); + } + s +} + +/// Read the three tables off the patch chain. +pub fn load_emote_sound_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\EmotesText.dbc") + .context("reading EmotesText.dbc")?; + let rs = parse(&bytes, schema("EmotesText", 19, &[1]), "EmotesText")?; + let mut by_name = HashMap::with_capacity(rs.records().len()); + let mut text_emote = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(name) = str_at(&rs, r, 1).filter(|n| !n.is_empty()) { + by_name.insert(name.to_ascii_uppercase(), id); + } + text_emote.insert(id, u32_at(r, 2).unwrap_or(0)); + } + + let bytes = chain + .read_file("DBFilesClient\\EmotesTextSound.dbc") + .context("reading EmotesTextSound.dbc")?; + let rs = parse(&bytes, schema("EmotesTextSound", 5, &[]), "EmotesTextSound")?; + let mut voice = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(text), Some(race), Some(sex), Some(kit)) = + (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) + else { + continue; + }; + voice.insert((text, race, sex), kit); + } + + let bytes = chain + .read_file("DBFilesClient\\Emotes.dbc") + .context("reading Emotes.dbc")?; + let rs = parse(&bytes, schema("Emotes", 7, &[1]), "Emotes")?; + let mut event_sound = HashMap::with_capacity(rs.records().len()); + let mut anim = HashMap::with_capacity(rs.records().len()); + let mut emote_flags = HashMap::with_capacity(rs.records().len()); + let mut spec_proc = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(kit) = u32_at(r, 6) { + event_sound.insert(id, kit); + } + if let Some(anim_id) = u32_at(r, 2) { + anim.insert(id, anim_id); + } + if let Some(flags) = u32_at(r, 3) { + emote_flags.insert(id, flags); + } + if let Some(proc) = u32_at(r, 4) { + spec_proc.insert(id, proc); + } + } + + Ok(EmoteSoundCatalog { + by_name, + text_emote, + voice, + event_sound, + anim, + emote_flags, + spec_proc, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The joins hold on real 5875 data: WAVE resolves by name to the byte-decoded id 101 with + /// anim emote 3; some voice row exists for a human (race 1) male; the voice map carries the + /// male/female split (the survey's sample rows pair sexes per race). + #[test] + fn real_emote_chain_resolves() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_emote_sound_catalog(&mut chain).expect("load emote catalog"); + assert_eq!(cat.text_id("wave"), Some(101), "case-insensitive by name"); + assert_eq!(cat.text_emote(101), Some(3), "WAVE plays anim emote 3"); + assert_eq!(cat.anim(2), Some(66), "ONESHOT_BOW (id 2) plays AnimID 66"); + // EmoteFlags for the director-verified posture-gate rows (emote-posture-gate.md §3), ids + // from vmangos `SharedDefines.h`'s `Emote` enum: BOW=2, CHEER=4, LAUGH=11, RUDE=14, + // APPLAUD=21, SALUTE=66. + assert_eq!(cat.emote_flags(2), Some(0x4801), "ONESHOT_BOW EmoteFlags"); + assert_eq!(cat.emote_flags(4), Some(0x0800), "ONESHOT_CHEER EmoteFlags"); + assert_eq!( + cat.emote_flags(11), + Some(0x0980), + "ONESHOT_LAUGH EmoteFlags" + ); + assert_eq!(cat.emote_flags(14), Some(0x0001), "ONESHOT_RUDE EmoteFlags"); + assert_eq!( + cat.emote_flags(21), + Some(0x0000), + "ONESHOT_APPLAUD EmoteFlags" + ); + assert_eq!( + cat.emote_flags(66), + Some(0x0800), + "ONESHOT_SALUTE EmoteFlags" + ); + // The $ESD gathering chain (decision 0562): STATE_WORK_NOSHEATHE_MINING (233) is a + // spec-proc-2 state emote carrying the MiningHit kit; its one-shot cousins carry proc 0. + assert_eq!(cat.spec_proc(233), Some(2), "mining state EmoteSpecProc"); + assert_eq!( + cat.event_sound(233), + Some(3782), + "mining state EventSoundID (SoundEntries \"Mining\")" + ); + assert_eq!(cat.anim(233), Some(136), "mining state plays anim 136"); + assert_eq!(cat.spec_proc(2), Some(0), "ONESHOT_BOW EmoteSpecProc"); + assert!( + cat.voice + .keys() + .any(|&(_, race, sex)| race == 1 && sex == 0), + "human male voice rows exist" + ); + assert!( + cat.voice.keys().any(|&(_, _, sex)| sex == 1), + "female voice rows exist" + ); + } +} diff --git a/crates/benilla-formats/src/environmental_damage.rs b/crates/benilla-formats/src/environmental_damage.rs new file mode 100644 index 00000000..bfc40513 --- /dev/null +++ b/crates/benilla-formats/src/environmental_damage.rs @@ -0,0 +1,105 @@ +//! **EnvironmentalDamage.dbc** — the environmental-damage feedback table: damage type → +//! `SpellVisualKit` id, played on the victim when `SMSG_ENVIRONMENTALDAMAGELOG` arrives (the +//! fall-landing dust puff and its five siblings). +//! +//! The byte-verified client shape (wow-re `sound/scratch/uisound-tables.md`): init `0x603900` +//! zeroes a **6-slot table** (`[0xc4d8e4]`) and, for each record with `field1 < 6`, stores +//! `slot[field1] = field2` — so field 1 is the `EnvironmentalDamageType` enum (0 exhausted · +//! 1 drowning · 2 fall · 3 lava · 4 slime · 5 fire; the wire's `damage_type`) and field 2 the +//! kit id. Sole reader `0x624fcc` (in the 0x1FC consequence method `0x624f30`) bounds the kit id +//! against `SpellVisualKit.dbc` and plays it. Build 5875 data: 0→871, 1→870, 2→**1066** (the +//! "DustCloud Land" kit), 3→1064, 4→1065, 5→1067. + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +const ENVIRONMENTAL_DAMAGE: &str = "DBFilesClient\\EnvironmentalDamage.dbc"; + +/// The 6-slot damage-type → `SpellVisualKit` table (the client's `[0xc4d8e4]`). +pub struct EnvironmentalDamageTable { + kits: [u32; 6], +} + +impl EnvironmentalDamageTable { + /// The `SpellVisualKit` id for a wire `damage_type` (0–5). `None` for an out-of-range type or + /// an empty slot — the client's `field1 < 6` init guard and zero-init respectively. + pub fn kit_id(&self, damage_type: u8) -> Option { + self.kits + .get(usize::from(damage_type)) + .copied() + .filter(|&k| k != 0) + } +} + +/// EnvironmentalDamage.dbc — 3 fields in build 5875: ID, the damage-type enum, the kit id. +fn schema() -> Schema { + let mut s = Schema::new("EnvironmentalDamage"); + for name in ["ID", "DamageType", "VisualKit"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Load the table from the patch chain, exactly as the client's init `0x603900` fills its 6-slot +/// store: zeroed slots, `slot[DamageType] = VisualKit` for each in-range record. +pub fn load_environmental_damage(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(ENVIRONMENTAL_DAMAGE) + .with_context(|| format!("reading {ENVIRONMENTAL_DAMAGE}"))?; + table_from(&bytes) +} + +/// The fill itself, split from the chain read so the golden test drives the identical path. +fn table_from(bytes: &[u8]) -> Result { + let rs = parse(bytes, schema(), "EnvironmentalDamage")?; + let mut kits = [0u32; 6]; + for r in rs.records() { + if let (Some(ty), Some(kit)) = (u32_at(r, 1), u32_at(r, 2)) { + if let Some(slot) = kits.get_mut(ty as usize) { + *slot = kit; + } + } + } + Ok(EnvironmentalDamageTable { kits }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A hand-built WDBC with the real 5875 rows (verified via benilla-extract 2026-07-16) plus an + /// out-of-range type-9 row that must hit the `field1 < 6` init guard and load nowhere. + #[test] + fn table_loads_by_damage_type_with_the_range_guard() { + let rows: &[[u32; 3]] = &[ + [1, 0, 871], + [2, 1, 870], + [3, 2, 1066], + [4, 3, 1064], + [5, 4, 1065], + [6, 5, 1067], + [7, 9, 999], // out of range: the client's `cmp edx,6; jae` skip + ]; + let mut dbc = Vec::new(); + dbc.extend_from_slice(b"WDBC"); + dbc.extend_from_slice(&(rows.len() as u32).to_le_bytes()); + dbc.extend_from_slice(&3u32.to_le_bytes()); // fields + dbc.extend_from_slice(&12u32.to_le_bytes()); // record size + dbc.extend_from_slice(&1u32.to_le_bytes()); // string block + for row in rows { + for v in row { + dbc.extend_from_slice(&v.to_le_bytes()); + } + } + dbc.push(0); // the string block + + let table = table_from(&dbc).unwrap(); + assert_eq!(table.kit_id(2), Some(1066), "fall → the DustCloud Land kit"); + assert_eq!(table.kit_id(0), Some(871)); + assert_eq!(table.kit_id(5), Some(1067)); + assert_eq!(table.kit_id(9), None, "out-of-range type"); + } +} diff --git a/crates/benilla-formats/src/factions.rs b/crates/benilla-formats/src/factions.rs new file mode 100644 index 00000000..99bc1d6f --- /dev/null +++ b/crates/benilla-formats/src/factions.rs @@ -0,0 +1,636 @@ +//! Unit reaction: the FactionTemplate.dbc catalog + the client's faction-reaction comparator. +//! +//! A unit's `UNIT_FIELD_FACTIONTEMPLATE` indexes **FactionTemplate.dbc**; the real client decides +//! hostile/neutral/friendly by comparing two of those rows with one pure function — the reaction +//! comparator at `0x606640` (wow-5875-re object-layer, byte-verified disasm, §5-cross-checked). Every +//! `UnitReaction`/`CanAttack`/`CanAssist` predicate bottoms out in it, and it's what colours nameplates +//! and the ground selection ring (red/yellow/green). [`FactionTemplate::reaction_toward`] is that +//! function, branch-for-branch. +//! +//! Record layout verified by the offsets the comparator reads (0x38-byte rows = 14 × u32, matching +//! vmangos `FactionTemplateEntry`): id@0, faction@1 (Faction.dbc id), flags@2, factionGroupMask@3, +//! friendGroupMask@4, enemyGroupMask@5, enemies[4]@6..9, friends[4]@10..13 (both id lists +//! 0-terminated). +//! +//! Also here: the **reputation identity** side of the decode — Faction.dbc's reputation slot + +//! race/class-gated base values ([`FactionInfo`]) and the standing→rank thresholds +//! ([`reputation_rank`]). The client checks `FactionHasReputation` *before* the comparator +//! (`0x606530`): a reputation faction's NPCs colour by the player's rank, not by templates. The +//! live standings are session state (`SMSG_INITIALIZE_FACTIONS`) and live with the net layer. +//! +//! Deliberately **not** here: the rest of the real `UnitReaction 0x6061e0` orchestration — +//! same-object/group/guild shortcuts, charm resolution, PvP-flag + sanctuary overrides. Those need +//! player/session state, not DBC rows; they land with the systems that own it. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +const FACTION_TEMPLATE: &str = "DBFilesClient\\FactionTemplate.dbc"; +const FACTION: &str = "DBFilesClient\\Faction.dbc"; +const FACTION_GROUP: &str = "DBFilesClient\\FactionGroup.dbc"; + +/// One FactionTemplate.dbc row — exactly the fields the reaction comparator reads (plus `faction`, +/// which the id-list scans compare against). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FactionTemplate { + /// The row's `Faction.dbc` id — what another template's `enemies`/`friends` lists name. + pub faction: u32, + /// The faction-group bits this template belongs to (Player/Alliance/Horde/Monster). + pub group_mask: u32, + /// Faction groups this template is friendly toward. + pub friend_group_mask: u32, + /// Faction groups this template is hostile toward. + pub enemy_group_mask: u32, + /// Explicit enemy `Faction.dbc` ids, 0-terminated. + pub enemies: [u32; 4], + /// Explicit friend `Faction.dbc` ids, 0-terminated. + pub friends: [u32; 4], +} + +/// A unit's base reaction toward another, on the client's own scale (`0x606640` returns exactly +/// these three; the extended reputation path widens the scale to 0..7, hence the gaps). The +/// nameplate/ring palette thresholds on this scale: `<= 1` red, `>= 4` green, else yellow. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Reaction { + Hostile = 1, + Neutral = 3, + Friendly = 4, +} + +impl FactionTemplate { + /// The real client's faction-template reaction comparator — `self`'s reaction toward `other`. + /// + /// Branch-for-branch the byte-verified `0x606640` (wow-5875-re object-layer w2d1, §5): enemy + /// group mask, then the enemies id list, then friendship *both ways* (self's friend mask/list + /// toward other, then other's friend mask/list toward self), else neutral. Hostility is only + /// ever **self's** — and that asymmetry is load-bearing: a player template is enemy-masked + /// against the whole Monster group (why you *may attack* a yellow beast), while a starter-zone + /// kobold's template carries no enemy data at all. Colour by the **unit's** reaction toward the + /// player — the direction the nameplate resolver `0x7cbaa0` verifiably calls (`this` = the unit) + /// — or every wild beast would wrongly read hostile red. + pub fn reaction_toward(&self, other: &FactionTemplate) -> Reaction { + if self.enemy_group_mask & other.group_mask != 0 { + return Reaction::Hostile; + } + for &enemy in &self.enemies { + if enemy == 0 { + break; + } + if enemy == other.faction { + return Reaction::Hostile; + } + } + if self.friend_group_mask & other.group_mask != 0 { + return Reaction::Friendly; + } + for &friend in &self.friends { + if friend == 0 { + break; + } + if friend == other.faction { + return Reaction::Friendly; + } + } + if other.friend_group_mask & self.group_mask != 0 { + return Reaction::Friendly; + } + for &friend in &other.friends { + if friend == 0 { + break; + } + if friend == self.faction { + return Reaction::Friendly; + } + } + Reaction::Neutral + } +} + +/// One Faction.dbc row's reputation identity: the reputation-list slot plus the race/class-gated +/// base values the client adds to the wire standing. Only rows with `rep_index >= 0` participate in +/// reputation (`FactionHasReputation`, the client's `[faction+4] >= 0` check). +#[derive(Debug, Clone, Copy)] +pub struct FactionInfo { + /// `reputationIndex` — the slot into the `SMSG_INITIALIZE_FACTIONS` standings array; `-1` = this + /// faction has no reputation (reaction falls through to the template comparator). + pub rep_index: i32, + race_masks: [u32; 4], + class_masks: [u32; 4], + base: [i32; 4], + /// Per-slot reputation flags — bit `0x4` (mangos `FACTION_FLAG_HIDDEN`) on the matched slot + /// suppresses the unit tooltip's faction-name line. + flags: [u32; 4], +} + +impl FactionInfo { + /// The base reputation for a player of `race`/`class` (1-based ids): the first of the four + /// race/class-gated slots that fits (a zero mask is a wildcard) — vmangos `GetIndexFitTo`, + /// mirroring the client's own base pick. `0` if none fits. + pub fn base_for(&self, race: u8, class: u8) -> i32 { + let race_mask = 1u32 << (race.max(1) - 1); + let class_mask = 1u32 << (class.max(1) - 1); + for i in 0..4 { + if (self.race_masks[i] == 0 || self.race_masks[i] & race_mask != 0) + && (self.class_masks[i] == 0 || self.class_masks[i] & class_mask != 0) + { + return self.base[i]; + } + } + 0 + } + + /// Whether the unit tooltip shows this faction's name to a player of `race`/`class` — the + /// byte loop in the unit builder `0x529fe0`: the FIRST of the four reputation slots that + /// matches decides, and it shows the line iff its flags lack the hidden bit `0x4`. The slot + /// match differs from [`Self::base_for`]'s in one spot the bytes insist on: a zero race mask + /// substitutes the CLASS mask as the match value, so a slot with both masks zero matches + /// nothing here (while `base_for` treats it as a wildcard). No matching slot → no line. + pub fn tooltip_shows_for(&self, race: u8, class: u8) -> bool { + let race_bit = 1u32 << (race.max(1) - 1); + let class_bit = 1u32 << (class.max(1) - 1); + for i in 0..4 { + let race_match = if self.race_masks[i] == 0 { + self.class_masks[i] + } else { + self.race_masks[i] & race_bit + }; + if race_match != 0 && (self.class_masks[i] == 0 || self.class_masks[i] & class_bit != 0) + { + return self.flags[i] & 0x4 == 0; + } + } + false + } +} + +/// Reputation rank thresholds (vanilla): total standing (base + wire) → rank `0..=7` (hated, +/// hostile, unfriendly, neutral, friendly, honored, revered, exalted). The rank scale **is** the +/// client's extended reaction scale — the ring/nameplate palette thresholds apply to it directly +/// (`<= 1` red, `>= 4` green, else yellow). Rank widths from the bottom (−42000): 36000, 3000, +/// 3000, 3000, 6000, 12000, 21000, 1000 (vmangos `PointsInRank`, matching the client). +pub fn reputation_rank(total_standing: i32) -> u8 { + match total_standing { + i32::MIN..=-6001 => 0, // hated + -6000..=-3001 => 1, // hostile + -3000..=-1 => 2, // unfriendly + 0..=2999 => 3, // neutral + 3000..=8999 => 4, // friendly + 9000..=20999 => 5, // honored + 21000..=41999 => 6, // revered + 42000..=i32::MAX => 7, // exalted + } +} + +impl Reaction { + /// A reputation rank (`0..=7`, [`reputation_rank`]) collapsed to the three-way scale, the way + /// the **nameplate** palette thresholds it (`0x7cbaa0`: `<= 1` hostile, `>= 4` friendly, else + /// neutral). The selection **ring** does *not* collapse — its own palette (`0x605960`) keys the + /// raw rank and has a distinct orange at rank 2 — so ring consumers use the rank directly. + pub fn from_rank(rank: u8) -> Reaction { + match rank { + 0..=1 => Reaction::Hostile, + 2..=3 => Reaction::Neutral, + _ => Reaction::Friendly, + } + } +} + +/// FactionTemplate.dbc + Faction.dbc + FactionGroup.dbc loaded into id → row maps (the template id +/// is what a unit's `UNIT_FIELD_FACTIONTEMPLATE` carries; the faction id is the template's +/// `faction` field; the group names key by mask bit). +pub struct FactionCatalog { + templates: HashMap, + factions: HashMap, + /// `1 << FactionGroup.MaskID` → the localized group Name ("Alliance", "Horde") — what + /// `GetZonePVPInfo`'s territory line formats (`0x48d540` reads FactionGroup.dbc Name of the + /// row whose bit ∈ the zone's FactionGroupMask; wow-re ui `zonetext-pvpinfo.md`). + group_names: HashMap, + /// Faction id → the localized Name — what the item tooltip's "Requires - + /// " line prints. + names: HashMap, +} + +impl FactionCatalog { + /// The template row for a `UNIT_FIELD_FACTIONTEMPLATE` id, or `None` for an id the DBC lacks. + pub fn template(&self, id: u32) -> Option<&FactionTemplate> { + self.templates.get(&id) + } + + /// The reputation identity of a `Faction.dbc` id, `Some` only when the faction actually has a + /// reputation slot — the client's `FactionHasReputation` (`0x605fc0`, `[faction+4] >= 0`). For + /// these factions unit reaction is the **player's reputation rank**, checked *before* the + /// template comparator (`0x606530`). + pub fn reputation_faction(&self, faction_id: u32) -> Option<&FactionInfo> { + self.factions.get(&faction_id).filter(|f| f.rep_index >= 0) + } + + /// The localized name of a `Faction.dbc` id — the reputation-requirement line's faction. + pub fn faction_name(&self, faction_id: u32) -> Option<&str> { + self.names.get(&faction_id).map(String::as_str) + } + + /// Every faction that has a reputation slot, with its identity — the app builds the + /// player's faction → rank map from this (base + wire standing, ranked). + pub fn reputation_factions(&self) -> impl Iterator { + self.factions + .iter() + .filter(|(_, f)| f.rep_index >= 0) + .map(|(&id, f)| (id, f)) + } + + /// The localized FactionGroup name of the (first) group bit set in `mask` — "Alliance" for 2, + /// "Horde" for 4; `None` for an unowned mask. The `GetZonePVPInfo` territory-line lookup. + pub fn faction_group_name(&self, mask: u32) -> Option<&str> { + (0..32) + .map(|b| 1u32 << b) + .filter(|bit| mask & bit != 0) + .find_map(|bit| self.group_names.get(&bit)) + .map(String::as_str) + } + + /// Number of template rows (for logging/diagnostics). + pub fn len(&self) -> usize { + self.templates.len() + } + + /// Whether no rows loaded. + pub fn is_empty(&self) -> bool { + self.templates.is_empty() + } +} + +/// FactionTemplate.dbc — 14 fields in build 5875 (`14 × 4 == 56`-byte records, the exact offsets the +/// client comparator reads; cross-checked with vmangos `FactionTemplateEntry`). +fn faction_template_schema() -> Schema { + let mut s = Schema::new("FactionTemplate"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("Faction", FieldType::UInt32), + ("Flags", FieldType::UInt32), + ("FactionGroup", FieldType::UInt32), + ("FriendGroup", FieldType::UInt32), + ("EnemyGroup", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + for i in 0..4 { + s.add_field(SchemaField::new(format!("Enemy{i}"), FieldType::UInt32)); + } + for i in 0..4 { + s.add_field(SchemaField::new(format!("Friend{i}"), FieldType::UInt32)); + } + s +} + +/// Faction.dbc — 37 fields in build 5875 (`ID`, `reputationIndex`, 4× race masks, 4× class masks, +/// 4× base values, 4× reputation flags, `team`, then 8+1 name and 8+1 description columns; +/// cross-checked with vmangos `FactionEntry` and the file header). We read the reputation identity. +fn faction_schema() -> Schema { + let mut s = Schema::new("Faction"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("ReputationIndex", FieldType::Int32)); + for i in 0..4 { + s.add_field(SchemaField::new(format!("RaceMask{i}"), FieldType::UInt32)); + } + for i in 0..4 { + s.add_field(SchemaField::new(format!("ClassMask{i}"), FieldType::UInt32)); + } + for i in 0..4 { + s.add_field(SchemaField::new(format!("Base{i}"), FieldType::Int32)); + } + for i in 0..4 { + s.add_field(SchemaField::new(format!("RepFlags{i}"), FieldType::UInt32)); + } + s.add_field(SchemaField::new("Team", FieldType::UInt32)); + for i in 0..8 { + s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String)); + } + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + for i in 0..8 { + s.add_field(SchemaField::new(format!("Desc{i}"), FieldType::String)); + } + s.add_field(SchemaField::new("DescFlags", FieldType::UInt32)); + s +} + +/// FactionGroup.dbc — 12 fields in build 5875 (48-byte records: `ID`, `MaskID` (the bit index), +/// an internal name, then the 8+1 localized Name block; layout per the `GetZonePVPInfo` reader +/// `0x48d540` — MaskID@+0x4, Name@+0xc — wow-re ui `zonetext-pvpinfo.md`). +fn faction_group_schema() -> Schema { + let mut s = Schema::new("FactionGroup"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("MaskID", FieldType::UInt32)); + s.add_field(SchemaField::new("InternalName", FieldType::String)); + for i in 0..8 { + s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String)); + } + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + s +} + +/// Load FactionTemplate.dbc + Faction.dbc from the patch chain into a [`FactionCatalog`]. +pub fn load_faction_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(FACTION_TEMPLATE) + .with_context(|| format!("reading {FACTION_TEMPLATE}"))?; + let rs = parse(&bytes, faction_template_schema(), "FactionTemplate")?; + let mut templates = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + let at = |i| u32_at(r, i).unwrap_or(0); + templates.insert( + id, + FactionTemplate { + faction: at(1), + group_mask: at(3), + friend_group_mask: at(4), + enemy_group_mask: at(5), + enemies: [at(6), at(7), at(8), at(9)], + friends: [at(10), at(11), at(12), at(13)], + }, + ); + } + } + + let bytes = chain + .read_file(FACTION) + .with_context(|| format!("reading {FACTION}"))?; + let rs = parse(&bytes, faction_schema(), "Faction")?; + let mut factions = HashMap::with_capacity(rs.records().len()); + let mut names = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + let at = |i| u32_at(r, i).unwrap_or(0); + let iat = |i| u32_at(r, i).unwrap_or(0) as i32; + factions.insert( + id, + FactionInfo { + rep_index: iat(1), + race_masks: [at(2), at(3), at(4), at(5)], + class_masks: [at(6), at(7), at(8), at(9)], + base: [iat(10), iat(11), iat(12), iat(13)], + flags: [at(14), at(15), at(16), at(17)], + }, + ); + // Name0 (enUS) — column 19 after ID/RepIndex/4·race/4·class/4·base/4·flags/Team. + if let Some(name) = crate::dbc::str_at(&rs, r, 19) { + names.insert(id, name); + } + } + } + + let bytes = chain + .read_file(FACTION_GROUP) + .with_context(|| format!("reading {FACTION_GROUP}"))?; + let rs = parse(&bytes, faction_group_schema(), "FactionGroup")?; + let mut group_names = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(mask_id), Some(name)) = (u32_at(r, 1), crate::dbc::str_at(&rs, r, 3)) else { + continue; + }; + group_names.insert(1u32 << mask_id, name); + } + + Ok(FactionCatalog { + templates, + factions, + group_names, + names, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A template with everything zeroed except what a test sets. + fn tpl(faction: u32) -> FactionTemplate { + FactionTemplate { + faction, + group_mask: 0, + friend_group_mask: 0, + enemy_group_mask: 0, + enemies: [0; 4], + friends: [0; 4], + } + } + + /// The comparator's full branch domain, in the byte order `0x606640` tests them: enemy mask, + /// enemies list (with its 0-terminator + 4-entry bound), friend mask (A), friends list (A), + /// friend mask (B toward A's group), friends list (B naming A), neutral fall-through — and + /// enemy-beats-friend precedence. + #[test] + fn comparator_branch_domain() { + let b = FactionTemplate { + group_mask: 0b0010, + ..tpl(50) + }; + + // (A.enemyGroupMask & B.group) != 0 → hostile. + let a = FactionTemplate { + enemy_group_mask: 0b0010, + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b), Reaction::Hostile); + + // A.enemies[i] == B.faction → hostile (in the last slot: the loop scans all 4). + let a = FactionTemplate { + enemies: [7, 8, 9, 50], + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b), Reaction::Hostile); + + // The enemies list is 0-terminated: a match *after* a 0 is never reached. + let a = FactionTemplate { + enemies: [7, 0, 50, 0], + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b), Reaction::Neutral); + + // (A.friendGroupMask & B.group) != 0 → friendly. + let a = FactionTemplate { + friend_group_mask: 0b0010, + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b), Reaction::Friendly); + + // A.friends[i] == B.faction → friendly. + let a = FactionTemplate { + friends: [50, 0, 0, 0], + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b), Reaction::Friendly); + + // (B.friendGroupMask & A.group) != 0 → friendly (friendship read both ways). + let a = FactionTemplate { + group_mask: 0b1000, + ..tpl(1) + }; + let b_friendly = FactionTemplate { + friend_group_mask: 0b1000, + ..b + }; + assert_eq!(a.reaction_toward(&b_friendly), Reaction::Friendly); + + // B.friends[i] == A.faction → friendly. + let b_names_a = FactionTemplate { + friends: [1, 0, 0, 0], + ..b + }; + assert_eq!(tpl(1).reaction_toward(&b_names_a), Reaction::Friendly); + + // Nothing matches → neutral. + assert_eq!(tpl(1).reaction_toward(&b), Reaction::Neutral); + + // Hostility is tested first: an enemy-mask hit wins over any friendship evidence. + let a = FactionTemplate { + enemy_group_mask: 0b0010, + friend_group_mask: 0b0010, + friends: [50, 0, 0, 0], + ..tpl(1) + }; + assert_eq!(a.reaction_toward(&b_names_a), Reaction::Hostile); + } + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end on the **real** build-5875 DBC, in the direction the client colours by (the unit's + /// reaction toward the player — verified in the binary: the nameplate resolver `0x7cbaa0` calls + /// `unit->UnitReaction(activePlayer)`): a human player is faction template 1; Marshal Dughan (12) + /// reads friendly and the Chicken (31) neutral (the exact ring colours the wow-re apitrace + /// captured), a Defias Trapper (17) and mine Kobold Miner (26) hostile, and the starter-field + /// Kobold Vermin (25) **neutral** — the famous non-aggro yellow level-1 mob. That last one also + /// locks the direction: the *player's* template is enemy-masked against the whole Monster group, + /// so the reverse direction would wrongly read every yellow beast as hostile. Template ids are + /// the vmangos `creature_template.faction` rows. Guards the schema (a shifted column scrambles + /// every mask) and the comparator against ground truth. + #[test] + fn real_dbc_reactions_match_reference_capture() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_faction_catalog(&mut chain).expect("load faction catalog"); + assert!( + cat.len() > 200, + "FactionTemplate loaded ({} rows)", + cat.len() + ); + + let human_player = cat.template(1).expect("human player template (1)"); + let tpl = |id: u32| { + cat.template(id) + .unwrap_or_else(|| panic!("faction template {id}")) + }; + let reaction = |id: u32| tpl(id).reaction_toward(human_player); + assert_eq!( + reaction(12), + Reaction::Friendly, + "Marshal Dughan (Stormwind)" + ); + assert_eq!(reaction(31), Reaction::Neutral, "Chicken (critter)"); + assert_eq!(reaction(17), Reaction::Hostile, "Defias Trapper"); + assert_eq!( + reaction(26), + Reaction::Hostile, + "Kobold Miner (aggro mine kobold)" + ); + assert_eq!( + reaction(25), + Reaction::Neutral, + "Kobold Vermin (non-aggro starter kobold)" + ); + // The direction asymmetry itself: the player is hostile *toward* the vermin (attackable), + // while the vermin — what the ring colours by — is neutral toward the player. + assert_eq!(human_player.reaction_toward(tpl(25)), Reaction::Hostile); + + // The reputation identity: Stormwind (faction 72, both guard templates 11 + 12 point at it) + // has reputation slot 19 with a human base of 4000 — friendly rank 4 at zero wire standing, + // which is why every Stormwind NPC is green to a human **before** any template comparison + // (even in GM mode — director-verified on the reference client). A critter faction (the + // Chicken's 28) has no reputation slot → the comparator path. + let stormwind = cat + .reputation_faction(72) + .expect("Stormwind (72) has reputation"); + assert_eq!(stormwind.rep_index, 19); + assert_eq!(stormwind.base_for(1, 1), 4000, "human base with Stormwind"); + assert_eq!(reputation_rank(stormwind.base_for(1, 1)), 4, "friendly"); + assert!(cat.reputation_faction(28).is_none(), "critter: no rep"); + // Orc (race 2) base with Stormwind is hated (−42000): rank 0 → hostile red. + assert_eq!(stormwind.base_for(2, 1), -42000); + assert_eq!(reputation_rank(stormwind.base_for(2, 1)), 0); + + // The unit tooltip's faction-name line (the `0x529fe0` tail): a Stormwind guard shows + // "Stormwind" to a human — the ref's Marshal McBride "Level 20 / Stormwind / PvP" shot. + assert_eq!(cat.faction_name(72), Some("Stormwind")); + assert!( + stormwind.tooltip_shows_for(1, 1), + "Stormwind's matched slot is not rep-hidden for a human" + ); + // Players never get the line: the PLAYER,* factions carry no reputation slot, so the + // builder's rep-index gate drops it before the slot walk. + assert!( + cat.reputation_faction(tpl(1).faction).is_none(), + "the human player faction has no reputation slot" + ); + } + + /// The rank thresholds (vanilla `PointsInRank`), exact at every boundary. + #[test] + fn reputation_rank_thresholds() { + for (standing, rank) in [ + (-42000, 0), + (-6001, 0), + (-6000, 1), + (-3001, 1), + (-3000, 2), + (-1, 2), + (0, 3), + (2999, 3), + (3000, 4), + (8999, 4), + (9000, 5), + (20999, 5), + (21000, 6), + (41999, 6), + (42000, 7), + (42999, 7), + ] { + assert_eq!(reputation_rank(standing), rank, "standing {standing}"); + } + // The palette mapping on the rank scale: <=1 red, >=4 green, else yellow. + assert_eq!(Reaction::from_rank(0), Reaction::Hostile); + assert_eq!(Reaction::from_rank(1), Reaction::Hostile); + assert_eq!(Reaction::from_rank(2), Reaction::Neutral); + assert_eq!(Reaction::from_rank(3), Reaction::Neutral); + assert_eq!(Reaction::from_rank(4), Reaction::Friendly); + assert_eq!(Reaction::from_rank(7), Reaction::Friendly); + } + + /// The real 5875 FactionGroup table: the territory-line names resolve by mask bit — Alliance + /// mask 2, Horde mask 4, nothing for an unowned 0 (the `GetZonePVPInfo` lookup, wow-re ui + /// `zonetext-pvpinfo.md`). Skips without client data. + #[test] + fn real_faction_group_names_by_mask() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_faction_catalog(&mut chain).expect("load factions"); + assert_eq!(cat.faction_group_name(2), Some("Alliance")); + assert_eq!(cat.faction_group_name(4), Some("Horde")); + assert_eq!(cat.faction_group_name(0), None); + } +} diff --git a/crates/benilla-formats/src/footsteps.rs b/crates/benilla-formats/src/footsteps.rs new file mode 100644 index 00000000..10b3e6f0 --- /dev/null +++ b/crates/benilla-formats/src/footsteps.rs @@ -0,0 +1,192 @@ +//! Footsteps: the terrain-type chain (decision 0070 slice 3) — +//! `ground texture → GroundEffectTexture.TerrainType → TerrainType.SoundID × +//! CreatureSoundData.FootstepID → FootstepTerrainLookup → SoundEntries (dry | splash)`. +//! +//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-07-02): +//! - `TerrainType` **11 × 6 × 24 B**: `ID, Desc(str), FootstepSprayRun, FootstepSprayWalk, +//! SoundID, Flags` — the full domain decoded: Dirt→1, Metallic→2, Stone→3, Snow→4, Wood→5, +//! Grass→6, Leaves→7, Sand→8, Soggy→9, DustyGrass→6, None→0. +//! - `FootstepTerrainLookup` **179 × 5 × 20 B**: `ID, CreatureFootstepID, TerrainSoundID, +//! SoundID(dry), SoundIDSplash`. Spot-checks: class 8 × terrain-sound 2 (Metallic) → +//! 650/1063; class 8 × 3 (Stone) → 653/1057. +//! - `GroundEffectTexture` field 6 is the `TerrainType` FK (the clutter catalog reads the same +//! table for doodads and rightly skips doodad-less rows; footsteps need every row, so this +//! module re-reads it into its own map). +//! +//! Class semantics (data-verified 2026-07-02; class-0 gate byte-confirmed at `0x6233ec`, +//! wow-re `benilla-pins.md` B11a): **class 7 is the humanoid/character class** — its ten rows +//! are exactly the `CharacterMediumLarge*` kits; characters reach it through the ordinary +//! display→sound data chain (`creature_sound`, the model fallback), never a code default. +//! **Class 0 means "no footstep sounds"** — the client bails before any lookup; the lookup's +//! class-0 rows are the Ancient Protector's stomps (kit 661), reached only by a *nonzero* class +//! on its own row. A position with **no ground-effect layer is silent** — the client's sentinel +//! is −1 and the kit lookup's signed bounds check rejects it (`0x458450`, B5-verified; the +//! audible fingerprint is vanilla's famously quiet dirt roads). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +/// The joined footstep tables, resolvable from `(footstep class, ground effect id)`. +pub struct FootstepCatalog { + /// `GroundEffectTexture` id → `TerrainType` id (every row, including doodad-less ones). + effect_terrain: HashMap, + /// `TerrainType` id → its `SoundID` class (the lookup's `TerrainSoundID` axis). + terrain_sound: HashMap, + /// `(CreatureFootstepID, TerrainSoundID)` → `(dry kit, splash kit)`. + lookup: HashMap<(u32, u32), (u32, u32)>, +} + +impl FootstepCatalog { + /// The `(dry, splash)` SoundEntries kits for a creature footstep class standing on the given + /// ground-effect layer. `None` = silence: no/unknown effect layer (the client's −1 sentinel, + /// module docs), or no lookup row for `(class, terrain)`. + pub fn resolve(&self, footstep_class: u32, effect_id: Option) -> Option<(u32, u32)> { + let terrain = self.effect_terrain.get(&effect_id?).copied()?; + let sound_class = self.terrain_sound.get(&terrain).copied()?; + self.lookup.get(&(footstep_class, sound_class)).copied() + } + + pub fn len(&self) -> usize { + self.lookup.len() + } + + pub fn is_empty(&self) -> bool { + self.lookup.is_empty() + } +} + +fn n_u32_schema(name: &str, n: usize, string_fields: &[usize]) -> Schema { + let mut s = Schema::new(name); + for i in 0..n { + let ty = if string_fields.contains(&i) { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("f{i}"), ty)); + } + s +} + +/// Read the three tables off the patch chain. +pub fn load_footstep_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\GroundEffectTexture.dbc") + .context("reading GroundEffectTexture.dbc")?; + let rs = parse( + &bytes, + n_u32_schema("GroundEffectTexture", 7, &[]), + "GroundEffectTexture", + )?; + let mut effect_terrain = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(tt)) = (u32_at(r, 0), u32_at(r, 6)) { + effect_terrain.insert(id, tt); + } + } + + let bytes = chain + .read_file("DBFilesClient\\TerrainType.dbc") + .context("reading TerrainType.dbc")?; + let rs = parse(&bytes, n_u32_schema("TerrainType", 6, &[1]), "TerrainType")?; + let mut terrain_sound = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(class)) = (u32_at(r, 0), u32_at(r, 4)) { + terrain_sound.insert(id, class); + } + } + + let bytes = chain + .read_file("DBFilesClient\\FootstepTerrainLookup.dbc") + .context("reading FootstepTerrainLookup.dbc")?; + let rs = parse( + &bytes, + n_u32_schema("FootstepTerrainLookup", 5, &[]), + "FootstepTerrainLookup", + )?; + let mut lookup = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(class), Some(ts)) = (u32_at(r, 1), u32_at(r, 2)) else { + continue; + }; + lookup.insert( + (class, ts), + (u32_at(r, 3).unwrap_or(0), u32_at(r, 4).unwrap_or(0)), + ); + } + + Ok(FootstepCatalog { + effect_terrain, + terrain_sound, + lookup, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The full chain resolves on real 5875 data: a Metallic-terrain effect under footstep + /// class 8 yields the byte-decoded kits (650 dry / 1063 splash); the humanoid class 7 + /// resolves the `CharacterMediumLarge*` kits (560 Dirt with no effect, 562 Grass on a + /// grass-terrain effect — the character-in-Elwynn case); an unknown class stays silent. + #[test] + fn real_footstep_chain_resolves() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_footstep_catalog(&mut chain).expect("load footstep catalog"); + assert_eq!(cat.len(), 179, "all lookup rows load"); + + // Some effect whose terrain is Metallic (TerrainType 1 → sound class 2). + let metallic = cat + .effect_terrain + .iter() + .find(|(_, &tt)| tt == 1) + .map(|(&e, _)| e); + if let Some(e) = metallic { + assert_eq!( + cat.resolve(8, Some(e)), + Some((650, 1063)), + "class 8 on metallic → the byte-decoded row" + ); + } + // The humanoid class (7): the dirt kit on a dirt-terrain effect, the grass kit on a + // grass one (class 0 is the Ancient Protector, module docs). + let effect_with_sound_class = |sc: u32| { + cat.effect_terrain + .iter() + .find(|(_, tt)| cat.terrain_sound.get(tt) == Some(&sc)) + .map(|(&e, _)| e) + }; + if let Some(e) = effect_with_sound_class(1) { + assert_eq!( + cat.resolve(7, Some(e)).map(|(dry, _)| dry), + Some(560), + "class 7 on dirt → CharacterMediumLargeDirt" + ); + } + if let Some(e) = effect_with_sound_class(6) { + assert_eq!( + cat.resolve(7, Some(e)).map(|(dry, _)| dry), + Some(562), + "class 7 on grass → CharacterMediumLargeGrass" + ); + } + // No ground-effect layer = silence (the −1 sentinel, B5); unknown class too. + assert_eq!(cat.resolve(7, None), None); + assert_eq!(cat.resolve(9999, None), None); + } +} diff --git a/crates/benilla-formats/src/gameobjects.rs b/crates/benilla-formats/src/gameobjects.rs new file mode 100644 index 00000000..cb03d872 --- /dev/null +++ b/crates/benilla-formats/src/gameobjects.rs @@ -0,0 +1,116 @@ +//! GameObject display resolution: `displayId` → world model path. +//! +//! A GameObject's `GAMEOBJECT_DISPLAYID` indexes **GameObjectDisplayInfo.dbc**, whose `modelName` +//! column is a direct path — mostly `.mdx`/`.mdl` (M2: chests, mailboxes, doodads) with a few `.wmo` +//! (large structures). Unlike creatures there's no model-data indirection or skins, so the catalog is +//! just `displayId → path`; the renderer dispatches the path through [`crate::load_object_model`]. +//! +//! Layout verified against build 5875: 12 fields (ID@0, modelName@1 string, Sound[10]@2..11). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +const GAMEOBJECT_DISPLAY_INFO: &str = "DBFilesClient\\GameObjectDisplayInfo.dbc"; + +/// `displayId → model path` from GameObjectDisplayInfo.dbc. +pub struct GameObjectCatalog { + models: HashMap, +} + +impl GameObjectCatalog { + /// The model path for a GameObject display id (`.mdx`/`.mdl`/`.wmo`), or `None`. + pub fn model_path(&self, display_id: u32) -> Option<&str> { + self.models.get(&display_id).map(String::as_str) + } + + /// Number of display entries (diagnostics). + pub fn len(&self) -> usize { + self.models.len() + } + + /// Whether the catalog is empty. + pub fn is_empty(&self) -> bool { + self.models.is_empty() + } +} + +/// GameObjectDisplayInfo.dbc — 12 fields in build 5875: ID, modelName, then 10 sound refs. +fn schema() -> Schema { + let mut s = Schema::new("GameObjectDisplayInfo"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("ModelName", FieldType::String)); + for i in 0..10 { + s.add_field(SchemaField::new(format!("Sound{i}"), FieldType::UInt32)); + } + s +} + +/// Load GameObjectDisplayInfo.dbc from the patch chain into a [`GameObjectCatalog`]. +pub fn load_gameobject_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(GAMEOBJECT_DISPLAY_INFO) + .with_context(|| format!("reading {GAMEOBJECT_DISPLAY_INFO}"))?; + let rs = parse(&bytes, schema(), "GameObjectDisplayInfo")?; + let mut models = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 1)) { + models.insert(id, path); + } + } + Ok(GameObjectCatalog { models }) +} + +/// The per-display **sound-kit slots** (`Sound0..9` — the other 10 columns of the same table): +/// `{0 Stand, 1 Open, 2 Loop, 3 Close, 4 Destroy, 5 Opened, 6..9 Custom}` → SoundEntries. +/// Loaded separately from the model catalog so the audio consumer doesn't reach into the +/// renderer's cache (decision 0070); only displays with at least one non-zero slot are kept +/// (most of the 1638 rows are silent props). +pub struct GameObjectSounds { + sounds: HashMap, +} + +/// The `Sound[10]` slot indices with recorded meanings (wowdev, vanilla family). +pub mod go_sound_slot { + pub const OPEN: usize = 1; + pub const CLOSE: usize = 3; +} + +impl GameObjectSounds { + /// The 10 sound-kit slots for a display id; `None` when the display has no sounds at all. + pub fn slots(&self, display_id: u32) -> Option<&[u32; 10]> { + self.sounds.get(&display_id) + } + + pub fn len(&self) -> usize { + self.sounds.len() + } + + pub fn is_empty(&self) -> bool { + self.sounds.is_empty() + } +} + +/// Load the sound slots off GameObjectDisplayInfo.dbc. +pub fn load_gameobject_sounds(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(GAMEOBJECT_DISPLAY_INFO) + .with_context(|| format!("reading {GAMEOBJECT_DISPLAY_INFO}"))?; + let rs = parse(&bytes, schema(), "GameObjectDisplayInfo")?; + let mut sounds = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let mut slots = [0u32; 10]; + for (i, slot) in slots.iter_mut().enumerate() { + *slot = u32_at(r, 2 + i).unwrap_or(0); + } + if slots.iter().any(|&s| s != 0) { + sounds.insert(id, slots); + } + } + Ok(GameObjectSounds { sounds }) +} diff --git a/crates/benilla-formats/src/ground_effects.rs b/crates/benilla-formats/src/ground_effects.rs new file mode 100644 index 00000000..475a5096 --- /dev/null +++ b/crates/benilla-formats/src/ground_effects.rs @@ -0,0 +1,487 @@ +//! Ground-effect doodads — the little grass tufts, weeds, flowers and pebbles scattered over the +//! terrain. This is the dense ground clutter that makes the real client's outdoors look lush; without +//! it our ground is bare texture. +//! +//! Mechanism (verified against build 5875 + wowdev.wiki ADT/v18, 2026-05-26): +//! - Each terrain texture layer in an MCNK carries an `effectId` ([`benilla_adt`] `TextureLayer.effect_id`; +//! `0xFFFFFFFF` = none) that indexes [`GroundEffectTexture.dbc`]. +//! - [`GroundEffectTexture.dbc`] (5875: **7 fields / 28 B** — `ID, doodadId[4], density, sound`; no +//! per-doodad weights, those arrive in 6.0) gives up to four [`GroundEffectDoodad.dbc`] refs + a +//! `density` (0→8, the client caps ~24: amount, then coverage/grouping). +//! - [`GroundEffectDoodad.dbc`] (5875: **3 fields / 12 B** — `ID, internalId, modelPath`) names the +//! model as a bare `*.mdl` filename; on disk in this client the real model is +//! `World\NoDXT\Detail\.m2` (an `MD20` M2 — verified `ElwGra01.m2` extracts and loads). These +//! models are **not in the MPQ listfile** but extract fine by exact path. +//! +//! Which cell gets which doodad set is **authored**, in two per-cell 8×8 grids in the MCNK header +//! (decoded onto [`ChunkMesh::pred_tex`] / [`ChunkMesh::no_effect_doodad`]): `predominantTexture` +//! (0x40) names the layer whose `effectId` applies, `noEffectDoodad` (0x50) hard-disables the cell. +//! The exact intra-cell scatter positions are client-internal, so the renderer scatters +//! deterministically at the authored density. This module owns the **data** (`effectId → (models, +//! density)`) and the faithful per-cell [`scatter_ground_doodads`]. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::terrain::ChunkMesh; + +const GROUND_EFFECT_TEXTURE: &str = "DBFilesClient\\GroundEffectTexture.dbc"; +const GROUND_EFFECT_DOODAD: &str = "DBFilesClient\\GroundEffectDoodad.dbc"; +/// Detail-doodad models live here; the DBC stores a bare `*.mdl` name, the on-disk file is `.m2`. +const DETAIL_DIR: &str = "World\\NoDXT\\Detail\\"; + +/// A "none" doodad slot. Vanilla 5875 uses **only `0xFFFFFFFF` (the wiki's "-1")** as the empty +/// sentinel — `0` is a valid `internalId`, owned by `ElwFlo01.mdl` (the dark-red branched flower +/// in Elwynn). Empirically: across `GroundEffectTexture.dbc` (12 742 rows × 4 slots = 50 968), +/// 48 083 slots are `0xFFFFFFFF`, only 16 are `0`, and all 16 resolve to `ElwFlo01` — so `0` is +/// definitely an id, not a sentinel. (Treating it as empty silently dropped every `ElwFlo01` +/// placement, e.g. the slot-0 entry on the 12 Elwynn effects 507/720/734/741/747/753/761/765/ +/// 777/801/805/868.) +fn is_no_doodad(id: u32) -> bool { + id == 0xFFFF_FFFF +} + +/// The detail doodads + density for one `effectId` (a [`GroundEffectTexture.dbc`] row). +#[derive(Clone, Debug)] +pub struct GroundEffect { + /// The 4 `doodadId` slots in order, resolved to `World\NoDXT\Detail\*.m2` paths. `None` = an + /// empty slot (`0`/`-1`); the client treats those as "place nothing" so the slot pattern (incl. + /// duplicates) acts as the doodad weighting. Kept in slot order (not deduped) for that reason. + pub doodads: [Option; 4], + /// Authored density (`0`..~`24`): per-cell doodad count; higher = denser, then grouped. + pub density: u32, +} + +impl GroundEffect { + /// The non-empty resolved model paths (diagnostics; the placement uses [`Self::doodads`] directly). + pub fn models(&self) -> Vec<&str> { + self.doodads.iter().filter_map(|d| d.as_deref()).collect() + } +} + +/// `effectId → GroundEffect` from the GroundEffect* DBCs. Query with [`GroundEffectCatalog::effect`]. +pub struct GroundEffectCatalog { + effects: HashMap, +} + +impl GroundEffectCatalog { + /// The doodads + density for a terrain layer's `effectId`, or `None` (no effect / no models). + pub fn effect(&self, effect_id: u32) -> Option<&GroundEffect> { + self.effects.get(&effect_id) + } + + /// Number of effect rows that resolve to at least one model (diagnostics). + pub fn len(&self) -> usize { + self.effects.len() + } + + /// Whether the catalog is empty. + pub fn is_empty(&self) -> bool { + self.effects.is_empty() + } +} + +/// One placed ground-clutter doodad: a small upright `World\NoDXT\Detail\*.m2` with a random heading. +/// Unlike [`crate::Doodad`] (MDDF placement matrix) these stand straight with just a yaw, like a +/// creature — so they get their own type to avoid the MDDF rotation convention. +#[derive(Clone, Debug)] +pub struct GroundDoodadPlacement { + pub model: String, + /// Position in WoW world coords (X north, Y west, Z up), on the terrain surface. + pub position: [f32; 3], + /// Heading in radians about the up axis (random — clutter has no authored facing). + pub yaw: f32, + /// Per-instance uniform scale, the client's random `[0.9, 1.1]` (`signed·0.1 + 1.0`). + pub scale: f32, +} + +/// The vanilla client's 256-byte detail-doodad noise table (a permutation of 0..255), recovered from +/// `Marlamin/noggit-red`'s `ChunkAddDetailDoodads.cpp` (where it sits commented out beside an `'a'` +/// placeholder). Drives [`BlizzardRandomizer`]. +#[rustfmt::skip] +pub(crate) const GROUND_EFFECT_NOISE: [u8; 256] = [ + 0x8e, 0x14, 0x27, 0x99, 0xfd, 0xaa, 0xc7, 0x08, 0xd5, 0xe6, 0x3e, 0x1f, 0xf6, 0xbb, 0x55, 0xda, + 0x75, 0xa0, 0x4a, 0x6a, 0xe8, 0xbd, 0x97, 0xff, 0xde, 0x9b, 0xbc, 0x9f, 0x81, 0x8a, 0xa1, 0x46, + 0x6e, 0x0b, 0xe3, 0x63, 0x76, 0x7a, 0x6c, 0x5d, 0x88, 0xd3, 0x69, 0xca, 0xc3, 0x47, 0xb9, 0x25, + 0x83, 0xab, 0xa2, 0x3f, 0xa6, 0x41, 0x7c, 0xba, 0xe5, 0xac, 0x95, 0x01, 0x7e, 0xcf, 0x09, 0xc1, + 0xd9, 0x62, 0x70, 0x71, 0x8d, 0xdb, 0x05, 0x02, 0x24, 0x87, 0xef, 0x54, 0xc6, 0xd4, 0x37, 0x30, + 0xd0, 0x1b, 0xcb, 0x7b, 0xb8, 0xe4, 0xd8, 0xec, 0x49, 0xce, 0xad, 0xdc, 0x13, 0xa9, 0x94, 0xc4, + 0x8f, 0x39, 0xae, 0x0d, 0x18, 0x52, 0xdd, 0x0e, 0x78, 0xfa, 0xf5, 0x85, 0x58, 0xd2, 0xaf, 0x6d, + 0xa4, 0xb2, 0x53, 0x3b, 0x51, 0xa5, 0x50, 0xbe, 0xfc, 0x2d, 0xf4, 0x11, 0x48, 0x98, 0x16, 0xf1, + 0x86, 0xdf, 0x3d, 0x66, 0x5e, 0x44, 0x2e, 0x2f, 0x36, 0x07, 0x6b, 0x17, 0x8b, 0x29, 0x4c, 0xb6, + 0xe2, 0x89, 0x5f, 0xe7, 0xcd, 0xa7, 0x21, 0xe1, 0x4d, 0xc9, 0x65, 0xed, 0xfe, 0xee, 0x9c, 0x23, + 0x33, 0x7d, 0xb7, 0x04, 0x9e, 0x9a, 0x2a, 0x40, 0xb3, 0x10, 0x5b, 0xf3, 0x82, 0x77, 0x1c, 0x92, + 0x20, 0x4e, 0x1e, 0x57, 0x22, 0x72, 0x06, 0x8c, 0x67, 0x2c, 0x73, 0xfb, 0x59, 0xc2, 0x0a, 0xbf, + 0x79, 0x5c, 0xf9, 0x0c, 0x28, 0x1a, 0x12, 0x68, 0x74, 0x34, 0x19, 0x42, 0xb1, 0xc0, 0x84, 0xf8, + 0x38, 0xf0, 0x15, 0x9d, 0x60, 0xf2, 0x3a, 0x6f, 0xb4, 0x90, 0xeb, 0x91, 0x1d, 0x7f, 0x35, 0x61, + 0x5a, 0x32, 0x03, 0x56, 0xa3, 0xc5, 0x2b, 0x93, 0x80, 0x0f, 0x4b, 0x43, 0xf7, 0xa8, 0xe0, 0x3c, + 0x96, 0xd1, 0x64, 0x26, 0xd7, 0x45, 0xcc, 0x4f, 0xc8, 0xb0, 0xe9, 0xb5, 0x00, 0xd6, 0x31, 0xea, +]; + +/// The vanilla client's detail-doodad PRNG (`FUN_004531e0` + the `FUN_0044ea80` seed-expand, a.k.a. the +/// Noggit `BlizzardRandomizer`). Seeded **once per chunk** from the chunk's global coords; the same +/// stream drives PASS-1 cell picks and PASS-2 positions/scale/yaw, so the layout is bit-exact to the +/// client and reload-stable. The lane math (`shuffle`) reproduces the binary exactly — see +/// [`scatter_ground_doodads`]. +struct BlizzardRandomizer { + source: u32, + seed: u32, +} + +impl BlizzardRandomizer { + fn new(source: u32) -> Self { + let seed = ((source % 0x2F) << 26) + | ((source % 0x35) << 18) + | ((source % 0x3B) << 10) + | (4 * (source % 0x3D)); + Self { source, seed } + } + + /// Little-endian `u32` read at byte offset `i` of the noise table. The 4-byte read wraps past 255 + /// (circular); the client's exact boundary behaviour at `i ≥ 253` is unconfirmed (rare, ~1%). + fn noise32(i: u8) -> u32 { + let b = |k: u8| GROUND_EFFECT_NOISE[i.wrapping_add(k) as usize] as u32; + b(0) | (b(1) << 8) | (b(2) << 16) | (b(3) << 24) + } + + /// Advance and return the next `source`. (`rol(v, 0) == v`.) + fn shuffle(&mut self) -> u32 { + // Per-lane index = byte − sub, and **when that goes negative add the lane's specific constant** + // (`0xf4/0xec/0xd4/0xbc`) — NOT a mod-256 wrap. This is the client's exact `FUN_004531e0` math + // (verified to the instruction); the result stays in `[0, 251]` so the 4-byte noise read is + // always in-bounds. (Using `wrapping_sub` diverges on ~11–16% of draws — see ground-effects.md.) + let lane = |byte: u32, sub: i32, wrap: i32| -> u8 { + let idx = (byte & 0xFF) as i32 - sub; + (if idx < 0 { idx + wrap } else { idx }) as u8 + }; + let a = lane(self.seed, 0x1C, 0xF4); + let b = lane(self.seed >> 8, 0x18, 0xEC); + let c = lane(self.seed >> 16, 0x0C, 0xD4); + let d = lane(self.seed >> 24, 0x04, 0xBC); + self.seed = (a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24); + self.source = self.source.wrapping_add( + Self::noise32(a) + ^ Self::noise32(d).rotate_left(1) + ^ Self::noise32(c).rotate_left(2) + ^ Self::noise32(b).rotate_left(3), + ); + self.source + } + + /// A signed uniform float in `(-1, 1)` — the client's `genCoord` output used for in-cell position, + /// scale, and yaw: take the mantissa float in `[1, 2)`, then map by the roll's sign bit. + fn signed_unit(&mut self) -> f32 { + let u = self.shuffle(); + let f = f32::from_bits((u & 0x007F_FFFF) | 0x3F80_0000); // [1, 2) + if (u as i32) < 0 { + 2.0 - f // (0, 1] → mapped to (−1, 0] + } else { + f - 2.0 // [1, 2) → [−1, 0) … together: (−1, 1) + } + } +} + +/// The client's `frillDensity` CVar default — the number of (randomly-chosen, with-repeats) cells the +/// client visits per chunk (RE'd from `WoW.exe`; range 1..256, `SetWorldDetail` sets 16/32/48). It is +/// **not** all 64 cells — that's what makes the real clutter patchy (some cells hit 2–3×, ~50 left bare) +/// rather than an even carpet. +const FRILL_DENSITY: u32 = 16; + +/// Scatter ground-clutter doodads over one terrain chunk — a **bit-exact port of the client's +/// `CMapChunk::CreateDetailDoodads`** (RE'd from `WoW.exe` @ `0x6bfc10`). One RNG stream per chunk, +/// seeded by **global chunk coords** +/// `(globalChunkY << 16) | globalChunkX`, then two passes: +/// +/// - **PASS 1** picks `frillDensity` (×`density_scale`) random cells `(rng&7, rng&7)` — **with repeats, +/// no dedup** (2 RNG draws each). This is the source of the reference's patchiness: cells drawn more +/// than once get extra doodads, the ~50 unpicked cells stay bare. +/// - **PASS 2** visits exactly those cells in order. Per cell: skip if [`ChunkMesh::no_effect_doodad`]; +/// the layer named by [`ChunkMesh::pred_tex`] (`predominantTexture`, **never** MCAL alpha) gives the +/// MCLY `effectId` → its [`GroundEffect`]. Then spawn `density` doodads; per doodad the exact draw +/// order is **rx, ry (always), then — only if the slot isn't empty — scale, yaw**. Slot is +/// `(n + listIndex) & 3` of the effect's 4 `doodadId`s; an **empty (−1/None) slot places nothing** +/// but still consumes the rx/ry draws (matching the client, so the stream stays aligned). +/// +/// Empty-slot effects are *why bare-earth cells stay clear*: a layer whose effect is all-−1 resolves to +/// `None` (we drop those from the catalog). `density_scale` (debug slider) scales the per-chunk cell +/// count = the client's `frillDensity` lever. +pub fn scatter_ground_doodads( + chunk: &ChunkMesh, + catalog: &GroundEffectCatalog, + tile_x: u32, + tile_y: u32, + density_scale: f32, +) -> Vec { + if chunk.positions.len() < 145 || density_scale <= 0.0 { + return Vec::new(); + } + let frill = ((FRILL_DENSITY as f32 * density_scale).round() as i64).clamp(0, 256) as u32; + if frill == 0 { + return Vec::new(); + } + // The chunk's 145 verts are MCVT's stride-17 interleave (B1): + // outer (r,c) @ r·17+c, inner (r,c) @ r·17+9+c. Outer = the corner grid; inner = each cell's + // CENTER vertex, carrying its own authored MCVT height (the bump that gives hilltops their + // rise). Clutter heights interpolate via the 4-triangle center-fan, NOT a 4-corner bilinear: + // matching the terrain mesh's subdivision so doodads sit flush on the surface (esp. over + // hilltops, where a bilinear interp would float them under the rising inner vertex). + let outer = |row: usize, col: usize| chunk.positions[row * 17 + col]; + let inner = |row: usize, col: usize| chunk.positions[row * 17 + 9 + col]; + let n_layers = chunk.layer_textures.len(); + + // ONE RNG stream for the whole chunk, seeded by GLOBAL chunk coords (Y high, X low) — matches the + // client, so the patch layout is globally unique (never repeats per tile) and reload-stable. + let global_x = tile_x * 16 + chunk.index_x; + let global_y = tile_y * 16 + chunk.index_y; + let mut rng = BlizzardRandomizer::new((global_y << 16) | (global_x & 0xFFFF)); + + // PASS 1: pick `frill` random cells (with repeats — do NOT dedup), 2 draws each. + let cells: Vec<(usize, usize)> = (0..frill) + .map(|_| { + let col = (rng.shuffle() & 7) as usize; // cellX + let row = (rng.shuffle() & 7) as usize; // cellY + (row, col) + }) + .collect(); + + // PASS 2: visit exactly those cells in order; gate + spawn. + let mut out = Vec::new(); + for (list_index, &(row, col)) in cells.iter().enumerate() { + let k = row * 8 + col; + if chunk.no_effect_doodad[k] { + continue; // client's explicit "no clutter" mask (roads/clearings) + } + // Holes (B2): skip clutter over holed 2×2 blocks — the surface itself isn't drawn there. + if chunk.holes & (1u16 << ((row >> 1) * 4 + (col >> 1))) != 0 { + continue; + } + let layer = chunk.pred_tex[k] as usize; + if layer >= n_layers { + continue; // names a non-existent layer + } + let Some(eff) = chunk + .layer_effect_ids + .get(layer) + .and_then(|&eid| catalog.effect(eid)) + else { + continue; // effect is none / all-(−1)-slots → the cell stays bare + }; + let density = if eff.density == 0 { 8 } else { eff.density }; + // Cell verts in our (row, col) frame: row steps south (-X), col steps east (-Y). + let tl = outer(row, col); + let tr = outer(row, col + 1); + let bl = outer(row + 1, col); + let br = outer(row + 1, col + 1); + let ctr = inner(row, col); + for n in 0..density as usize { + // Exact client draw order: rx, ry ALWAYS consumed; scale/yaw only when a model is placed. + let rx = rng.signed_unit(); + let ry = rng.signed_unit(); + let Some(model) = eff.doodads[(n + list_index) & 3].as_deref() else { + continue; // empty (−1) slot: places nothing, but rx/ry are already consumed + }; + let scale = rng.signed_unit() * 0.1 + 1.0; // [0.9, 1.1] + let yaw = rng.signed_unit() * std::f32::consts::PI; // (−π, π) + let (fx, fy) = ((rx + 1.0) * 0.5, (ry + 1.0) * 0.5); + let position = fan_point(fx, fy, tl, tr, bl, br, ctr); + out.push(GroundDoodadPlacement { + model: model.to_string(), + position, + yaw, + scale, + }); + } + } + out +} + +/// Interpolate a point inside one MCNK cell at fractional `(fx, fy) ∈ [0, 1]²` (fx=east, fy=south) +/// across the **4-triangle center-fan** the terrain mesh itself draws — barycentric over the wedge +/// containing the point (CTR + two adjacent corners). The 4 wedges are bounded by the cell's two +/// diagonals (`fy=fx` and `fy=1-fx`). Matches the visible terrain so doodads sit flush on hilltops +/// (a 4-corner bilinear would sink them below the rising inner-center vertex). +fn fan_point( + fx: f32, + fy: f32, + tl: [f32; 3], + tr: [f32; 3], + bl: [f32; 3], + br: [f32; 3], + ctr: [f32; 3], +) -> [f32; 3] { + // (u, v, w) weights and the two corner vertices for the wedge. + let (u, v, w, va, vb) = if fx + fy <= 1.0 { + if fy <= fx { + // North wedge: (CTR, TL, TR) — u_ctr=2·fy, v_tl=1−fx−fy, w_tr=fx−fy + (2.0 * fy, 1.0 - fx - fy, fx - fy, tl, tr) + } else { + // West wedge: (CTR, BL, TL) — u_ctr=2·fx, v_bl=fy−fx, w_tl=1−fx−fy + (2.0 * fx, fy - fx, 1.0 - fx - fy, bl, tl) + } + } else if fy >= fx { + // South wedge: (CTR, BR, BL) — u_ctr=2·(1−fy), v_br=fx+fy−1, w_bl=fy−fx + (2.0 * (1.0 - fy), fx + fy - 1.0, fy - fx, br, bl) + } else { + // East wedge: (CTR, TR, BR) — u_ctr=2·(1−fx), v_tr=fx−fy, w_br=fx+fy−1 + (2.0 * (1.0 - fx), fx - fy, fx + fy - 1.0, tr, br) + }; + [ + u * ctr[0] + v * va[0] + w * vb[0], + u * ctr[1] + v * va[1] + w * vb[1], + u * ctr[2] + v * va[2] + w * vb[2], + ] +} + +/// GroundEffectTexture.dbc — 5875: `ID, doodadId[4], density, sound` (7 fields). +fn texture_schema() -> Schema { + let mut s = Schema::new("GroundEffectTexture"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + for i in 0..4 { + s.add_field(SchemaField::new(format!("Doodad{i}"), FieldType::UInt32)); + } + s.add_field(SchemaField::new("Density", FieldType::UInt32)); + s.add_field(SchemaField::new("Sound", FieldType::UInt32)); + s +} + +/// GroundEffectDoodad.dbc — 5875: `ID, internalId, modelPath` (3 fields). +fn doodad_schema() -> Schema { + let mut s = Schema::new("GroundEffectDoodad"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("InternalId", FieldType::UInt32)); + s.add_field(SchemaField::new("ModelPath", FieldType::String)); + s +} + +/// Resolve a DBC doodad string (a bare `ElwGra01.mdl`) to its on-disk model `World\NoDXT\Detail\ +/// ElwGra01.m2`. Keeps only the file stem (defensive against any stray path) and forces `.m2`. +fn resolve_model(dbc_name: &str) -> String { + let file = dbc_name.rsplit(['\\', '/']).next().unwrap_or(dbc_name); + let stem = file.rsplit_once('.').map(|(s, _)| s).unwrap_or(file); + format!("{DETAIL_DIR}{stem}.m2") +} + +/// Load the GroundEffect* DBCs from the patch chain into a [`GroundEffectCatalog`]. +/// +/// `key_by_internal_id` selects how `GroundEffectTexture.doodadId[k]` is matched to a +/// `GroundEffectDoodad` row. **The faithful value is `true`:** the client (verified in `WoW.exe`) +/// treats `doodadId[k]` as a **`GroundEffectDoodad.internalId`** +/// (field 1) and indexes its model cache by it — NOT the DBC row `ID` (field 0). In 5875 the two +/// differ by exactly one (`rowID = internalId + 1`), so keying by row ID is an off-by-one that picks +/// the neighbouring species (e.g. the main Elwynn grass effect resolves a *flower* into slot 0 and +/// shifts the grass). `false` reproduces that old bug for A/B comparison only. +pub fn load_ground_effect_catalog( + chain: &mut Chain, + key_by_internal_id: bool, +) -> Result { + // doodadId (an internalId) → resolved model path. Keyed by GroundEffectDoodad.internalId (field 1) + // when faithful; field 0 (row ID) is the legacy off-by-one path. + let key_field = if key_by_internal_id { 1 } else { 0 }; + let doodads = { + let bytes = chain + .read_file(GROUND_EFFECT_DOODAD) + .with_context(|| format!("reading {GROUND_EFFECT_DOODAD}"))?; + let rs = parse(&bytes, doodad_schema(), "GroundEffectDoodad")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(name)) = (u32_at(r, key_field), str_at(&rs, r, 2)) { + m.insert(id, resolve_model(&name)); + } + } + m + }; + + let bytes = chain + .read_file(GROUND_EFFECT_TEXTURE) + .with_context(|| format!("reading {GROUND_EFFECT_TEXTURE}"))?; + let rs = parse(&bytes, texture_schema(), "GroundEffectTexture")?; + let mut effects = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + // Keep the 4 slots in order (not deduped): the client uses slot multiplicity as the weighting. + let mut slots: [Option; 4] = Default::default(); + for (i, slot) in slots.iter_mut().enumerate() { + let d = u32_at(r, 1 + i).unwrap_or(0); + if !is_no_doodad(d) { + *slot = doodads.get(&d).cloned(); + } + } + if slots.iter().all(Option::is_none) { + continue; // a "terrain type only" row (footsteps/sound, no doodads) — nothing to draw + } + let density = u32_at(r, 5).unwrap_or(0); + effects.insert( + id, + GroundEffect { + doodads: slots, + density, + }, + ); + } + Ok(GroundEffectCatalog { effects }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_model_forces_detail_m2_path() { + assert_eq!( + resolve_model("ElwGra01.mdl"), + "World\\NoDXT\\Detail\\ElwGra01.m2" + ); + assert_eq!( + resolve_model("ElwFlo01.MDX"), + "World\\NoDXT\\Detail\\ElwFlo01.m2" + ); + } + + #[test] + fn no_doodad_sentinels() { + // 0xFFFFFFFF is the only empty marker; `0` is the valid internalId of ElwFlo01 (see the + // function's doc-comment for the empirical tally that settles this). + assert!(is_no_doodad(0xFFFF_FFFF)); + assert!(!is_no_doodad(0)); + assert!(!is_no_doodad(42)); + } + + #[test] + fn noise_table_is_a_permutation() { + let mut seen = [false; 256]; + for &b in &GROUND_EFFECT_NOISE { + seen[b as usize] = true; + } + assert!( + seen.iter().all(|&s| s), + "noise table must be a 0..255 permutation" + ); + } + + #[test] + fn randomizer_is_deterministic_and_bounded() { + // Same seed → identical stream (clutter is stable every run). + let (mut a, mut b) = ( + BlizzardRandomizer::new(0x0030_0020), + BlizzardRandomizer::new(0x0030_0020), + ); + for _ in 0..1000 { + assert_eq!(a.shuffle(), b.shuffle()); + } + // Different seeds diverge (different chunks ⇒ different layouts). + assert_ne!( + BlizzardRandomizer::new(1).shuffle(), + BlizzardRandomizer::new(2).shuffle() + ); + // signed_unit stays in (-1, 1) — the client's genCoord range. + let mut r = BlizzardRandomizer::new(12345); + for _ in 0..10_000 { + let c = r.signed_unit(); + assert!((-1.0..1.0).contains(&c), "signed_unit out of range: {c}"); + } + } +} diff --git a/crates/benilla-formats/src/item_sounds.rs b/crates/benilla-formats/src/item_sounds.rs new file mode 100644 index 00000000..add0a4aa --- /dev/null +++ b/crates/benilla-formats/src/item_sounds.rs @@ -0,0 +1,139 @@ +//! `ItemGroupSounds.dbc` adapter — the per-item **pickup / put-down / use** sound groups +//! (decision 0091: the bag-drag item sounds). +//! +//! Layout — VERIFIED byte-exact against build 5875 (wow-re +//! `system/sound/scratch/item-pickup-place-sound.md`, §5 cross-checked): **5 fields × 4 = 20 B** +//! per record (loader `0x5477d0` asserts fieldCount 5 @`0x547879`, recordSize 0x14 @`0x5478ae`): +//! `{ id, kit[0], kit[1], kit[2], kit[3] }` — the kits are `SoundEntries.dbc` ids, indexed by the +//! client's **gesture**: `kit[0]` pickup/grab, `kit[1]` put-down/place, `kit[2]` use/activate +//! (`0x458024: mov ecx,[eax+4*ecx+0x4]`); `kit[3]` is unused by any caller. 24 rows in the real +//! DBC. An item reaches its group through `ItemDisplayInfo.field11` +//! ([`crate::items::ItemDisplay::group_sounds`]); a `0` kit slot means the gesture is silent — +//! the client's play tail drops kit id 0. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +const ITEM_GROUP_SOUNDS: &str = "DBFilesClient\\ItemGroupSounds.dbc"; + +/// The client's gesture index into an [`ItemGroupSoundsCatalog`] row (the `ecx` every +/// `SndInterfacePlayItemSound` caller passes — wow-re `item-pickup-place-sound.md`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ItemGesture { + /// `ecx = 0` — item grabbed onto the cursor. + Pickup = 0, + /// `ecx = 1` — item placed / put down / cursor cleared. + PutDown = 1, + /// `ecx = 2` — item used/activated (only groups 1–6 populate it). + Use = 2, +} + +/// `ItemGroupSounds.dbc`, keyed by group id (`ItemDisplayInfo.field11`). +pub struct ItemGroupSoundsCatalog { + groups: HashMap, +} + +impl ItemGroupSoundsCatalog { + /// The `SoundEntries` kit for a group's gesture — `None` when the group id is unknown or the + /// slot is `0` (both are the client's silent returns: the bounds/null checks at + /// `0x45800f`/`0x45801d`, and the play tail dropping kit 0). + pub fn kit(&self, group: u32, gesture: ItemGesture) -> Option { + self.groups + .get(&group) + .map(|kits| kits[gesture as usize]) + .filter(|&k| k != 0) + } + + pub fn len(&self) -> usize { + self.groups.len() + } + + pub fn is_empty(&self) -> bool { + self.groups.is_empty() + } +} + +fn item_group_sounds_schema() -> Schema { + let mut s = Schema::new("ItemGroupSounds"); + for name in ["ID", "Pickup", "PutDown", "Use", "Unused3"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Load `ItemGroupSounds.dbc` off the patch chain. +pub fn load_item_group_sounds(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(ITEM_GROUP_SOUNDS) + .with_context(|| format!("reading {ITEM_GROUP_SOUNDS}"))?; + let rs = parse(&bytes, item_group_sounds_schema(), "ItemGroupSounds")?; + let mut groups = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + groups.insert(id, std::array::from_fn(|i| u32_at(r, 1 + i).unwrap_or(0))); + } + Ok(ItemGroupSoundsCatalog { groups }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The real 5875 rows match the RE's corroboration decode (`item-pickup-place-sound.md`): + /// 24 groups; id 1 → kits [273, 274, 275, 0] (a group with a use kit), id 7 → [1185, 1202, 0, 0] + /// (a weapon/armor group, no use kit — its `Use` gesture resolves silent). + #[test] + fn real_item_group_sounds_resolve() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_item_group_sounds(&mut chain).expect("load ItemGroupSounds"); + assert_eq!(cat.len(), 24, "24 groups in build 5875"); + assert_eq!(cat.kit(1, ItemGesture::Pickup), Some(273)); + assert_eq!(cat.kit(1, ItemGesture::PutDown), Some(274)); + assert_eq!(cat.kit(1, ItemGesture::Use), Some(275)); + assert_eq!(cat.kit(7, ItemGesture::Pickup), Some(1185)); + assert_eq!(cat.kit(7, ItemGesture::PutDown), Some(1202)); + assert_eq!(cat.kit(7, ItemGesture::Use), None, "a 0 slot is silent"); + assert_eq!(cat.kit(999, ItemGesture::Pickup), None, "unknown group"); + } + + /// The display→group→kit join holds on real data: every nonzero `ItemDisplayInfo.field11` + /// (`group_sounds`) is a valid group id — the RE's 20513/20513 corroboration, re-run through + /// our own two adapters. + #[test] + fn real_display_group_ids_all_resolve() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let sounds = load_item_group_sounds(&mut chain).expect("load ItemGroupSounds"); + let displays = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo"); + let mut nonzero = 0usize; + let mut valid = 0usize; + for d in displays.iter() { + if d.group_sounds != 0 { + nonzero += 1; + if sounds.groups.contains_key(&d.group_sounds) { + valid += 1; + } + } + } + assert_eq!(nonzero, 20513, "the RE's nonzero field-11 count"); + assert_eq!(valid, nonzero, "every nonzero group id resolves"); + } +} diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs new file mode 100644 index 00000000..38811f2c --- /dev/null +++ b/crates/benilla-formats/src/items.rs @@ -0,0 +1,353 @@ +//! `ItemDisplayInfo.dbc` adapter — displayId → held-item / worn-equipment visual identity (decision +//! 0072 slice 1: combat presence, held items via the display chain). +//! +//! Layout — **TRIPLE-VERIFIED** against build 5875 (`decisions/0072`: our raw dump +//! `record_count=29604`; wow-re's `charactermodel` disasm; wow-re's independent dbc-node schema +//! catalog): 23 fields / 92-byte records. Columns: `0` id (key) · `1`/`2` model name L/R (string) · +//! `3`/`4` model texture L/R (string) · `5` icon (string, unread — no consumer yet) · `6`-`8` +//! geosetGroup[0..2] (u32) · `9` unpinned int (unread) · `10` the ranged-weapon `SpellVisual.dbc` +//! id (u32 — the substitute visual a RANGED-attribute spell with no own visual borrows for its +//! fire animation; byte-verified `0x60d493: mov eax,[eax+0x28]`, wow-re +//! `throw-ranged-attack-anim.md`) · `11` the `ItemGroupSounds.dbc` id +//! (u32 — the pickup/place sound group; byte-verified `0x458008: mov eax,[edx+0x2c]`, wow-re +//! `system/sound/scratch/item-pickup-place-sound.md`, corroborated 20513/20513 valid ids on the +//! real DBC) · `12`/`13` helm-vis (u32) · `14`-`21` the 8 body-region textures, in **ArmUpper, +//! ArmLower, Hand, TorsoUpper, TorsoLower, LegUpper, LegLower, Foot** order (string) · `22` +//! unpinned int (unread). +//! +//! **Trap, load-bearing:** `model` and `model_texture` are **independently-resolved basenames** in +//! the same `Item\ObjectComponents\\` folder — never derive one from the other. Real +//! counter-example: the Worn Wooden Shield row carries model `Shield_Round_A_01.mdx` (→ `.m2` on +//! disk) alongside texture `Buckler_Damaged_A_01Purple` (no relation to the model's own name). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, RecordSet, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::models::model_path; + +const ITEM_DISPLAY_INFO: &str = "DBFilesClient\\ItemDisplayInfo.dbc"; + +/// One `ItemDisplayInfo` row: the visual identity a held item or worn equipment display resolves to. +/// +/// `model`/`model_texture` carry **no directory prefix** — the app layer owns the +/// `Item\ObjectComponents\\` join, since `` depends on the item's inventory type (Weapon, +/// Shield, Head, …), which isn't a column in this DBC. +#[derive(Debug, Clone, Default)] +pub struct ItemDisplay { + /// Left/right model basename (index 0/1), the existing `.mdx`→`.m2` normalizer applied + /// (lowercased, extension swapped) — see [`crate::models::model_path`]. No directory. `None` for + /// an empty column (most rows carry only the left slot; two-handed/dual visuals use both). + pub model: [Option; 2], + /// Left/right model-texture basename (index 0/1) — raw, **no extension** (the app appends + /// `.blp`) and **not** derived from [`Self::model`] (see the module trap note). + pub model_texture: [Option; 2], + /// `geosetGroup[0..2]` — worn-equipment geoset selectors (robes etc.); `0` where unauthored. + /// Unused by held items. + pub geoset_groups: [u32; 3], + /// The 8 body-region texture overrides this display paints on, in ArmUpper/ArmLower/Hand/ + /// TorsoUpper/TorsoLower/LegUpper/LegLower/Foot order. `None` per-slot where this display + /// doesn't touch that region (most held-item rows: all 8 empty). + pub region_textures: [Option; 8], + /// A helm's `HelmetGeosetVisData` row ids — `[male, female]` (cols 12/13; VERIFIED wow-re + /// RF-0083: `ItemDisplayInfo_inmem[+0x30 + sex*4]`). `0` = no vis row (non-helm displays). + pub helmet_vis: [u32; 2], + /// The inventory icon (col 5) as a ready `Interface\Icons\…` MPQ path, extensionless as the + /// DBC stores names (the BLP loader appends it). Unlike the model columns there is no + /// variable directory to join — the icon dir is fixed — so this one ships app-ready, same as + /// `SpellCatalog`'s icons. `None` on the ~26% of rows that are icon-less visual attachments. + /// Anchored on live server-truth pairings (vmangos `item_template.display_id`, the values the + /// real wire answers): Worn Shortsword 1542 → `INV_Sword_04`, Tough Jerky 2473 → + /// `INV_Misc_Food_16`, Worn Wooden Shield 18730 → `INV_Shield_09`, Hearthstone 6418 → + /// `INV_Misc_Rune_01`. + pub icon: Option, + /// The `ItemGroupSounds.dbc` id (col 11) — the item's pickup/place/use sound group + /// ([`crate::item_sounds`]). `0` = no group (the display drags silently), matching the client's + /// bounds-check-and-return (`0x45800b`). + pub group_sounds: u32, + /// The ranged-weapon `SpellVisual.dbc` id (col 10) — the SUBSTITUTE visual a RANGED-attribute + /// spell with no own visual borrows from the equipped ranged weapon (the client's `0x60d450` + /// fallback: how Throw/Auto Shot get their fire clips — wow-re `throw-ranged-attack-anim.md`). + /// `0` on every non-ranged display; only three distinct nonzero ids exist across the real + /// table (thrown 98 · bow 5 · gun/rifle 224). + pub spell_visual: u32, +} + +/// `ItemDisplayInfo.dbc`, keyed by `displayId` (the id `ItemDisplayInfoID`/`UNIT_VIRTUAL_ITEM_SLOT_DISPLAY` +/// resolve into — decision 0072). +pub struct ItemDisplayCatalog { + displays: HashMap, +} + +impl ItemDisplayCatalog { + /// Look up a display id, or `None` if unknown. + pub fn get(&self, display_id: u32) -> Option<&ItemDisplay> { + self.displays.get(&display_id) + } + + pub fn len(&self) -> usize { + self.displays.len() + } + + pub fn is_empty(&self) -> bool { + self.displays.is_empty() + } + + /// Iterate the rows (order unspecified) — the cross-DBC join checks use it. + pub fn iter(&self) -> impl Iterator { + self.displays.values() + } +} + +/// `ItemDisplayInfo.dbc` — 23 fields / 92-byte records in build 5875 (see the module doc for the +/// column pins). Unpinned integer columns are still typed `UInt32` (matching the file's own record +/// stride) even though nothing reads them, so the schema's field-count check against the real header +/// stays exact. +fn item_display_info_schema() -> Schema { + let mut s = Schema::new("ItemDisplayInfo"); + for (name, ty) in [ + ("ID", FieldType::UInt32), + ("ModelNameLeft", FieldType::String), + ("ModelNameRight", FieldType::String), + ("ModelTextureLeft", FieldType::String), + ("ModelTextureRight", FieldType::String), + ("Icon", FieldType::String), + ("GeosetGroup0", FieldType::UInt32), + ("GeosetGroup1", FieldType::UInt32), + ("GeosetGroup2", FieldType::UInt32), + ] { + s.add_field(SchemaField::new(name, ty)); + } + // fields 9..14 (positional): 9 unpinned, 10 the ranged-weapon SpellVisual id, 11 the + // ItemGroupSounds id, 12/13 helm-vis. + for name in [ + "Unk9", + "SpellVisualID", + "ItemGroupSoundsID", + "HelmVisMale", + "HelmVisFemale", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for name in [ + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", + ] { + s.add_field(SchemaField::new(name, FieldType::String)); + } + s.add_field(SchemaField::new("Unk22", FieldType::UInt32)); + s +} + +/// Build the catalog from an already-parsed record set — the testable core (no `Chain` needed); +/// [`load_item_display_catalog`] is the chain-reading wrapper. +fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog { + let mut displays = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let model = [ + str_at(&rs, r, 1).map(|s| model_path(&s)), + str_at(&rs, r, 2).map(|s| model_path(&s)), + ]; + let model_texture = [str_at(&rs, r, 3), str_at(&rs, r, 4)]; + let geoset_groups = [ + u32_at(r, 6).unwrap_or(0), + u32_at(r, 7).unwrap_or(0), + u32_at(r, 8).unwrap_or(0), + ]; + let region_textures = std::array::from_fn(|i| str_at(&rs, r, 14 + i)); + let helmet_vis = [u32_at(r, 12).unwrap_or(0), u32_at(r, 13).unwrap_or(0)]; + let icon = str_at(&rs, r, 5).map(|i| format!("Interface\\Icons\\{i}")); + let group_sounds = u32_at(r, 11).unwrap_or(0); + let spell_visual = u32_at(r, 10).unwrap_or(0); + displays.insert( + id, + ItemDisplay { + model, + model_texture, + geoset_groups, + region_textures, + helmet_vis, + icon, + group_sounds, + spell_visual, + }, + ); + } + ItemDisplayCatalog { displays } +} + +/// Load `ItemDisplayInfo.dbc` off the patch chain into an [`ItemDisplayCatalog`]. +pub fn load_item_display_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(ITEM_DISPLAY_INFO) + .with_context(|| format!("reading {ITEM_DISPLAY_INFO}"))?; + let rs = parse(&bytes, item_display_info_schema(), "ItemDisplayInfo")?; + Ok(catalog_from_records(rs)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal synthetic WDBC (20-byte header + fixed-width records + a string block) — the same + /// shape `benilla-dbc`'s own tests build, reproduced here so this adapter is testable without a + /// real client install. + fn build_wdbc( + record_count: u32, + field_count: u32, + record_size: u32, + records: &[u8], + strings: &[u8], + ) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"WDBC"); + b.extend_from_slice(&record_count.to_le_bytes()); + b.extend_from_slice(&field_count.to_le_bytes()); + b.extend_from_slice(&record_size.to_le_bytes()); + b.extend_from_slice(&(strings.len() as u32).to_le_bytes()); + b.extend_from_slice(records); + b.extend_from_slice(strings); + b + } + + const FIELD_COUNT: u32 = 23; + const RECORD_SIZE: u32 = FIELD_COUNT * 4; + const OFF_EMPTY: u32 = 0; + + fn u32le(v: u32) -> [u8; 4] { + v.to_le_bytes() + } + + /// A growable string block: offset 0 is always `""` (an absent column resolves there); each + /// `push` appends a NUL-terminated string and returns its offset. + #[derive(Default)] + struct StringBlock(Vec); + impl StringBlock { + fn new() -> Self { + Self(vec![0u8]) // offset 0 = "" + } + fn push(&mut self, s: &str) -> u32 { + let off = self.0.len() as u32; + self.0.extend_from_slice(s.as_bytes()); + self.0.push(0); + off + } + } + + /// The ammo-display **shape rule** on the real build-5875 rows (decision 0099 phase 5): a + /// projectile display carries its flight model in the **right** slot only (`Ammo\` dir) — + /// verified over every display a class-6 projectile item resolves to (17 rows, vmangos + /// `item_template`) — while a thrown weapon fills the **left** slot (`Weapon\` dir; the weapon + /// itself flies). The missile spawner keys its dir choice on that shape; the client keys the + /// same fork on the wire ammo block's InventoryType (`0x19`=THROWN, wow-re + /// `item-visual-enchant.md` §4) — identical output on every real row. Skips without client data. + #[test] + fn real_ammo_displays_carry_flight_models_right_thrown_left() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo"); + + // Rough Arrow's display: flight model + its object skin, right slot, left empty. + let arrow = cat.get(5996).expect("arrow display"); + assert_eq!(arrow.model, [None, Some("arrowflight_01.m2".into())]); + assert_eq!(arrow.model_texture[1].as_deref(), Some("Arrow_A_01Brown")); + // Light Shot's display: the bullet pair. + let shot = cat.get(5998).expect("bullet display"); + assert_eq!(shot.model[1].as_deref(), Some("bulletflight_01.m2")); + + // Balanced Throwing Dagger's display: the weapon model in the LEFT slot (it lives in + // `Item\ObjectComponents\Weapon\`, verified against the MPQ listing). + let thrown = cat.get(16752).expect("thrown display"); + assert_eq!(thrown.model[0].as_deref(), Some("thrown_1h_dagger_a_01.m2")); + assert_eq!( + thrown.model_texture[0].as_deref(), + Some("Thrown_1H_Dagger_A_01Copper") + ); + } + + #[test] + fn parses_model_and_texture_independently_and_skips_empty_columns() { + let mut strings = StringBlock::new(); + let off_model = strings.push("Shield_Round_A_01.mdx"); + let off_texture = strings.push("Buckler_Damaged_A_01Purple"); + + let mut rec = Vec::with_capacity(RECORD_SIZE as usize); + rec.extend(u32le(18730)); // ID — the Worn Wooden Shield's real displayId + rec.extend(u32le(off_model)); // ModelNameLeft + rec.extend(u32le(OFF_EMPTY)); // ModelNameRight — absent + rec.extend(u32le(off_texture)); // ModelTextureLeft + rec.extend(u32le(OFF_EMPTY)); // ModelTextureRight — absent + rec.extend(u32le(OFF_EMPTY)); // Icon — unread + rec.extend(u32le(1)); // GeosetGroup0 + rec.extend(u32le(0)); // GeosetGroup1 + rec.extend(u32le(0)); // GeosetGroup2 + rec.extend(u32le(0)); // Unk9 + rec.extend(u32le(0)); // Unk10 + rec.extend(u32le(21)); // ItemGroupSoundsID + rec.extend(u32le(0)); // HelmVisMale + rec.extend(u32le(0)); // HelmVisFemale + for _ in 0..8 { + rec.extend(u32le(OFF_EMPTY)); // region textures — this row paints none + } + rec.extend(u32le(0)); // Unk22 + assert_eq!(rec.len(), RECORD_SIZE as usize); + + let bytes = build_wdbc(1, FIELD_COUNT, RECORD_SIZE, &rec, &strings.0); + let rs = parse(&bytes, item_display_info_schema(), "test").expect("synthetic DBC parses"); + let catalog = catalog_from_records(rs); + + assert_eq!(catalog.len(), 1); + let display = catalog.get(18730).expect("displayId 18730 present"); + + // The model normalizer applied (lowercased, `.mdx` → `.m2`) — reused, not duplicated. + assert_eq!( + display.model[0].as_deref(), + Some("shield_round_a_01.m2"), + "model name normalized like any other model path" + ); + assert_eq!(display.model[1], None, "empty ModelNameRight column ⇒ None"); + + // The texture is an independently-resolved basename — NOT derived from the model name (the + // module-doc trap this test guards). + assert_eq!( + display.model_texture[0].as_deref(), + Some("Buckler_Damaged_A_01Purple"), + "model texture is unrelated to the model's own basename" + ); + assert_eq!(display.model_texture[1], None); + + assert_eq!(display.geoset_groups, [1, 0, 0]); + assert!( + display.region_textures.iter().all(Option::is_none), + "a held-item row paints no body regions" + ); + assert_eq!( + display.group_sounds, 21, + "field 11 is the ItemGroupSounds id" + ); + } + + #[test] + fn unknown_display_id_misses() { + let rec = vec![0u8; RECORD_SIZE as usize]; // ID 0, everything else empty/zero + let bytes = build_wdbc(1, FIELD_COUNT, RECORD_SIZE, &rec, &StringBlock::new().0); + let rs = parse(&bytes, item_display_info_schema(), "test").expect("synthetic DBC parses"); + let catalog = catalog_from_records(rs); + assert_eq!(catalog.len(), 1); + assert!(catalog.get(999).is_none()); + } +} diff --git a/crates/benilla-formats/src/itemsets.rs b/crates/benilla-formats/src/itemsets.rs new file mode 100644 index 00000000..b3dced6f --- /dev/null +++ b/crates/benilla-formats/src/itemsets.rs @@ -0,0 +1,139 @@ +//! ItemSet.dbc — the item-set catalog behind the tooltip's SET block (wow-re +//! `ui/scratch/tooltip-content-law.md` §22: set name "(owned/total)" gold, per-member lines +//! pale-cream/gray, threshold bonuses green/gray via the `$`-token engine). +//! +//! Record layout per vmangos `ItemSetEntry` (`DBCStructure.h`, the 1.12 branch): id@0, the 8+1 +//! localized name block @1..9, itemId[17]@10..26 (0-padded), setSpellID[8]@27..34, +//! setThreshold[8]@35..42 (each spell's required equipped count), requiredSkill@43, +//! requiredSkillRank@44 — 45 fields. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const ITEM_SET: &str = "DBFilesClient\\ItemSet.dbc"; + +/// One set: display name, the member item ids (nonzero of the 17 slots), the threshold bonuses +/// (`(required equipped count, spell id)`, nonzero spells only, in the DBC's stored slot order +/// — the tooltip sorts threshold-ascending at print time, like the client's qsort `0x52e5c0`), +/// and the set-level skill requirement. +#[derive(Debug, Clone)] +pub struct ItemSetInfo { + pub name: String, + pub items: Vec, + pub bonuses: Vec<(u32, u32)>, + pub required_skill: u32, + pub required_skill_rank: u32, +} + +/// ItemSet.dbc loaded into an id → row map. +pub struct ItemSetCatalog { + sets: HashMap, +} + +impl ItemSetCatalog { + /// The set row for an item template's `itemset` id, or `None` for an id the DBC lacks. + pub fn set(&self, id: u32) -> Option<&ItemSetInfo> { + self.sets.get(&id) + } + + /// Number of rows (for logging/diagnostics). + pub fn len(&self) -> usize { + self.sets.len() + } + + /// Whether no rows loaded. + pub fn is_empty(&self) -> bool { + self.sets.is_empty() + } +} + +fn item_set_schema() -> Schema { + let mut s = Schema::new("ItemSet"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + for i in 0..8 { + s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String)); + } + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + for i in 0..17 { + s.add_field(SchemaField::new(format!("Item{i}"), FieldType::UInt32)); + } + for i in 0..8 { + s.add_field(SchemaField::new(format!("Spell{i}"), FieldType::UInt32)); + } + for i in 0..8 { + s.add_field(SchemaField::new(format!("Threshold{i}"), FieldType::UInt32)); + } + s.add_field(SchemaField::new("RequiredSkill", FieldType::UInt32)); + s.add_field(SchemaField::new("RequiredSkillRank", FieldType::UInt32)); + s +} + +/// Load ItemSet.dbc from the patch chain into an [`ItemSetCatalog`]. +pub fn load_item_sets(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(ITEM_SET) + .with_context(|| format!("reading {ITEM_SET}"))?; + let rs = parse(&bytes, item_set_schema(), "ItemSet")?; + let mut sets = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1)) else { + continue; + }; + let at = |i| u32_at(r, i).unwrap_or(0); + sets.insert( + id, + ItemSetInfo { + name, + items: (10..27).map(at).filter(|&i| i != 0).collect(), + bonuses: (0..8) + .filter_map(|i| { + let spell = at(27 + i); + (spell != 0).then(|| (at(35 + i), spell)) + }) + .collect(), + required_skill: at(43), + required_skill_rank: at(44), + }, + ); + } + Ok(ItemSetCatalog { sets }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Data-gated on the real 5875 DBC (172 rows): Vestments of the Devout (182, the priest + /// dungeon set) carries 8 members + its bonus ladder; Defias Leather (161) carries 5. + /// Bonuses load in the DBC's stored slot order (The Gladiator stores 3,2,5,4 — the tooltip + /// sorts at print time). Every skill-gated set carries a nonzero rank (the builder's + /// rank-0 format leg is data-empty). Skips without client data. + #[test] + fn item_sets_load_from_the_chain() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_item_sets(&mut chain).expect("ItemSet.dbc loads"); + assert!(!cat.is_empty()); + let devout = cat.set(182).expect("Vestments of the Devout row"); + assert_eq!(devout.name, "Vestments of the Devout"); + assert_eq!(devout.items.len(), 8); + assert!(!devout.bonuses.is_empty()); + assert!(devout.bonuses.iter().all(|&(n, s)| n >= 2 && s != 0)); + assert_eq!(cat.set(161).expect("Defias Leather row").items.len(), 5); + assert!( + cat.sets + .values() + .all(|s| s.required_skill == 0 || s.required_skill_rank > 0), + "a skill-gated set with rank 0 would need the builder's rank-less format leg" + ); + } +} diff --git a/crates/benilla-formats/src/itemsubclass.rs b/crates/benilla-formats/src/itemsubclass.rs new file mode 100644 index 00000000..fdf46f44 --- /dev/null +++ b/crates/benilla-formats/src/itemsubclass.rs @@ -0,0 +1,216 @@ +//! ItemSubClass.dbc — per `(class, subclass)`: the alternate-proficiency fields and the +//! display gate the item tooltip's slot|type line reads (wow-re builder `0x52b650`, the +//! `0xc0db90` row cache). +//! +//! The builder consumes exactly three fields beyond the key: **prerequisiteProficiency@2 / +//! postrequisiteProficiency@3** (−1 = none; a weapon whose own subclass bit is missing from the +//! player's proficiency mask is still usable when the alternate's bit is set — the slot cell's +//! red instead of the type cell's), and **displayFlags@5** bit 0 (suppress the type name — the +//! "Miscellaneous" family: rings, trinkets, shirts never print an armor type). +//! +//! Record layout (no id column; keyed by class+subclass, 28 fields): class@0, subClass@1, +//! prerequisiteProficiency@2, postrequisiteProficiency@3, flags@4, displayFlags@5, +//! weaponParrySeq@6, weaponReadySeq@7, weaponAttackSeq@8, weaponSwingSize@9, displayName +//! 8+1 @10..18, verboseName 8+1 @19..27 — the offsets the builder reads (`[row+2]`, `[row+3]`, +//! byte of `[row+5]`, `[row+locale+10]`) land on exactly this shape. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{i32_at, parse, u32_at}; +use crate::Chain; + +const ITEM_SUB_CLASS: &str = "DBFilesClient\\ItemSubClass.dbc"; + +/// One row's tooltip-relevant fields. +#[derive(Debug, Clone, Copy)] +pub struct ItemSubClassInfo { + /// Alternate subclasses whose proficiency also permits use (−1 = none). The builder's + /// short-circuit: prerequisite wins when present; postrequisite is only consulted when + /// prerequisite is −1. + pub prerequisite_proficiency: i32, + pub postrequisite_proficiency: i32, + /// Bit 0 = never print the type name on the slot|type line. + pub display_flags: u32, +} + +/// ItemSubClass.dbc keyed by `(class, subclass)`. +pub struct ItemSubClassCatalog { + rows: HashMap<(u32, u32), ItemSubClassInfo>, + /// The crafting book's header vocabulary (0437's TU-B fold-back): the resolved display name, + /// by the client's own byte law — **VerboseName** (`row + locale·4 + 0x4c`, enUS column 19) + /// when non-empty, else **DisplayName** (`+0x28`, column 10). "One-Handed Swords" over + /// "Sword"; plain "Cloth" where no verbose form exists. + names: HashMap<(u32, u32), String>, +} + +impl ItemSubClassCatalog { + /// The alternate proficiency subclass for `(class, subclass)` — the builder's exact + /// sentinel walk: prerequisite if not −1, else postrequisite if not −1, else `None`. + pub fn proficiency_alt(&self, class: u32, subclass: u32) -> Option { + let r = self.rows.get(&(class, subclass))?; + [r.prerequisite_proficiency, r.postrequisite_proficiency] + .into_iter() + .find(|&v| v != -1) + .map(|v| v as u32) + } + + /// The subclass display name (verbose-first, the wow-re `tradeskill` node's byte law) — the + /// crafting book's group header text; `None` for an unknown key. + pub fn name(&self, class: u32, subclass: u32) -> Option<&str> { + self.names.get(&(class, subclass)).map(String::as_str) + } + + /// Whether the type name is suppressed (displayFlags bit 0). + pub fn hides_name(&self, class: u32, subclass: u32) -> bool { + self.rows + .get(&(class, subclass)) + .is_some_and(|r| r.display_flags & 1 != 0) + } + + /// Number of rows (for logging/diagnostics). + pub fn len(&self) -> usize { + self.rows.len() + } + + /// Whether no rows loaded. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +fn item_sub_class_schema() -> Schema { + let mut s = Schema::new("ItemSubClass"); + s.add_field(SchemaField::new("Class", FieldType::UInt32)); + s.add_field(SchemaField::new("SubClass", FieldType::UInt32)); + s.add_field(SchemaField::new( + "PrerequisiteProficiency", + FieldType::Int32, + )); + s.add_field(SchemaField::new( + "PostrequisiteProficiency", + FieldType::Int32, + )); + s.add_field(SchemaField::new("Flags", FieldType::UInt32)); + s.add_field(SchemaField::new("DisplayFlags", FieldType::UInt32)); + for name in ["ParrySeq", "ReadySeq", "AttackSeq", "SwingSize"] { + s.add_field(SchemaField::new(format!("Weapon{name}"), FieldType::UInt32)); + } + for i in 0..8 { + s.add_field(SchemaField::new( + format!("DisplayName{i}"), + FieldType::String, + )); + } + s.add_field(SchemaField::new("DisplayNameFlags", FieldType::UInt32)); + for i in 0..8 { + s.add_field(SchemaField::new( + format!("VerboseName{i}"), + FieldType::String, + )); + } + s.add_field(SchemaField::new("VerboseNameFlags", FieldType::UInt32)); + s +} + +/// Load ItemSubClass.dbc from the patch chain. +pub fn load_item_sub_classes(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(ITEM_SUB_CLASS) + .with_context(|| format!("reading {ITEM_SUB_CLASS}"))?; + let rs = parse(&bytes, item_sub_class_schema(), "ItemSubClass")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + let mut names = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(class), Some(subclass)) = (u32_at(r, 0), u32_at(r, 1)) else { + continue; + }; + rows.insert( + (class, subclass), + ItemSubClassInfo { + prerequisite_proficiency: i32_at(r, 2).unwrap_or(-1), + postrequisite_proficiency: i32_at(r, 3).unwrap_or(-1), + display_flags: u32_at(r, 5).unwrap_or(0), + }, + ); + // VerboseName enUS (col 19) first, DisplayName enUS (col 10) fallback — the struct doc's + // byte law. Empty both → no name row (the header renders blank, faithfully unlikely). + let name = crate::dbc::str_at(&rs, r, 19) + .filter(|n| !n.is_empty()) + .or_else(|| crate::dbc::str_at(&rs, r, 10).filter(|n| !n.is_empty())); + if let Some(name) = name { + names.insert((class, subclass), name); + } + } + Ok(ItemSubClassCatalog { rows, names }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The header-name law on the real 5875 file (0446's TU-B fold-back): verbose-first, + /// display fallback. Skips without client data. + #[test] + fn real_subclass_names_resolve_verbose_first() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_item_sub_classes(&mut chain).expect("load ItemSubClass.dbc"); + assert_eq!(cat.name(2, 7), Some("One-Handed Swords"), "verbose wins"); + assert_eq!(cat.name(4, 1), Some("Cloth")); + assert_eq!( + cat.name(5, 0), + Some("Reagent"), + "display fallback when verbose empty" + ); + assert_eq!(cat.name(0, 0), Some("Consumable")); + assert_eq!(cat.name(99, 0), None); + } + + /// Data-gated on the real 5875 DBC. Prints the live prereq/postreq and displayFlags rows so + /// a schema slip is visible, and pins the known shape. Skips without client data. + #[test] + fn item_sub_classes_load_from_the_chain() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc loads"); + assert!(!cat.is_empty()); + // The live alt pairs come in prerequisite/postrequisite couples per weapon family: + // 2H Axe (2,1) ← 1H Axe via prerequisite, 1H Mace (2,4) → 2H Mace via POSTrequisite + // (the sentinel short-circuit's second leg), 2H Sword (2,8) ← 1H Sword, and Shield + // (4,6) ← Buckler. Print the full list so a schema slip is visible. + for ((c, sc), r) in { + let mut v: Vec<_> = cat.rows.iter().map(|(&k, v)| (k, *v)).collect(); + v.sort_by_key(|&((c, sc), _)| (c, sc)); + v + } { + if r.prerequisite_proficiency != -1 || r.postrequisite_proficiency != -1 { + eprintln!( + "alt: class {c} sub {sc} pre {} post {}", + r.prerequisite_proficiency, r.postrequisite_proficiency + ); + } + } + assert_eq!(cat.proficiency_alt(2, 1), Some(0)); + assert_eq!(cat.proficiency_alt(2, 4), Some(5)); + assert_eq!(cat.proficiency_alt(2, 8), Some(7)); + assert_eq!(cat.proficiency_alt(4, 6), Some(5)); + // Daggers stand alone — no other proficiency softens a dagger's red. + assert_eq!(cat.proficiency_alt(2, 15), None); + // displayFlags bit 0: Miscellaneous armor (rings/trinkets/shirts) hides its type name; + // ordinary armor and weapons show theirs. + assert!(cat.hides_name(4, 0)); + assert!(!cat.hides_name(4, 1), "Cloth prints"); + assert!(!cat.hides_name(2, 7), "Sword prints"); + } +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs new file mode 100644 index 00000000..f5dc604c --- /dev/null +++ b/crates/benilla-formats/src/lib.rs @@ -0,0 +1,570 @@ +//! `benilla-formats` — adapters and CLI tooling over WoW 1.12.1 (build 5875) asset formats. +//! +//! Every asset format is **in-repo** (decision 0021): MPQ archives (`benilla-mpq`), BLP2 textures +//! (`benilla-blp`), DBC tables (`benilla-dbc`), WDT tile tables (`benilla-wdt`), ADT terrain +//! (`benilla-adt`), WMO buildings (`benilla-wmo`), and M2 models (`benilla-m2`). This crate adds our +//! schema layer, the patch-chain helper ([`Chain`]), and the `benilla-extract` CLI, and keeps data in +//! **raw WoW coordinates** (the Bevy transform lives in `benilla`). +//! +//! Phase 1: MPQ list/extract, BLP -> PNG, DBC -> CSV. + +use std::io::Cursor; +use std::path::Path; + +use anyhow::{Context, Result}; +use benilla_dbc::{DbcParser, FieldType, Schema, SchemaField}; + +mod chain; +pub use chain::{Chain, ChainEntry}; +mod characters; +pub use characters::{ + CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EquipGeosets, StartOutfitItem, +}; +mod creatures; +mod dbc; +mod unit_blood; +pub use creatures::{load_creature_catalog, CreatureCatalog, CreatureModel, NpcAppearance}; +pub use unit_blood::{load_blood_catalog, BloodCatalog}; +mod itemsets; +pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo}; +mod itemsubclass; +pub use itemsubclass::{load_item_sub_classes, ItemSubClassCatalog, ItemSubClassInfo}; +mod factions; +pub use factions::{ + load_faction_catalog, reputation_rank, FactionCatalog, FactionInfo, FactionTemplate, Reaction, +}; +mod creature_types; +pub use creature_types::{load_creature_type_flags, CreatureTypeFlags}; +mod gameobjects; +pub use gameobjects::{ + go_sound_slot, load_gameobject_catalog, load_gameobject_sounds, GameObjectCatalog, + GameObjectSounds, +}; +mod durability; +pub use durability::{load_durability_tables, DurabilityTables}; +mod bank_bag_slot_prices; +pub use bank_bag_slot_prices::{load_bank_bag_slot_prices, BankBagSlotPrices}; +mod stationery; +pub use stationery::{load_stationery_catalog, StationeryCatalog, STATIONERY_DEFAULT}; +mod lock; +pub use lock::{ + load_lock_catalog, LockCatalog, LockSlot, LOCK_KEY_ITEM, LOCK_KEY_NONE, LOCK_KEY_SKILL, + MAX_LOCK_SLOTS, +}; +mod lock_type; +pub use lock_type::{load_lock_type_catalog, LockTypeCatalog}; +mod items; +pub use items::{load_item_display_catalog, ItemDisplay, ItemDisplayCatalog}; +mod item_sounds; +pub use item_sounds::{load_item_group_sounds, ItemGesture, ItemGroupSoundsCatalog}; +mod ground_effects; +pub use ground_effects::{ + load_ground_effect_catalog, scatter_ground_doodads, GroundDoodadPlacement, GroundEffect, + GroundEffectCatalog, +}; +mod light; +pub use light::{Atmosphere, LightCatalog}; +mod loading_screen; +pub use loading_screen::{load_loading_screens, LoadingScreenCatalog}; +mod liquid; +pub use liquid::{LiquidKind, LiquidMesh}; +mod maps; +pub use maps::{load_map_catalog, MapCatalog}; +mod anim_data; +pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry}; +mod quest_headers; +pub use quest_headers::{load_quest_header_names, QuestHeaderNames}; +mod area_sound; +pub use area_sound::{ + load_area_sound_catalog, AreaAudio, AreaEntry, AreaSoundCatalog, SoundAmbienceEntry, + ZoneIntroEntry, ZoneMusicEntry, +}; +mod creature_sound; +pub use creature_sound::{load_creature_voice_catalog, CreatureVoice, CreatureVoiceCatalog}; + +mod npc_greeting; +pub use npc_greeting::{load_npc_greeting_catalog, NpcGreeting, NpcGreetingCatalog}; +mod emotes; +pub use emotes::{load_emote_sound_catalog, EmoteSoundCatalog}; +mod environmental_damage; +pub use environmental_damage::{load_environmental_damage, EnvironmentalDamageTable}; +mod footsteps; +pub use footsteps::{load_footstep_catalog, FootstepCatalog}; +mod sound_entries; +pub use sound_entries::{load_sound_kit_catalog, sound_kit_flags, SoundKit, SoundKitCatalog}; +mod sound_provider; +pub use sound_provider::{load_sound_provider_catalog, SoundProvider, SoundProviderCatalog}; +mod sound_water; +pub use sound_water::{load_water_sound_catalog, WaterSoundCatalog}; +mod weapon_impact; +pub use weapon_impact::{ + impact_slot, load_weapon_impact_catalog, WeaponImpactCatalog, WeaponImpactRow, +}; +mod sheathe; +pub use sheathe::{load_sheathe_sound_catalog, SheatheSoundCatalog, SheatheSounds}; +mod wmo_area; +pub use wmo_area::{load_wmo_area_catalog, WmoArea, WmoAreaCatalog}; +mod spells; +pub use spells::{ + load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_durations, + load_spell_radii, load_spell_ranges, substitute, ShapeshiftForm, SpellCastTime, + SpellCastTimeCatalog, SpellCatalog, SpellDisplay, SpellDuration, SpellDurationCatalog, + SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, + ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, SPELL_EFFECT_CREATE_ITEM, + SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_SKINNING, + SPELL_EFFECT_TRADE_SKILL, +}; +mod skill_lines; +pub use skill_lines::{ + load_skill_line_catalog, SkillLineCatalog, SkillLineInfo, SlaInfo, SKILL_CATEGORY_NOT_DISPLAYED, +}; +mod spell_focus; +pub use spell_focus::{load_spell_focus_catalog, SpellFocusCatalog}; +mod talents; +pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX_TALENT_RANK}; +mod spell_visual; +pub use spell_visual::{ + load_spell_visual_catalog, SpellVisualCatalog, VisualKit, VisualStages, KIT_SLOT_TAGS, + MISSILE_ATTACH_TABLE, +}; +mod particles; +pub use particles::{ + parse_m2_particle_emitters, OverLife, ParticleBlend, ParticleEmitterDef, ParticleShape, + SplineData, +}; +mod ribbons; +pub use ribbons::{parse_m2_ribbon_emitters, RibbonEmitterDef}; +mod value_track; +pub use value_track::{OnOffTrack, TrackValue, ValueTrack}; +mod models; +pub use models::{ + accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses, + load_m2_animation_summary, load_m2_bounds, load_m2_collision_hull, load_m2_mesh, + load_m2_mesh_skinned, load_object_model, load_wmo, load_wmo_collision_tris, + m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_summary, + parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera, + parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones, + parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera, + parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, + parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, + wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, + wmo_group_submeshes, wmo_root_id, AlphaAnim, AnimEvent, Billboard, BillboardKind, BoneKeys, + BoneScaleAnim, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, + FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, + M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, + RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, + WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, + WmoPortals, WmoRoot, +}; +mod terrain; +pub use terrain::{ + adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, load_tile_mesh, + load_tiles_around, mcsh_shadowed_at, terrain_height_at, triangle_z_at, ChunkMesh, Doodad, + MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY, TILE_SIZE, +}; +mod wdl; +/// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles +/// (and the minimap uses to place its tile art, decision 0203: minimap `map_.blp` names +/// share the ADT index order, chain-verified — `AhnQiraj_27_46.adt` exists, the swap doesn't). +pub use benilla_wdt::{tile_to_world, world_to_tile, WdtFile, WdtReader, WowVersion}; +pub use wdl::{WdlFile, WdlTileMesh}; + +// The map arc (decision 0203), phase 0: the minimap tile hash catalog + the world-map DBCs. +mod minimap_translate; +pub use minimap_translate::{load_minimap_translate, MinimapTranslate}; +mod world_map_area; +pub use world_map_area::{load_world_map_area_catalog, WorldMapArea, WorldMapAreaCatalog}; +mod world_map_overlay; +pub use world_map_overlay::{ + load_world_map_overlay_catalog, WorldMapOverlay, WorldMapOverlayCatalog, +}; +mod world_map_continent; +pub use world_map_continent::{ + load_world_map_continent_catalog, WorldMapContinent, WorldMapContinentCatalog, +}; +mod area_poi; +pub use area_poi::{load_area_poi_catalog, AreaPoi, AreaPoiCatalog}; +mod area_table; +pub use area_table::{load_area_table_catalog, AreaTableCatalog, AreaTableRow}; +mod zone_map; +pub use zone_map::{load_zone_map, ZONE_MAP_EDGE}; + +// The transport arc (decision 0438), phase 0: the taxi/transport path DBC adapter + timetable. +mod taxi; +pub use taxi::{load_taxi_path_nodes, TaxiPathNode, TaxiPathNodes}; +mod transport_period; +mod transports; +pub use transports::{TransportSample, TransportTimetable}; +// The transport arc phase 3's second consumer: type-11 elevator/lift keyframe paths. +mod elevators; +pub use elevators::{ + elevator_period_ms, elevator_sample, load_elevator_paths, ElevatorKeyframe, ElevatorPaths, +}; + +// The taxi arc (decision 0484), phase 1: the flight-master catalogs — node identity/position/ +// per-team mount (`TaxiNodes.dbc`) and the direct-hop fare table (`TaxiPath.dbc`). Distinct from +// `taxi`/`TaxiPathNode.dbc` above, which carries a path's waypoints, not its node list or price. +mod taxi_nodes; +pub use taxi_nodes::{load_taxi_nodes, TaxiNode, TaxiNodes}; +mod taxi_path; +pub use taxi_path::{load_taxi_paths, TaxiPath, TaxiPaths}; + +/// Vanilla 1.12.1 MPQ load order, **lowest priority first**. +/// +/// Later archives override earlier ones for files sharing an internal path. The base +/// content archives hold the bulk of the data but carry **no `(listfile)`**; the master +/// `(listfile)` (and most overrides) live in `patch.MPQ` / `patch-2.MPQ`, which is why the +/// chain — not a single archive — is the correct unit to read from. Verified against the +/// build-5875 client (`patch.MPQ` listfile = 26,350 entries). +pub const VANILLA_LOAD_ORDER: &[&str] = &[ + "base.MPQ", + "dbc.MPQ", + "fonts.MPQ", + "interface.MPQ", + "misc.MPQ", + "model.MPQ", + "sound.MPQ", + "speech.MPQ", + "terrain.MPQ", + "texture.MPQ", + "wmo.MPQ", + "patch.MPQ", + "patch-2.MPQ", +]; + +/// Open a [`Chain`] from either a single `.MPQ` file or a vanilla `Data` directory. Thin wrapper over +/// [`Chain::open`], kept as the established entry point used across the crate and the CLI. +pub fn open_chain(path: &Path) -> Result { + Chain::open(path) +} + +/// Decode a BLP texture from raw archive bytes and write it as a PNG at `out`. +/// +/// Returns the source `(width, height)`. Used by the `benilla-extract` CLI. **1.12 textures are BLP2** +/// (DXT-compressed or paletted Raw1 — NOT BLP1; verified). Decoded in-repo by [`benilla_blp`] +/// (decision 0021); mip level 0 (full resolution) is written. +pub fn blp_to_png(blp_bytes: &[u8], out: &Path) -> Result<(u32, u32)> { + let (w, h, rgba) = blp_to_rgba(blp_bytes)?; + image::RgbaImage::from_raw(w, h, rgba) + .ok_or_else(|| anyhow::anyhow!("BLP RGBA buffer size mismatch"))? + .save(out) + .with_context(|| format!("writing PNG {}", out.display()))?; + Ok((w, h)) +} + +/// Decode a BLP texture (raw bytes) to RGBA8: `(width, height, pixels)`. +/// +/// For feeding GPU textures (the renderer) without a disk round-trip; mip level 0. +pub fn blp_to_rgba(blp_bytes: &[u8]) -> Result<(u32, u32, Vec)> { + let decoded = + benilla_blp::decode(blp_bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?; + let level0 = decoded + .mips + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("BLP has no level 0"))?; + Ok((level0.width, level0.height, level0.rgba)) +} + +/// Read a texture from the chain by internal path (accepts `/` or `\`) and decode to RGBA8. +pub fn read_texture_rgba(chain: &mut Chain, path: &str) -> Result<(u32, u32, Vec)> { + let name = path.replace('/', "\\"); + let bytes = chain + .read_file(&name) + .with_context(|| format!("reading texture '{name}'"))?; + blp_to_rgba(&bytes) +} + +/// A decoded BLP, mip-major: each entry is one authored mip level's RGBA8 bytes, level 0 first. +/// **Never empty**: the chain length is `BlpHeader::mipmaps_count().max(1)` — a no-mipmaps BLP (the +/// count formula reports 0) still yields a 1-level chain (level 0), matching `benilla_blp::decode`'s +/// own guarantee that `mips` is never empty (see [`blp_bytes_to_mip_chain`]). +#[derive(Debug, Clone)] +pub struct BlpMipChain { + /// Mip-0 dimensions; level i's dims are `(width >> i).max(1) × (height >> i).max(1)`. + pub width: u32, + pub height: u32, + /// One RGBA8 buffer per authored mip level, level 0 first. **Authored, not regenerated** — + /// the BLP's pre-baked mip levels are what the real 1.12 client uploads ("the BLP's own stored mip + /// chain is used verbatim — no client-side mip regeneration"). Using these instead of + /// CPU-resampling mip 0 in gamma byte space is the + /// C2 fix: gamma-byte averaging of texels darkens mid-tones (the historical "dim/cool" + /// terrain tone gap). + pub mips: Vec>, +} + +impl BlpMipChain { + /// Width × height of the mip level `i`, clamped to 1×1 minimum (wgpu / GL convention). + pub fn mip_size(&self, i: u32) -> (u32, u32) { + ((self.width >> i).max(1), (self.height >> i).max(1)) + } +} + +/// Decode every authored mip level of a BLP texture from the chain into `Rgba8Unorm` buffers. +/// +/// One RGBA8 allocation per mip level (8 for a 256² with 8 mips). Caller is responsible for any +/// further processing (concatenation, upload, BCn re-encode). The mip pyramid is the BLP's own, +/// not regenerated — the C2-faithful behaviour. +pub fn read_texture_mip_chain(chain: &mut Chain, path: &str) -> Result { + let name = path.replace('/', "\\"); + let bytes = chain + .read_file(&name) + .with_context(|| format!("reading texture '{name}'"))?; + blp_bytes_to_mip_chain(&bytes).with_context(|| format!("decoding texture '{name}'")) +} + +/// Decode every authored mip level of an **in-memory** BLP into `Rgba8Unorm` buffers — the BLP's own +/// stored pyramid, used verbatim (no client-side regeneration; the C2-faithful behaviour). This is the +/// bytes-in entry point used by the Bevy `AssetLoader`; [`read_texture_mip_chain`] is the +/// chain-reading wrapper. +pub fn blp_bytes_to_mip_chain(bytes: &[u8]) -> Result { + let decoded = benilla_blp::decode(bytes).map_err(|e| anyhow::anyhow!("decoding BLP: {e}"))?; + // `decode` guarantees `mips` is never empty: it always decodes level 0 regardless of the + // authored mip-chain count (`benilla_blp::decode`, `n = chain.clamp(1, 16)`, so the loop that + // fills `mips` runs at least once) — a no-mipmaps BLP reports `mip_chain_count() == 0` but still + // carries `mips[0]`. Take at least that one level: the chain we return is never empty, so a + // mipmap-less BLP yields a 1-level chain here, not zero. + let (width, height, count) = ( + decoded.width, + decoded.height, + decoded.mip_chain_count().max(1), + ); + let mips: Vec> = decoded + .mips + .into_iter() + .take(count) + .map(|m| m.rgba) + .collect(); + Ok(BlpMipChain { + width, + height, + mips, + }) +} + +/// A hand-defined schema for a known 1.12 DBC, keyed by base filename, or `None`. +/// +/// DBC files carry no column types, so the schema is ours to supply (from wowdev.wiki + +/// the verified file header). Vanilla localized strings occupy **9 dwords** (8 locale +/// offsets + 1 flags); only the enUS slot is populated in an enUS client. As we need more +/// DBCs (Map, AreaTable, ...) add them here. +fn schema_for(dbc_name: &str) -> Option { + let base = dbc_name.rsplit(['/', '\\']).next().unwrap_or(dbc_name); + + if base.eq_ignore_ascii_case("TaxiNodes.dbc") { + // 16 fields (record_size 64), verified against build 5875. + let mut s = Schema::new("TaxiNodes"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("MapID", FieldType::UInt32)); + s.add_field(SchemaField::new("X", FieldType::Float32)); + s.add_field(SchemaField::new("Y", FieldType::Float32)); + s.add_field(SchemaField::new("Z", FieldType::Float32)); + s.add_field(SchemaField::new("Name", FieldType::String)); // enUS (locale 0) + s.add_field(SchemaField::new_array( + "NameOtherLocales", + FieldType::String, + 7, + )); + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + s.add_field(SchemaField::new("MountIdHorde", FieldType::UInt32)); + s.add_field(SchemaField::new("MountIdAlliance", FieldType::UInt32)); + s.set_key_field("ID"); + return Some(s); + } + + if base.eq_ignore_ascii_case("AreaTable.dbc") { + // 25 fields (record_size 100), verified against build 5875 (the layout + // `area_table.rs` / `area_sound.rs` load from). + let mut s = Schema::new("AreaTable"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("ContinentID", FieldType::UInt32)); + s.add_field(SchemaField::new("ParentAreaID", FieldType::UInt32)); + s.add_field(SchemaField::new("AreaBit", FieldType::UInt32)); + s.add_field(SchemaField::new("Flags", FieldType::UInt32)); + s.add_field(SchemaField::new("SoundProviderPref", FieldType::UInt32)); + s.add_field(SchemaField::new( + "SoundProviderPrefUnderwater", + FieldType::UInt32, + )); + s.add_field(SchemaField::new("AmbienceID", FieldType::UInt32)); + s.add_field(SchemaField::new("ZoneMusic", FieldType::UInt32)); + s.add_field(SchemaField::new("IntroSound", FieldType::UInt32)); + s.add_field(SchemaField::new("ExplorationLevel", FieldType::UInt32)); + s.add_field(SchemaField::new("AreaName", FieldType::String)); // enUS (locale 0) + s.add_field(SchemaField::new_array( + "AreaNameOtherLocales", + FieldType::String, + 7, + )); + s.add_field(SchemaField::new("AreaNameFlags", FieldType::UInt32)); + s.add_field(SchemaField::new("FactionGroupMask", FieldType::UInt32)); + s.add_field(SchemaField::new_array("LiquidTypeID", FieldType::UInt32, 4)); + s.set_key_field("ID"); + return Some(s); + } + + if base.eq_ignore_ascii_case("GameObjectDisplayInfo.dbc") { + // 12 fields (ID, ModelName, Sound[10]) — the layout `gameobjects.rs`'s own typed adapter + // loads from (verified build 5875). + let mut s = Schema::new("GameObjectDisplayInfo"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("ModelName", FieldType::String)); + for i in 0..10 { + s.add_field(SchemaField::new(format!("Sound{i}"), FieldType::UInt32)); + } + s.set_key_field("ID"); + return Some(s); + } + + if base.eq_ignore_ascii_case("TaxiPath.dbc") { + // 4 fields: ID, FromTaxiNode, ToTaxiNode, Cost — verified build 5875. + let mut s = Schema::new("TaxiPath"); + for name in ["ID", "FromTaxiNode", "ToTaxiNode", "Cost"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.set_key_field("ID"); + return Some(s); + } + + if base.eq_ignore_ascii_case("TaxiPathNode.dbc") { + // 9 fields (ID, PathID, NodeIndex, MapID, LocX/Y/Z, Flags, Delay) — the layout + // `taxi.rs`'s own typed adapter loads from (decision 0438 phase 0, verified build 5875). + let mut s = Schema::new("TaxiPathNode"); + for name in ["ID", "PathID", "NodeIndex", "MapID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for name in ["LocX", "LocY", "LocZ"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + for name in ["Flags", "Delay"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.set_key_field("ID"); + return Some(s); + } + + if base.eq_ignore_ascii_case("TransportAnimation.dbc") { + // 7 fields: ID, TransportID, TimeIndex, PosX, PosY, PosZ, SequenceID — matches vmangos's + // `TransportAnimationEntry` (`DBCStructure.h:709-718`), which only reads the middle five + // (TransportEntry/TimeSeg/X/Y/Z) but the row itself carries all 7 (its own `Id` and + // `MovementId` columns are commented out there, not absent from the file). + let mut s = Schema::new("TransportAnimation"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("TransportID", FieldType::UInt32)); + s.add_field(SchemaField::new("TimeIndex", FieldType::UInt32)); + for name in ["PosX", "PosY", "PosZ"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + s.add_field(SchemaField::new("SequenceID", FieldType::UInt32)); + s.set_key_field("ID"); + return Some(s); + } + + None +} + +/// Parse a DBC (raw archive bytes) using our schema for it and write it as CSV with +/// column headers. Returns `(record_count, field_count)`. Errors if we have no schema for +/// the named DBC (with a hint), so a wrong/missing schema fails loudly rather than silently +/// misaligning columns. +pub fn dbc_to_csv(dbc_bytes: &[u8], dbc_name: &str, out: &Path) -> Result<(u32, u32)> { + let mut cursor = Cursor::new(dbc_bytes); + let parser = + DbcParser::parse(&mut cursor).map_err(|e| anyhow::anyhow!("parsing DBC header: {e}"))?; + let (records, fields) = (parser.header().record_count, parser.header().field_count); + + let schema = schema_for(dbc_name).ok_or_else(|| { + anyhow::anyhow!( + "no schema defined for '{dbc_name}' ({fields} fields); known: TaxiNodes.dbc, \ + AreaTable.dbc, GameObjectDisplayInfo.dbc, TaxiPath.dbc, TaxiPathNode.dbc, \ + TransportAnimation.dbc" + ) + })?; + + let parser = parser + .with_schema(schema) + .map_err(|e| anyhow::anyhow!("applying schema (field count mismatch?): {e}"))?; + let record_set = parser + .parse_records() + .map_err(|e| anyhow::anyhow!("parsing DBC records: {e}"))?; + + let file = std::fs::File::create(out).with_context(|| format!("creating {}", out.display()))?; + benilla_dbc::export_to_csv(&record_set, std::io::BufWriter::new(file)) + .with_context(|| format!("writing CSV {}", out.display()))?; + + Ok((records, fields)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal, complete BLP2 header (magic..=mip_sizes[16], 148 bytes) — mirrors the private + /// builder in `benilla_blp`'s own tests (that crate's helper isn't exported). + #[allow(clippy::too_many_arguments)] + fn blp2_header( + compression: u8, + alpha_bits: u8, + alpha_type: u8, + has_mipmaps: u8, + width: u32, + height: u32, + offsets: [u32; 16], + sizes: [u32; 16], + ) -> Vec { + const HEADER_SIZE: usize = 148; + let mut b = vec![0u8; HEADER_SIZE]; + b[0..4].copy_from_slice(b"BLP2"); + b[4..8].copy_from_slice(&1u32.to_le_bytes()); // content: 1 = Direct + b[8] = compression; + b[9] = alpha_bits; + b[10] = alpha_type; + b[11] = has_mipmaps; + b[12..16].copy_from_slice(&width.to_le_bytes()); + b[16..20].copy_from_slice(&height.to_le_bytes()); + for (i, o) in offsets.iter().enumerate() { + b[20 + i * 4..24 + i * 4].copy_from_slice(&o.to_le_bytes()); + } + for (i, s) in sizes.iter().enumerate() { + b[84 + i * 4..88 + i * 4].copy_from_slice(&s.to_le_bytes()); + } + b + } + + /// THE VERIFIED LATENT PANIC, regression-pinned: a no-mipmaps BLP2 (`has_mipmaps = 0`) makes + /// `benilla_blp::decode` report `mip_chain_count() == 0`, which `blp_bytes_to_mip_chain` used to + /// take literally (`.take(0)`), returning an **empty** `mips` — `benilla-assets`' `world_art_image` + /// then panics on `chain.mips[0]`. `decode` itself always keeps level 0 regardless of the count + /// (see the comment on `blp_bytes_to_mip_chain`), so the fix is to floor the take at 1: this must + /// yield a 1-level chain, never an empty one. + #[test] + fn no_mipmap_blp_yields_a_one_level_chain_not_empty() { + const HEADER_SIZE: usize = 148; + const PALETTE_SIZE: usize = 256 * 4; + let pixel_offset = (HEADER_SIZE + PALETTE_SIZE) as u32; + let pixel_size = 2 * 2 * 4; + let mut offsets = [0u32; 16]; + let mut sizes = [0u32; 16]; + offsets[0] = pixel_offset; + sizes[0] = pixel_size; + // compression 3 = Raw3 (uncompressed BGRA8); has_mipmaps 0. + let mut b = blp2_header(3, 8, 0, 0, 2, 2, offsets, sizes); + b.resize(HEADER_SIZE + PALETTE_SIZE, 0); // palette bytes: unused by Raw3, present anyway + b.extend_from_slice(&[ + 10, 20, 30, 40, // B G R A + 50, 60, 70, 80, // + 90, 100, 110, 120, // + 130, 140, 150, 160, + ]); + + let chain = blp_bytes_to_mip_chain(&b).expect("valid no-mipmap BLP decodes"); + assert_eq!(chain.width, 2); + assert_eq!(chain.height, 2); + assert_eq!( + chain.mips.len(), + 1, + "no-mipmaps BLP must yield 1 level, not 0" + ); + assert_eq!(chain.mips[0].len(), 2 * 2 * 4); + } +} diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs new file mode 100644 index 00000000..7822fc3d --- /dev/null +++ b/crates/benilla-formats/src/light.rs @@ -0,0 +1,611 @@ +//! Authentic vanilla atmosphere from the client's lighting DBCs — so fog distance/color and +//! sun/ambient tint come from Blizzard's authored data, not invented constants. +//! +//! Pipeline: [`Light.dbc`] gives a sphere per area (a world +//! position via [`dbc_to_world`] + a radius in yards) and a `LightParams` id per weather condition; +//! that id indexes the band tables — [`LightIntBand.dbc`] (18 color rows/param) and +//! [`LightFloatBand.dbc`] (6 float rows/param) — each row a value over time-of-day. We sample the +//! **clear-weather** param at a given time and return the fog/sun/ambient/sky values. +//! +//! **Verified against build 5875** (raw-byte decode cross-checked with wowdev.wiki, 2026-05-25): +//! headers are Light = 12 fields/48 B (5 param refs), both bands = 34 fields/136 B +//! (`ID, num, time[16], value[16]`). LightIntBand = 7668 = 426×18 rows and LightFloatBand = +//! 2556 = 426×6 rows — i.e. exactly 18 int / 6 float rows per LightParams, confirming the band row +//! id for param `P` (1-based), band index `b`: `(P-1)*per + b + 1` (== wiki `P*18−17` / `P*6−5`). +//! The int-row→meaning map is the **vanilla** layout (rows 2–6 = the 5 sky stops, 7 = fog, 9 = sun), +//! confirmed by the data itself (row 8 is flat gray = shadow-opacity slot, not a sun). The +//! decoded values match our parser exactly (e.g. Northshire/global-light param 12 noon SkyMiddle +//! = `(58,162,207)`). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; +use crate::Chain; + +mod atmosphere; // the resolved `Atmosphere` output type + the DBC row/field → meaning constants +mod tables; // raw LightIntBand/FloatBand decode + time-of-day interpolation + +pub use atmosphere::Atmosphere; +use atmosphere::{ + FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE, + IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP, + IB_RIVER_SHALLOW, IB_SKY0, IB_SUN_COLOR, LP_GLOW, LP_HIGHLIGHT, LP_OCEAN_DEEP_ALPHA, + LP_OCEAN_SHALLOW_ALPHA, LP_WATER_DEEP_ALPHA, LP_WATER_SHALLOW_ALPHA, +}; +use tables::{band_schema, load_bands, load_float_bands, sample_color, sample_float, Band, DAY}; + +const LIGHT: &str = "DBFilesClient\\Light.dbc"; +const INT_BAND: &str = "DBFilesClient\\LightIntBand.dbc"; +const FLOAT_BAND: &str = "DBFilesClient\\LightFloatBand.dbc"; +const LIGHT_PARAMS: &str = "DBFilesClient\\LightParams.dbc"; + +/// World-unit scale factor on Light.dbc positions/radii (stored as yards × 36). +const POS_SCALE: f32 = 36.0; + +/// Light.dbc weather param slots: [0]=clear, [1]=clear-underwater, [2]=storm, [3]=storm-underwater, +/// [4]=death. +const SLOT_CLEAR: usize = 0; +/// `[1]` clear-underwater: the denser/greener fog + cooler light the client switches to when the eye +/// is submerged (VERIFIED apitrace WoW.18: ~38 yd teal fog + blue ambient/diffuse vs the ~1284 yd +/// surface fog). Same band-row meanings as the clear slot, just a different `LightParams` id. +const SLOT_CLEAR_UNDERWATER: usize = 1; +const SLOT_STORM: usize = 2; +const SLOT_STORM_UNDERWATER: usize = 3; +/// The ghost-world profile — byte-VERIFIED (wow-re death-light.md): the client's ghost watcher +/// writes slot index 4 into the active-slot global `[0xce9bb0]` and the day/night color-table +/// rebuild consumes it INSTANTLY (per-frame rebuild, no blend). Selected while `ghost`, taking +/// priority over weather/underwater exactly as the single global slot does. +const SLOT_DEATH: usize = 4; + +/// Pick the `LightParams` slot for the current ghost/weather/submersion state (see `SLOT_*`). +/// Ghost wins outright — the client keeps ONE active slot, and the ghost watcher owns it while +/// the flag is up. +fn weather_slot(ghost: bool, stormy: bool, underwater: bool) -> usize { + if ghost { + return SLOT_DEATH; + } + match (stormy, underwater) { + (false, false) => SLOT_CLEAR, + (false, true) => SLOT_CLEAR_UNDERWATER, + (true, false) => SLOT_STORM, + (true, true) => SLOT_STORM_UNDERWATER, + } +} + +struct Light { + map: u32, + /// WoW net-protocol **world** coords (yards), converted from the DBC's mirrored/axis-swapped + /// inch frame via [`dbc_to_world`]. Meaningless when `global` (the sentinel was `(0,0,0)`). + pos: [f32; 3], + /// Distance (yards) at which this light is at full strength; inside it `blendAlpha = 1`. + falloff_start: f32, + falloff_end: f32, + /// The continent's `(0,0,0)` fallback light — matches everywhere, lowest priority. + global: bool, + /// LightParams id per weather slot (see `SLOT_*`); 0 = unset. + params: [u32; 5], +} + +/// Convert a `Light.dbc` position to WoW net-protocol **world** coords (yards). The DBC stores +/// continent coords as **inches** (yards × 36) in a mirrored, axis-swapped frame, so it is *not* a +/// plain `/36`. Per wowdev.wiki `LightRec::ConvertDBToGameCoords` (the world-coord correction): +/// `world.x = 17066.666 − z/36`, `world.y = 17066.666 − x/36`, `world.z = y/36`. +/// `17066.666` = half the 34133⅓-yard map (32 tiles × 533⅓). Verified against decoded entries +/// (e.g. Light #51 → `(-8480, 548, 81)`). +fn dbc_to_world(x: f32, y: f32, z: f32) -> [f32; 3] { + const MAP_HALF: f32 = 17066.666; + [ + MAP_HALF - z / POS_SCALE, + MAP_HALF - x / POS_SCALE, + y / POS_SCALE, + ] +} + +/// Parsed lighting tables; query with [`LightCatalog::sample`]. +pub struct LightCatalog { + lights: Vec, + int_bands: HashMap>, + float_bands: HashMap>, + /// `LightParams.dbc` id → glow scalar (field [`LP_GLOW`]); the per-zone bloom composite weight. + light_params_glow: HashMap, + /// `LightParams.dbc` id → `highlightSky` flag (field [`LP_HIGHLIGHT`]) as 0.0/1.0; gates the + /// per-zone dawn/dusk sky-dome warp. + light_params_highlight: HashMap, + /// `LightParams.dbc` id → water-blend alphas `[waterShallow, waterDeep, oceanShallow, oceanDeep]` + /// (fields 5–8). The from-above swatch's per-zone depth-alpha ramp endpoints. + light_params_water_alpha: HashMap, +} + +fn light_schema() -> Schema { + let mut s = Schema::new("Light"); + for (n, t) in [ + ("ID", FieldType::UInt32), + ("continent", FieldType::UInt32), + ("x", FieldType::Float32), + ("y", FieldType::Float32), + ("z", FieldType::Float32), + ("falloffStart", FieldType::Float32), + ("falloffEnd", FieldType::Float32), + ] { + s.add_field(SchemaField::new(n, t)); + } + for i in 0..5 { + s.add_field(SchemaField::new(format!("param{i}"), FieldType::UInt32)); + } + s +} + +/// `LightParams.dbc` — 9 fields, 36 B/record. We only consume `glow` (field [`LP_GLOW`]); the rest are +/// named for clarity (highlightSky flag, skybox/cloud ids, and the four water/ocean blend alphas we +/// don't yet use). The `+0x0C` `cloudTypeID`/reserved slot is 0 in all 5875 records — the engine's glow +/// is the *next* field (+0x10). Field types per wowdev + the byte-exact RE in `bloom-per-zone/`. +fn light_params_schema() -> Schema { + let mut s = Schema::new("LightParams"); + for (n, t) in [ + ("ID", FieldType::UInt32), + ("highlightSky", FieldType::UInt32), + ("lightSkyboxID", FieldType::UInt32), + ("cloudTypeID", FieldType::UInt32), + ("glow", FieldType::Float32), + ("waterShallowAlpha", FieldType::Float32), + ("waterDeepAlpha", FieldType::Float32), + ("oceanShallowAlpha", FieldType::Float32), + ("oceanDeepAlpha", FieldType::Float32), + ] { + s.add_field(SchemaField::new(n, t)); + } + s +} + +/// The client's distance→strength ramp for an area light: `1` within `start`, falling linearly to +/// `0` at `end` (and `0` beyond). Matches `getEnvInfo`'s `blendAlpha`. +fn blend_alpha(dist: f32, start: f32, end: f32) -> f32 { + if dist >= end { + 0.0 + } else if dist <= start || end <= start { + 1.0 + } else { + (end - dist) / (end - start) + } +} + +impl LightCatalog { + /// Read the three lighting DBCs off the patch chain. + pub fn load(chain: &mut Chain) -> Result { + let lights = { + let bytes = chain + .read_file(LIGHT) + .with_context(|| format!("reading {LIGHT}"))?; + let rs = parse(&bytes, light_schema(), "Light")?; + let mut v = Vec::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(map), Some(x), Some(y), Some(z), Some(start), Some(end)) = ( + u32_at(r, 1), + f32_at(r, 2), + f32_at(r, 3), + f32_at(r, 4), + f32_at(r, 5), + f32_at(r, 6), + ) { + // Fields 7..12 are the 5 LightParams refs (clear, clear-water, storm, …). + let mut params = [0u32; 5]; + for (i, p) in params.iter_mut().enumerate() { + *p = u32_at(r, 7 + i).unwrap_or(0); + } + // `(0,0,0)` is the continent-wide fallback sentinel — detect it on the raw + // values (it does NOT survive the world transform). + let global = x == 0.0 && y == 0.0 && z == 0.0; + v.push(Light { + map, + pos: if global { + [0.0; 3] + } else { + dbc_to_world(x, y, z) + }, + falloff_start: start / POS_SCALE, + falloff_end: end / POS_SCALE, + global, + params, + }); + } + } + v + }; + let int_bands = load_bands( + chain, + INT_BAND, + band_schema("LightIntBand", FieldType::UInt32), + )?; + let float_bands = load_float_bands( + chain, + FLOAT_BAND, + band_schema("LightFloatBand", FieldType::Float32), + )?; + let (light_params_glow, light_params_highlight, light_params_water_alpha) = { + let bytes = chain + .read_file(LIGHT_PARAMS) + .with_context(|| format!("reading {LIGHT_PARAMS}"))?; + let rs = parse(&bytes, light_params_schema(), "LightParams")?; + let mut glow = HashMap::with_capacity(rs.records().len()); + let mut highlight = HashMap::with_capacity(rs.records().len()); + let mut water_alpha = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(g) = f32_at(r, LP_GLOW) { + glow.insert(id, g); + } + // highlightSky is an int 0/1 flag; store 0.0/1.0 as the dawn/dusk-warp gate. + if let Some(h) = u32_at(r, LP_HIGHLIGHT) { + highlight.insert(id, if h != 0 { 1.0 } else { 0.0 }); + } + // Water-blend alphas (fields 5–8). Stored only when all four parse; consumers fall back + // to the DEFAULT alphas otherwise. Per-zone the from-above swatch alpha ramp endpoints. + if let (Some(ws), Some(wd), Some(os), Some(od)) = ( + f32_at(r, LP_WATER_SHALLOW_ALPHA), + f32_at(r, LP_WATER_DEEP_ALPHA), + f32_at(r, LP_OCEAN_SHALLOW_ALPHA), + f32_at(r, LP_OCEAN_DEEP_ALPHA), + ) { + water_alpha.insert(id, [ws, wd, os, od]); + } + } + (glow, highlight, water_alpha) + }; + Ok(LightCatalog { + lights, + int_bands, + float_bands, + light_params_glow, + light_params_highlight, + light_params_water_alpha, + }) + } + + /// Sample the atmosphere for `pos` (raw WoW yards) on `map` at `time` (half-minutes, 1440=noon). + /// `stormy` selects the rainy/snowy param slot over the clear one. Picks the most-local light + /// sphere containing `pos`, else the continent's global `(0,0,0)` light, else + /// [`Atmosphere::DEFAULT`]. + pub fn sample(&self, map: u32, pos: [f32; 3], time: u32, stormy: bool) -> Atmosphere { + let Some(light) = self.pick_light(map, pos) else { + return Atmosphere::DEFAULT; + }; + // Fall back to the clear slot if the requested weather slot is unset. + let slot = if stormy { SLOT_STORM } else { SLOT_CLEAR }; + let param = match light.params[slot] { + 0 => light.params[SLOT_CLEAR], + p => p, + }; + if param < 1 { + return Atmosphere::DEFAULT; + } + self.sample_param(param, time) + } + + /// [`LightCatalog::sample`] at noon, clear weather. + pub fn sample_noon(&self, map: u32, pos: [f32; 3]) -> Atmosphere { + self.sample(map, pos, DAY / 2, false) + } + + /// Debug: print every `LightIntBand` row (0..18) and `LightFloatBand` row (0..6) for the clear + /// LightParams covering `pos`, sampled at `time`. Used to verify which rows hold which colors + /// (the five sky-gradient stops, sun/cloud colors, etc.) rather than guessing the layout. + pub fn debug_bands(&self, map: u32, pos: [f32; 3], time: u32) { + let Some(light) = self.pick_light(map, pos) else { + println!("(no light covers this position)"); + return; + }; + let p = match light.params[SLOT_CLEAR] { + 0 => { + println!("(no clear LightParams)"); + return; + } + x => x, + }; + println!("LightParams {p} @ time {time} half-min — all int rows (sRGB 0..255):"); + for b in 0..18u32 { + let key = (p - 1) * 18 + b + 1; + match self + .int_bands + .get(&key) + .and_then(|band| sample_color(band, time)) + { + Some(c) => println!( + " int[{b:2}] = [{:3}, {:3}, {:3}]", + (c[0] * 255.0) as u8, + (c[1] * 255.0) as u8, + (c[2] * 255.0) as u8 + ), + None => println!(" int[{b:2}] = (unset)"), + } + } + for b in 0..6u32 { + let key = (p - 1) * 6 + b + 1; + match self + .float_bands + .get(&key) + .and_then(|band| sample_float(band, time)) + { + Some(v) => println!(" float[{b}] = {v:.4}"), + None => println!(" float[{b}] = (unset)"), + } + } + } + + /// Faithful **area-light blend** (step 2 isolation): replicates the real client's + /// `calculateLightParamBlends` + the sequential lerp in `getLightResultsFromDB`. Start from the + /// continent's global `(0,0,0)` light, then for every local light sphere covering `pos` (sorted by + /// `blendAlpha` descending) lerp the accumulated atmosphere toward that light by its `blendAlpha` + /// (`1` inside `falloffStart`, ramping to `0` at `falloffEnd`). Contrast with [`pick_light`], which + /// picks just the single most-local sphere — this tells us whether a faithful blend resolves a + /// **different** (e.g. redder) light at our spot than the single-sample approximation. + #[allow(clippy::too_many_arguments)] + pub fn sample_blended( + &self, + map: u32, + pos: [f32; 3], + time: u32, + stormy: bool, + underwater: bool, + ghost: bool, + ) -> Atmosphere { + let slot = weather_slot(ghost, stormy, underwater); + let atmo_of = |l: &Light| -> Option { + // Fall back to the clear slot if the requested slot is unset for this light (e.g. a zone + // with no dedicated underwater param degrades to the above-water atmosphere). + let param = match l.params[slot] { + 0 => l.params[SLOT_CLEAR], + p => p, + }; + (param >= 1).then(|| self.sample_param(param, time)) + }; + + // Base = the continent global light (lowest priority, covers everywhere). + let mut acc = self + .lights + .iter() + .find(|l| l.map == map && l.global) + .and_then(atmo_of) + .unwrap_or(Atmosphere::DEFAULT); + + // Collect local spheres covering `pos` with their blendAlpha, strongest first. + let mut locals: Vec<(f32, &Light)> = self + .lights + .iter() + .filter(|l| l.map == map && !l.global) + .filter_map(|l| { + let d = (0..3) + .map(|i| (l.pos[i] - pos[i]).powi(2)) + .sum::() + .sqrt(); + let a = blend_alpha(d, l.falloff_start, l.falloff_end); + (a > 0.0).then_some((a, l)) + }) + .collect(); + locals.sort_by(|a, b| b.0.total_cmp(&a.0)); + + for (alpha, l) in locals { + if let Some(atmo) = atmo_of(l) { + acc = acc.lerp(&atmo, alpha); + } + } + acc + } + + /// Debug (step 2): dump every light sphere on `map` near `pos` — distance, falloff, `blendAlpha`, + /// and its clear-weather ambient + sun colors — then the faithful blended result vs the single + /// [`pick_light`] sample, so we can see whether the residual under-red lives in the **values**. + pub fn debug_blend(&self, map: u32, pos: [f32; 3], time: u32) { + let mut rows: Vec<(f32, f32, &Light)> = self + .lights + .iter() + .filter(|l| l.map == map && !l.global) + .map(|l| { + let d = (0..3) + .map(|i| (l.pos[i] - pos[i]).powi(2)) + .sum::() + .sqrt(); + (d, blend_alpha(d, l.falloff_start, l.falloff_end), l) + }) + .collect(); + rows.sort_by(|a, b| a.0.total_cmp(&b.0)); + + let c = |x: [f32; 3]| { + format!( + "[{:3} {:3} {:3}]", + (x[0] * 255.0) as u8, + (x[1] * 255.0) as u8, + (x[2] * 255.0) as u8 + ) + }; + println!( + "Lights on map {map} near [{:.0} {:.0} {:.0}] (nearest 8) — dist | start->end | alpha | amb / sun:", + pos[0], pos[1], pos[2] + ); + for (d, alpha, l) in rows.iter().take(8) { + let a = if l.params[SLOT_CLEAR] >= 1 { + self.sample_param(l.params[SLOT_CLEAR], time) + } else { + Atmosphere::DEFAULT + }; + println!( + " d={d:7.0} | {:6.0}->{:6.0} | a={alpha:.3} | amb {} sun {}", + l.falloff_start, + l.falloff_end, + c(a.ambient), + c(a.sun_diffuse) + ); + } + let global = self + .lights + .iter() + .find(|l| l.map == map && l.global) + .and_then(|l| { + (l.params[SLOT_CLEAR] >= 1).then(|| self.sample_param(l.params[SLOT_CLEAR], time)) + }); + if let Some(g) = global { + println!( + " GLOBAL (0,0,0) base | amb {} sun {}", + c(g.ambient), + c(g.sun_diffuse) + ); + } + let picked = self.sample(map, pos, time, false); + let blended = self.sample_blended(map, pos, time, false, false, false); + println!( + " => pick_light : amb {} sun {}", + c(picked.ambient), + c(picked.sun_diffuse) + ); + println!( + " => blended : amb {} sun {}", + c(blended.ambient), + c(blended.sun_diffuse) + ); + } + + fn pick_light(&self, map: u32, pos: [f32; 3]) -> Option<&Light> { + let mut local: Option<&Light> = None; + let mut global: Option<&Light> = None; + for l in self.lights.iter().filter(|l| l.map == map) { + if l.global { + global = Some(l); + continue; + } + let d2 = (0..3).map(|i| (l.pos[i] - pos[i]).powi(2)).sum::(); + if d2 <= l.falloff_end * l.falloff_end + && local.is_none_or(|b| l.falloff_end < b.falloff_end) + { + local = Some(l); + } + } + local.or(global) + } + + fn sample_param(&self, p: u32, t: u32) -> Atmosphere { + let ib = |b: u32| self.int_bands.get(&((p - 1) * 18 + b + 1)); + let fb = |b: u32| self.float_bands.get(&((p - 1) * 6 + b + 1)); + let d = Atmosphere::DEFAULT; + // Fog distances carry the ×36 storage scale (yards = raw/36): Elwynn clear 18000→500 yd, + // storm 10000→278 yd. Byte-VERIFIED (0327, wow-re rf-weather-fog-veil Q2·LOAD): the client + // scales ONCE at DBC load — `0x53f504 → 0x6d6160 → 0x6d6100` runs `dn_array_scale_36` + // (`0x6d6090`, ×1/36 @0x7ff9d0) over each float-band record with `rowIndex % 6 == 0`, i.e. + // ONLY the sub-0 fog-END band; the sub-1 start FRACTION is never scaled. We apply the same + // /36 at sample time (a time-interp of scaled values ≡ the scale of the interp). + let fog_end = fb(FB_FOG_END) + .and_then(|b| sample_float(b, t)) + .map(|v| v / POS_SCALE) + .filter(|&v| v > 1.0) + .unwrap_or(d.fog_end); + let fog_start_frac = fb(FB_FOG_START_MULT) + .and_then(|b| sample_float(b, t)) + .unwrap_or(d.fog_start_frac); + let col = |idx: u32, fallback: [f32; 3]| { + ib(idx).and_then(|b| sample_color(b, t)).unwrap_or(fallback) + }; + Atmosphere { + fog_end, + // UNCLAMPED, negative under storm (Elwynn −0.5) — the negative start IS the constant + // near veil the reference shows in rain; clamping it to 0 was the veil-killing bug. + fog_start_frac, + fog_color: col(IB_FOG_COLOR, d.fog_color), + sun_diffuse: col(IB_DIFFUSE, d.sun_diffuse), + sun_color: col(IB_SUN_COLOR, d.sun_color), + ambient: col(IB_AMBIENT, d.ambient), + sky: std::array::from_fn(|i| col(IB_SKY0 + i as u32, d.sky[i])), + // Dedicated water-tint rows (RAW, no scale): 16/17 river/lake, 14/15 ocean — the 2-endpoint + // depth-swatch lerp (`FUN_0068a830`). These are real per-zone rows, NOT the sky gradient. + water_river: [ + col(IB_RIVER_SHALLOW, d.water_river[0]), + col(IB_RIVER_DEEP, d.water_river[1]), + ], + water_ocean: [ + col(IB_OCEAN_SHALLOW, d.water_ocean[0]), + col(IB_OCEAN_DEEP, d.water_ocean[1]), + ], + // Per-zone water-blend alphas (static LightParams fields 5–8); fall back to the DEFAULT + // ramp endpoints when the record didn't carry them. + water_river_alpha: self + .light_params_water_alpha + .get(&p) + .map(|a| [a[0], a[1]]) + .unwrap_or(d.water_river_alpha), + water_ocean_alpha: self + .light_params_water_alpha + .get(&p) + .map(|a| [a[2], a[3]]) + .unwrap_or(d.water_ocean_alpha), + // Per-zone glow (static LightParams field, not a time band); fallback 0.5. + glow: self.light_params_glow.get(&p).copied().unwrap_or(d.glow), + // Cloud density C (float sub-3) + the three cloud palette rows (int sub-10/11/12) — + // the coverage threshold and the visible dome's colors (wow-re cloud pipeline §3c/§4). + cloud_density: fb(FB_CLOUD_DENSITY) + .and_then(|b| sample_float(b, t)) + .unwrap_or(d.cloud_density), + cloud_colors: [ + col(IB_CLOUD_SUN, d.cloud_colors[0]), + col(IB_CLOUD_SLOPE, d.cloud_colors[1]), + col(IB_CLOUD_GBASE, d.cloud_colors[2]), + ], + // Per-zone highlightSky flag (static); gates the dawn/dusk dome warp. Fallback 0.0. + highlight_sky: self + .light_params_highlight + .get(&p) + .copied() + .unwrap_or(d.highlight_sky), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dbc_to_world_matches_wiki_convertdbtogamecoords() { + // The `(0,0,0)` global sentinel maps to the map center (we special-case it before this). + assert_eq!(dbc_to_world(0.0, 0.0, 0.0), [17066.666, 17066.666, 0.0]); + + // Round-trip a known world point through the wiki inverse + // (DBC.x = (17066.666 − world.y)·36, DBC.y = world.z·36, DBC.z = (17066.666 − world.x)·36): + // applying `dbc_to_world` must recover the world coords. Light #51 → (-8480, 548, 81). + let world = [-8480.4f32, 548.3, 80.9]; + let dbc = [ + (17066.666 - world[1]) * POS_SCALE, + world[2] * POS_SCALE, + (17066.666 - world[0]) * POS_SCALE, + ]; + let back = dbc_to_world(dbc[0], dbc[1], dbc[2]); + for i in 0..3 { + assert!( + (back[i] - world[i]).abs() < 0.1, + "axis {i}: {} != {}", + back[i], + world[i] + ); + } + } + + #[test] + fn glow_defaults_blends_and_quantises() { + // Fallback = the binary's "no active world-light" 0.5. + assert_eq!(Atmosphere::DEFAULT.glow, 0.5); + // Glow blends across light spheres like the colours (Duskwood 0.50 ↔ a 0.85 area). + let a = Atmosphere { + glow: 0.50, + ..Atmosphere::DEFAULT + }; + let b = Atmosphere { + glow: 0.85, + ..Atmosphere::DEFAULT + }; + assert!((a.lerp(&b, 0.5).glow - 0.675).abs() < 1e-6); + // The renderer quantises floor(glow·255)/255 — Elwynn's 0.65 → 165/255 = 0.647 (the + // apitrace composite weight), confirming LP_GLOW (field 4) is the right field. + let q = |g: f32| (g * 255.0).floor() / 255.0; + assert!((q(0.65) - 165.0 / 255.0).abs() < 1e-6); + assert!((q(0.50) - 127.0 / 255.0).abs() < 1e-6); // Duskwood → 0.498 + } +} diff --git a/crates/benilla-formats/src/light/atmosphere.rs b/crates/benilla-formats/src/light/atmosphere.rs new file mode 100644 index 00000000..6f6f0e8d --- /dev/null +++ b/crates/benilla-formats/src/light/atmosphere.rs @@ -0,0 +1,184 @@ +//! The resolved lighting **output type** ([`Atmosphere`]) + the DBC row/field → meaning map. +//! +//! `LightIntBand`/`LightFloatBand` rows and `LightParams` fields are just numbered slots; the +//! constants here name which slot is which (the **vanilla** layout, VERIFIED — see the `light` +//! module header). [`super::LightCatalog::sample`] reads these rows/fields into an [`Atmosphere`]. + +/// LightIntBand row indices (0-based within a param's 18 rows). +pub(super) const IB_DIFFUSE: u32 = 0; +pub(super) const IB_AMBIENT: u32 = 1; +/// The five sky-gradient stops are rows 2..6: SkyColor0 (zenith) → SkyColor4 (horizon). +pub(super) const IB_SKY0: u32 = 2; +pub(super) const IB_FOG_COLOR: u32 = 7; +pub(super) const IB_SUN_COLOR: u32 = 9; +/// **Cloud layer palette rows** — byte-VERIFIED (wow-re `scratch/cloud-coverage-pipeline.md` §3c: +/// the band gather `0x6d64d0` stores sub-10/11/12 at color-table slots 9/10/11 = `0xce9c30/34/38`, +/// the three palettes the cloud dome builder `0x6cfb00` reads): sun-glow tint, gradient slope, +/// gradient base. +pub(super) const IB_CLOUD_SUN: u32 = 10; +pub(super) const IB_CLOUD_SLOPE: u32 = 11; +pub(super) const IB_CLOUD_GBASE: u32 = 12; +/// **Dedicated water-surface tint rows.** The from-above depth swatch is a plain 2-endpoint linear +/// lerp of these IntBand colours, RAW (no scale), VERIFIED from WoW.exe `FUN_0068a830` (the body-colour +/// swatch builder reads `gWorldLight+0xe0..+0xec` = these rows; golden-vector-matched to the apitrace +/// swatch ≤1/255 over all 64 rows). Ocean = rows 14/15 (shallow/deep), river/lake = rows 16/17. +/// NOT the sky gradient — the earlier "reflected sky × 0.711 via `FUN_0068c250`" conclusion fingered +/// the WRONG builder (that fills a *separate* symmetric grey edge texture not bound on the water unit). +pub(super) const IB_OCEAN_SHALLOW: u32 = 14; +pub(super) const IB_OCEAN_DEEP: u32 = 15; +pub(super) const IB_RIVER_SHALLOW: u32 = 16; +pub(super) const IB_RIVER_DEEP: u32 = 17; +/// LightFloatBand row indices (within a param's 6 rows). +pub(super) const FB_FOG_END: u32 = 0; +pub(super) const FB_FOG_START_MULT: u32 = 1; +/// **Cloud density `C`** — byte-VERIFIED (wow-re `scratch/cloud-coverage-pipeline.md` §4: the +/// scalar gather `0x6d64d0` stores float sub-3 at `0xce9c0c+0x58` = `[0xce9c64]`, the coverage +/// threshold input `T = trunc((1−C)·255)`). Zeroed only in the degenerate no-light-record +/// fallback — hence the 0.0 default. +pub(super) const FB_CLOUD_DENSITY: u32 = 3; + +/// LightParams.dbc field index of the **glow** scalar (the FFXGlow/bloom composite weight). VERIFIED +/// from `WoW.exe` RE: the engine reads glow from record byte +/// **+0x10 = field index 4**, NOT the wiki's labelled +0x0C `m_glow` (which is 0.0 in all 426 vanilla +/// records). Byte-exact cross-check: LightParams 12 (EK default) field 4 = 0.65 → `floor(0.65·255)/255 = +/// 0.647`, matching the apitrace composite weight. Per-zone (13 distinct values, 0.0–1.0 across zones). +pub(super) const LP_GLOW: usize = 4; + +/// `LightParams.dbc` field index of the **highlightSky** flag (record byte +0x04 = field index 1). +/// VERIFIED from `WoW.exe` RE: the sky-dome +/// colour fold `FUN_006d0f50` reads it as `params[0x14] = (float)*(int*)(LightSrc+4)` — an int 0/1 +/// flag that **gates the dawn/dusk sky-dome azimuthal warp** (Elwynn/Westfall = 1, Duskwood = 0). +/// Stored as 0.0/1.0. +pub(super) const LP_HIGHLIGHT: usize = 1; + +/// `LightParams.dbc` field indices of the **water-blend alphas** — the from-above swatch's depth-alpha +/// ramp endpoints (`swatch.a = lerp(shallow, deep, V)`; the runtime reads these into `gWorldLight+0x114..` +/// via `FUN_006b6b60`). Layout follows the glow shift: glow is field 4 (+0x10), so these are fields 5–8 +/// (+0x14..+0x20). **[VERIFY against a real file/trace]** — confirmed by `probe_water_pickblend` that +/// LP 26 (STV) waterShallow ≈ 0.85 matches the WoW.21 swatch alpha 216/255; if a future file disagrees, +/// re-check the offset (cf. the glow +0x0C-vs-+0x10 trap). +pub(super) const LP_WATER_SHALLOW_ALPHA: usize = 5; +pub(super) const LP_WATER_DEEP_ALPHA: usize = 6; +pub(super) const LP_OCEAN_SHALLOW_ALPHA: usize = 7; +pub(super) const LP_OCEAN_DEEP_ALPHA: usize = 8; + +/// Sampled atmosphere for a position + time of day. Distances are yards; colors are sRGB 0..1 +/// (feed straight into Bevy `Color::srgb`). +#[derive(Clone, Copy, Debug)] +pub struct Atmosphere { + /// Fog-end band value (FloatBand sub0) in **yards** (`raw/36` — Elwynn clear 18000→500, storm + /// 10000→278). Byte-VERIFIED (0327): the client applies the ×1/36 ONCE at DBC load + /// (`dn_array_scale_36 0x6d6090`, selector `rowIndex % 6 == 0` — the sub-0 distance band only). + /// The consumer pushes `min(fog_end, farclip)` (`dn_scene_fog 0x6cee30`), so the wall tracks + /// the view-distance slider only below the zone's value. Lerped verbatim across weather/area + /// blends, like the binary's `0x6d69d8`. + pub fog_end: f32, + /// Fog-start **fraction** of the pushed fog end (FloatBand sub1), **raw and unclamped** — storm + /// params go negative (Elwynn storm −0.5), which is the mechanism behind the constant near veil: + /// `GL_FOG_START = frac × GL_FOG_END` with no clamp (`0x6cee6d`), so a negative start puts + /// `1 − 1/(1−frac)` fog at the eye. Lerped verbatim, like the binary's `0x6d69ed`. + pub fog_start_frac: f32, + pub fog_color: [f32; 3], + /// Global diffuse (the sun's warm direct tint; orange in daylight). + pub sun_diffuse: [f32; 3], + /// Sun disc / halo color (the cleaner near-white we drive the directional light with). + pub sun_color: [f32; 3], + pub ambient: [f32; 3], + /// The five sky-dome gradient stops, zenith → horizon (`SkyColor0..4`): navy zenith, the strong + /// midday blue, light blue, pale cyan, pale horizon. All five matter — using only every other one + /// drops the saturated blue and washes the sky out. + pub sky: [[f32; 3]; 5], + /// **River/lake water-surface tint** `[shallow, deep]` — IntBand rows 16/17, RAW. The from-above + /// depth swatch is a 2-endpoint linear lerp of these by the per-vertex depth `V` (built in + /// `benilla-formats::liquid` — river/lake `V = clamp(byte/42)`) (VERIFIED `WoW.exe FUN_0068a830`; see + /// [`IB_RIVER_SHALLOW`]). No `× 0.711` — these are the raw water rows. + pub water_river: [[f32; 3]; 2], + /// **Ocean water-surface tint** `[shallow, deep]` — IntBand rows 14/15, RAW (same builder as + /// [`Atmosphere::water_river`], `param_6 = 0`). Ocean uses a gentler depth-byte ramp than rivers + /// (≈1.7 vs ≈8.5 byte/yd — the data's own slope), so its deep colour appears far from shore. + pub water_ocean: [[f32; 3]; 2], + /// **River/lake water-blend alphas** `[shallow, deep]` — `LightParams.waterShallowAlpha`/ + /// `waterDeepAlpha` (fields [`LP_WATER_SHALLOW_ALPHA`]/[`LP_WATER_DEEP_ALPHA`]). The depth swatch's + /// alpha ramp endpoints (`swatch.a = lerp(shallow, deep, V)`). Per-zone: Elwynn/Loch Modan shallow + /// ≈0.5, STV ≈0.85 (its shallows read pale from the colour, not transparency). Deep ≈1.0 everywhere. + pub water_river_alpha: [f32; 2], + /// **Ocean water-blend alphas** `[shallow, deep]` — `LightParams.oceanShallowAlpha`/`oceanDeepAlpha` + /// (fields [`LP_OCEAN_SHALLOW_ALPHA`]/[`LP_OCEAN_DEEP_ALPHA`]). Shallow ≈0.75, deep ≈1.0. + pub water_ocean_alpha: [f32; 2], + /// **FFXGlow / bloom composite weight** for this zone — `LightParams.glow` (field [`LP_GLOW`]), + /// raw `0.0..1.0`. The composite is `scene + glow·bloom²`; the real client quantises it to a byte + /// (`floor(glow·255)/255`) when it packs the composite-quad colour. Per-zone (Elwynn 0.65, Duskwood + /// 0.50, …); fallback 0.5. + pub glow: f32, + /// **highlightSky** flag for this zone (`LightParams.highlightSky`, field [`LP_HIGHLIGHT`]), as + /// 0.0/1.0. Gates the dawn/dusk sky-dome azimuthal warp (`FUN_006d0f50`): the warp strength is + /// `dawn_dusk_curve(dayfrac) × highlight_sky`, so highlightSky=0 zones (e.g. Duskwood) get no + /// warp at all. Per-zone, static (a LightParams record field, not a time band); fallback 0.0. + pub highlight_sky: f32, + /// **Cloud density `C`** (FloatBand sub-3, [`FB_CLOUD_DENSITY`]) — the coverage threshold + /// input `T = trunc((1−C)·255)`: 0 = a cloudless sky (the field quantizes to zero + /// everywhere), 1 = full overcast potential. Per-zone, per-time, and lerped across the + /// weather/area blends like every band. + pub cloud_density: f32, + /// **Cloud palette** `[sun-glow, slope, gbase]` (IntBand sub-10/11/12) — the visible cloud + /// dome's colors: per-texel `RGB = slope·p + gbase` plus a sun-aligned glow tinted by the + /// sun row (wow-re `cloud-coverage-pipeline.md` §3b/§3c). + pub cloud_colors: [[f32; 3]; 3], +} + +impl Atmosphere { + /// Neutral daytime fallback when a position has no light / the bands are missing. + pub const DEFAULT: Atmosphere = Atmosphere { + fog_end: 1000.0, + fog_start_frac: 0.4, + fog_color: [0.55, 0.72, 0.92], + sun_diffuse: [1.0, 0.96, 0.86], + sun_color: [1.0, 1.0, 0.9], + ambient: [0.45, 0.52, 0.65], + sky: [ + [0.30, 0.50, 0.85], + [0.35, 0.58, 0.88], + [0.55, 0.72, 0.92], + [0.68, 0.80, 0.93], + [0.78, 0.86, 0.95], + ], + // Daytime fallback water tints (used only when the DBC rows are absent): green shore → teal + // deep (river/lake), teal → deep-blue (ocean). Real values come from IntBand rows 16/17 / 14/15. + water_river: [[0.27, 0.33, 0.14], [0.19, 0.31, 0.32]], + water_ocean: [[0.10, 0.29, 0.34], [0.04, 0.16, 0.28]], + water_river_alpha: [0.5, 1.0], + water_ocean_alpha: [0.75, 1.0], + glow: 0.5, // the binary's "no active world-light" fallback + highlight_sky: 0.0, // no dawn/dusk dome warp unless a zone sets the flag + // The degenerate no-light-record fallback force-zeroes C (`0x6d22ec`) — no clouds. + cloud_density: 0.0, + cloud_colors: [[1.0, 0.98, 0.9], [0.35, 0.38, 0.42], [0.75, 0.78, 0.82]], + }; + + /// Linearly blend two atmospheres (`t=0`→`self`, `t=1`→`other`) — for smooth weather/time fades. + pub fn lerp(&self, other: &Atmosphere, t: f32) -> Atmosphere { + let f = |a: f32, b: f32| a + (b - a) * t; + let c = |a: [f32; 3], b: [f32; 3]| [f(a[0], b[0]), f(a[1], b[1]), f(a[2], b[2])]; + Atmosphere { + fog_end: f(self.fog_end, other.fog_end), + fog_start_frac: f(self.fog_start_frac, other.fog_start_frac), + fog_color: c(self.fog_color, other.fog_color), + sun_diffuse: c(self.sun_diffuse, other.sun_diffuse), + sun_color: c(self.sun_color, other.sun_color), + ambient: c(self.ambient, other.ambient), + sky: std::array::from_fn(|i| c(self.sky[i], other.sky[i])), + water_river: std::array::from_fn(|i| c(self.water_river[i], other.water_river[i])), + water_ocean: std::array::from_fn(|i| c(self.water_ocean[i], other.water_ocean[i])), + water_river_alpha: std::array::from_fn(|i| { + f(self.water_river_alpha[i], other.water_river_alpha[i]) + }), + water_ocean_alpha: std::array::from_fn(|i| { + f(self.water_ocean_alpha[i], other.water_ocean_alpha[i]) + }), + glow: f(self.glow, other.glow), + highlight_sky: f(self.highlight_sky, other.highlight_sky), + cloud_density: f(self.cloud_density, other.cloud_density), + cloud_colors: std::array::from_fn(|i| c(self.cloud_colors[i], other.cloud_colors[i])), + } + } +} diff --git a/crates/benilla-formats/src/light/tables.rs b/crates/benilla-formats/src/light/tables.rs new file mode 100644 index 00000000..34bf9c7f --- /dev/null +++ b/crates/benilla-formats/src/light/tables.rs @@ -0,0 +1,156 @@ +//! Low-level `LightIntBand`/`LightFloatBand` table decode + time-of-day interpolation. A [`Band`] is +//! one row's parallel `(time, value)` pairs across the day; [`sample_color`]/[`sample_float`] lerp it +//! at a given time (wrapping across midnight). The meaning of each row is in [`super::atmosphere`]; +//! this module is purely "parse the band tables + interpolate", no lighting-domain knowledge. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; +use crate::Chain; + +/// Half-minutes in a game day (band time axis); `1440` = noon. +pub(super) const DAY: u32 = 2880; + +/// One band row: parallel (time, value) pairs across the day, ascending in `times`. +pub(super) struct Band { + times: Vec, + values: Vec, +} + +/// The shared `LightIntBand`/`LightFloatBand` record schema: `ID, num, time[16], value[16]` (34 +/// fields, 136 B). `value` is `UInt32` for the int band, `Float32` for the float band. +pub(super) fn band_schema(name: &str, value: FieldType) -> Schema { + let mut s = Schema::new(name); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("num", FieldType::UInt32)); + for i in 0..16 { + s.add_field(SchemaField::new(format!("time{i}"), FieldType::UInt32)); + } + for i in 0..16 { + s.add_field(SchemaField::new(format!("value{i}"), value)); + } + s +} + +/// Decode a packed LightIntBand color (stored `0x00RRGGBB`, blue in the low byte) to sRGB 0..1. +fn decode_color(v: u32) -> [f32; 3] { + [ + ((v >> 16) & 0xff) as f32 / 255.0, + ((v >> 8) & 0xff) as f32 / 255.0, + (v & 0xff) as f32 / 255.0, + ] +} + +/// Find the segment of a band's time axis bracketing `t` (wrapping past the last entry to the +/// first), returning `(i0, i1, frac)` for a lerp. Assumes `times` is non-empty and ascending. +fn segment(times: &[u32], t: u32) -> (usize, usize, f32) { + let n = times.len(); + if n == 1 { + return (0, 0, 0.0); + } + // Wrap region: before the first or after the last entry interpolates last→first across midnight. + if t < times[0] || t >= times[n - 1] { + let span = times[0] + DAY - times[n - 1]; + if span == 0 { + return (n - 1, 0, 0.0); + } + let into = if t < times[0] { t + DAY } else { t } - times[n - 1]; + return (n - 1, 0, into as f32 / span as f32); + } + for i in 0..n - 1 { + if t <= times[i + 1] { + let span = times[i + 1] - times[i]; + let f = if span == 0 { + 0.0 + } else { + (t - times[i]) as f32 / span as f32 + }; + return (i, i + 1, f); + } + } + (n - 1, n - 1, 0.0) +} + +pub(super) fn sample_float(b: &Band, t: u32) -> Option { + if b.values.is_empty() { + return None; + } + let (i0, i1, f) = segment(&b.times, t); + Some(b.values[i0] + (b.values[i1] - b.values[i0]) * f) +} + +pub(super) fn sample_color(b: &Band, t: u32) -> Option<[f32; 3]> { + if b.values.is_empty() { + return None; + } + let (i0, i1, f) = segment(&b.times, t); + let (c0, c1) = (decode_color(b.values[i0]), decode_color(b.values[i1])); + Some([ + c0[0] + (c1[0] - c0[0]) * f, + c0[1] + (c1[1] - c0[1]) * f, + c0[2] + (c1[2] - c0[2]) * f, + ]) +} + +pub(super) fn load_bands( + chain: &mut Chain, + path: &str, + schema: Schema, +) -> Result>> { + let bytes = chain + .read_file(path) + .with_context(|| format!("reading {path}"))?; + let rs = parse(&bytes, schema, path)?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(num)) = (u32_at(r, 0), u32_at(r, 1)) else { + continue; + }; + let num = (num as usize).min(16); + let times = (0..num).filter_map(|i| u32_at(r, 2 + i)).collect(); + let values = (0..num).filter_map(|i| u32_at(r, 18 + i)).collect(); + m.insert(id, Band { times, values }); + } + Ok(m) +} + +pub(super) fn load_float_bands( + chain: &mut Chain, + path: &str, + schema: Schema, +) -> Result>> { + let bytes = chain + .read_file(path) + .with_context(|| format!("reading {path}"))?; + let rs = parse(&bytes, schema, path)?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(num)) = (u32_at(r, 0), u32_at(r, 1)) else { + continue; + }; + let num = (num as usize).min(16); + let times = (0..num).filter_map(|i| u32_at(r, 2 + i)).collect(); + let values = (0..num).filter_map(|i| f32_at(r, 18 + i)).collect(); + m.insert(id, Band { times, values }); + } + Ok(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segment_wraps_across_midnight() { + // Two keyframes at 06:00 (720) and 18:00 (2160): a time before the first wraps from the + // last (interpolating across midnight), not clamps. + let times = [720u32, 2160]; + let (i0, i1, f) = segment(×, 360); + assert_eq!((i0, i1), (1, 0)); + let span = 720 + DAY - 2160; // 1440 + assert!((f - (360 + DAY - 2160) as f32 / span as f32).abs() < 1e-6); + } +} diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs new file mode 100644 index 00000000..e252110f --- /dev/null +++ b/crates/benilla-formats/src/liquid.rs @@ -0,0 +1,324 @@ +//! MCLQ liquid surfaces → per-chunk water meshes (Phase E; Phase 1 = inland water). +//! +//! Vanilla liquid is **MCLQ** — per-MCNK, a 9×9 absolute-height grid + an 8×8 cell-flag grid +//! (no WotLK MH2O). `wow-adt`'s [`MclqChunk`] decodes it; we validated its output **byte-identical** +//! to a hand-decode of the raw 804-B payload against `World\Maps\Azeroth\Azeroth_32_48.adt` +//! (wow-adt 0.6.4, 2026-05-31): per-vertex `height`, raw `tile_flags`, `min/max_height`, and the +//! MCNK-flag-derived `liquid_type` all match (the on-disk `QLCM` magic sits at the offset; the +//! payload — and the crate's parse — start 8 bytes in). +//! +//! Output is in **raw WoW coords** (+X north, +Y west, +Z up, yards), like [`crate::terrain`]; the +//! renderer applies the WoW→Bevy transform and ports the `ocean0_s.bls` shader. We emit a flat +//! surface (normal `(0,0,1)`) of 2 triangles per **wet** cell; dry cells (flag low-nibble `0xf`, +//! whose verts carry the FLT_MAX sentinel height) are skipped. Magma/slime is deferred to Phase 3. + +use benilla_adt::{LiquidType, MclqChunk}; + +use crate::terrain::{snap_to_lattice, UNIT_SIZE}; + +/// Which animated texture set + render path a liquid surface uses (selects the frame set in +/// `benilla`). The MCLQ (ADT) path emits only Still/Rapids/Ocean; the WMO MLIQ path additionally +/// emits Magma/Slime (canals, fountains, the Ironforge lava, dungeon pools). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LiquidKind { + /// Still water — `XTextures\river\lake_a.*` (30 frames). Lakes, ponds, slow rivers, WMO canals. + Still, + /// Rapids — `XTextures\river\fast_a.*` (16 frames). Fast streams (per-cell nibble `8`). + Rapids, + /// Ocean — `XTextures\ocean\ocean_h.*` (30 frames). Coastal / sea tiles. + Ocean, + /// Magma / lava — `XTextures\lava\lava.*`. Opaque, fullbright (the animated texture IS the body, + /// no depth LUT; VERIFIED wow-re `rf-water-liquid-type-texture-material.md`, magma vert-fill + /// `0x68d890` = constant 1.0). WMO liquid only (nibbles `2`/`6`) — the Ironforge Great Forge. + Magma, + /// Slime — `XTextures\slime\slime.*`. Its own texture on the magma render category (opaque, + /// fullbright). WMO liquid only (nibbles `3`/`7`) — Undercity, the Sludge Fields. + Slime, +} + +impl LiquidKind { + /// The liquid type from a per-tile MLIQ flag low nibble (`flag & 0xf`) — the VERIFIED reference + /// selection (wow-re `rf-water-liquid-type-texture-material.md`, name table `0x86a000`): the + /// nibble indexes the animated-texture table directly. `0xf` (and any unmapped value) = hole / + /// no liquid → `None`. `4` is a same-class `lake_a` variant of `0`; `6`/`7` of `2`/`3`. + pub fn from_nibble(nibble: u8) -> Option { + match nibble & 0xf { + 0 | 4 => Some(LiquidKind::Still), + 1 => Some(LiquidKind::Ocean), + 2 | 6 => Some(LiquidKind::Magma), + 3 | 7 => Some(LiquidKind::Slime), + 8 => Some(LiquidKind::Rapids), + _ => None, // 5, 9..=0xf — NULL name-table slots / the 0xf hole sentinel + } + } + + /// Whether this kind renders as an **opaque, fullbright** surface (the animated texture is the + /// body colour, no water swatch / depth ramp / N·L darkening) — magma and slime. Water/ocean use + /// the depth-swatch `ocean0_s.bls` path instead. + pub fn is_fullbright(self) -> bool { + matches!(self, LiquidKind::Magma | LiquidKind::Slime) + } +} + +/// One MCNK's liquid surface as an indexed triangle mesh in **raw WoW coords** (+Z up, flat). +/// +/// `positions`/`uvs` hold the full 9×9 grid (81 verts); verts under dry cells stay present but +/// unreferenced. `indices` is 2 triangles per wet cell. Drawn two-sided (cull off), so winding +/// is not load-bearing. +#[derive(Debug, Clone)] +pub struct LiquidMesh { + /// Vertex positions `[x, y, z]` in WoW yards — 81 entries (9×9 grid, row-major). + pub positions: Vec<[f32; 3]>, + /// Cell-index UVs (`¼` per cell, tiling) parallel to `positions`. No scroll for water. + pub uvs: Vec<[f32; 2]>, + /// Per-vertex liquid swatch coord **V** `0..1` from the MCLQ `SVert` depth byte, parallel to + /// `positions`. River/lake = `clamp(byte/42)` (VERIFIED `c81768`, saturates ~5 yd); ocean = byte/255 + /// (placeholder — ocean uses a different non-LUT path, see `build_liquid_mesh`). The reference uses + /// this single V to index the depth swatch for BOTH the body-colour band (shallow→deep water rows) + /// and the opacity ramp (`colorTex.a` = `127+2·row`, deeper = more opaque). Magma/slime reuse + /// the union bytes for UVs (N/A here). + pub depths: Vec, + /// Triangle indices into `positions` — 6 per wet cell. + pub indices: Vec, + /// The surface's **sound-class nibble** — the majority wet cell's low nibble (terrain), or + /// the `0x6ba970`-resolved nibble (WMO): `class = nibble & 3`, `FluidSpeed = nibble & 0xc`, + /// the key the above-water liquid ambient-loop system resolves through `SoundWaterType.dbc` + /// ([`crate::WaterSoundCatalog`]; wow-re `liquid-ambience-loop.md`, decision 0506). Carried + /// beside `kind` because the render kind collapses the river speeds (nibbles 0 and 4 both + /// draw `lake_a`) that the sound table splits (RiverStill 1111 vs RiverSlow 1112). + pub sound_nibble: u8, + /// The texture set / render path this surface uses. + pub kind: LiquidKind, +} + +/// 9×9 vertex grid per MCNK. +const GRID: usize = 9; +/// 8×8 cell grid per MCNK. +const CELLS: usize = 8; +/// Cell-flag low nibble meaning "dry / do not render". +const DRY_NIBBLE: u8 = 0x0f; +/// Cell-flag low nibble meaning "rapids" (`river\fast_a`) rather than still water. +const RAPIDS_NIBBLE: u8 = 0x08; +/// Verts under no liquid carry FLT_MAX (`0x7F7FFFFF`) — `is_finite()` is TRUE for it, so gate on +/// magnitude instead. +const HEIGHT_SENTINEL: f32 = 1.0e9; +/// River/lake depth-byte at which the swatch coord `V` saturates to 1.0: `V = clamp(byte/42)`. +/// VERIFIED from `WoW.exe` — the in-game river/lake water list (`FUN_0068d790`) reads the **steep** +/// `DAT_00c81768` LUT, built by `FUN_0068c4c0` as `clamp((d/9)/4.6667) = clamp(d/42)` (constants +/// `0x81028c=1/9`, `0x810380=1/4.6667` static-verified), and the live VBO confirms `V` is multiples +/// of 1/42 saturating at 1.0. (The gentler `byte/255` = the `DAT_00c7fcd8` LUT on a DIFFERENT list +/// `FUN_0068d690` that the from-above river path does not use — hence the river middle never reached +/// the deep/teal swatch row until this. The river depth ramp is ≈8.5 byte/yd, so V saturates at ≈5 yd.) +const RIVER_DEPTH_V_SATURATION: f32 = 42.0; + +/// Build a flat water surface mesh from a parsed MCLQ chunk at MCNK origin `position` +/// (the chunk header's raw WoW `[x, y, z]`). Returns `None` when the chunk has no wet cells +/// or is magma/slime (Phase 3). +pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option { + // Coarse kind from the MCNK-flag liquid type; magma/slime deferred. + let base_kind = match mclq.liquid_type { + LiquidType::Water => LiquidKind::Still, + LiquidType::Ocean => LiquidKind::Ocean, + LiquidType::Magma | LiquidType::Slime => return None, + }; + // Depth-byte → swatch coord `V` divisor (VERIFIED). River/lake (the `c81768`/`FUN_0068d790` path) + // saturates at byte 42 = ~5 yd, so the channel middle reaches the deep/teal swatch row. Ocean uses + // a DIFFERENT mechanism (`FUN_0068d890` reads explicit per-vertex MCLQ UVs, not a depth LUT) — left + // at /255 here pending its own RE + an in-game A/B; an ocean A/B has not been done since this fix. + let depth_v_div = match base_kind { + LiquidKind::Ocean => 255.0, + _ => RIVER_DEPTH_V_SATURATION, + }; + if mclq.vertices.len() < GRID * GRID || mclq.tile_flags.len() < CELLS * CELLS { + return None; + } + let [wx, wy, _wz] = position; + + // 81 grid verts in raw WoW coords. Linear index `n`: row = n/9 (steps south, −X), col = n%9 + // (steps east, −Y) — the same orientation as the MCVT outer grid, so the water lattice lines + // up with the terrain it sits in. Z is the MCLQ per-vertex absolute world height. Snap X/Y to + // the global lattice exactly as terrain does. + let mut positions = Vec::with_capacity(GRID * GRID); + let mut uvs = Vec::with_capacity(GRID * GRID); + let mut depths = Vec::with_capacity(GRID * GRID); + for n in 0..GRID * GRID { + let row = (n / GRID) as f32; + let col = (n % GRID) as f32; + // Dry/no-liquid verts carry the FLT_MAX (0x7F7FFFFF) sentinel (and `is_finite()` is TRUE for + // it). They're never indexed (only wet cells emit tris), but they stay in `positions`, so a + // raw sentinel would blow the mesh AABB out to ~1e38 — which makes Bevy's visibility culling + // drop the whole chunk (the "partial water chunks vanish, fully-wet ocean is fine" bug, since + // only mixed-wet/dry chunks contain sentinels). Substitute the chunk's `min_height` for any + // non-finite/sentinel height so the AABB stays tight; the value is invisible (unreferenced). + let raw = mclq.vertices[n].height; + let h = if raw.is_finite() && raw.abs() < HEIGHT_SENTINEL { + raw + } else { + mclq.min_height + }; + positions.push([ + snap_to_lattice(wx - row * UNIT_SIZE), + snap_to_lattice(wy - col * UNIT_SIZE), + h, + ]); + uvs.push([col * 0.25, row * 0.25]); + // SVert depth byte (water/ocean: union_data[0]) → swatch coord V (0..1). River/lake saturate at + // byte 42 (`clamp(byte/42)`, VERIFIED `c81768`) so the channel middle hits the deep/teal swatch + // row; ocean at /255 (see `depth_v_div`). The SAME V drives both the body-colour depth band and + // the opacity ramp on the shader side (one swatch row → colour + alpha). + depths.push((mclq.vertices[n].depth_byte() as f32 / depth_v_div).clamp(0.0, 1.0)); + } + + // 2 tris per wet cell. Skip dry cells (low nibble 0xf) and any cell with a sentinel-height + // corner (belt-and-suspenders). Track whether any wet cell is rapids → use fast_a for the + // whole chunk (still/rapids virtually never mix in one MCNK; per-cell sets are a refinement). + let mut indices = Vec::with_capacity(CELLS * CELLS * 6); + let mut any_rapids = false; + // Wet-cell nibble tally → the surface's majority sound-class nibble (`LiquidMesh::sound_nibble`). + let mut nibble_counts = [0u32; 16]; + for row in 0..CELLS { + for col in 0..CELLS { + let flag = mclq.tile_flags[row * CELLS + col] & 0x0f; + if flag == DRY_NIBBLE { + continue; + } + nibble_counts[flag as usize] += 1; + let tl = (row * GRID + col) as u32; + let tr = tl + 1; + let bl = ((row + 1) * GRID + col) as u32; + let br = bl + 1; + // Guard against a wet cell whose corner is the sentinel/NaN (data anomaly) — check the + // ORIGINAL heights, not the sanitized `positions`. Wet cells normally carry real heights. + if [tl, tr, bl, br].iter().any(|&i| { + let raw = mclq.vertices[i as usize].height; + !raw.is_finite() || raw.abs() >= HEIGHT_SENTINEL + }) { + continue; + } + if flag == RAPIDS_NIBBLE { + any_rapids = true; + } + indices.extend_from_slice(&[tl, bl, br, tl, br, tr]); + } + } + + if indices.is_empty() { + return None; + } + let kind = if any_rapids && base_kind == LiquidKind::Still { + LiquidKind::Rapids + } else { + base_kind + }; + let sound_nibble = nibble_counts + .iter() + .enumerate() + .max_by_key(|(_, &c)| c) + .map(|(n, _)| n as u8) + .unwrap_or(0); + Some(LiquidMesh { + positions, + uvs, + depths, + indices, + sound_nibble, + kind, + }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use benilla_adt::{parse_adt, ParsedAdt}; + + use super::*; + + fn data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// Golden test against a real Elwynn tile: Crystal Lake area `Azeroth_32_48.adt` has 42 MCLQ + /// water chunks (all river/still, surface ≈ 143.99 yd). Skips when the client isn't present. + #[test] + fn parses_elwynn_lake_tile() { + let data = data_dir(); + if !data.is_dir() { + eprintln!("skipping: no WoW client at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("World\\Maps\\Azeroth\\Azeroth_32_48.adt") + .expect("read Azeroth_32_48.adt"); + let ParsedAdt::Root(root) = + parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt"); + + let mut meshes = Vec::new(); + for mcnk in &root.mcnk_chunks { + if let Some(mclq) = &mcnk.liquid { + if let Some(m) = build_liquid_mesh(mclq, mcnk.header.position) { + meshes.push(m); + } + } + } + + // 42 water chunks on this tile, every one still river water. + assert_eq!( + meshes.len(), + 42, + "expected 42 liquid chunks on Azeroth_32_48" + ); + assert!( + meshes.iter().all(|m| m.kind == LiquidKind::Still), + "Crystal Lake is all still water" + ); + + for m in &meshes { + assert_eq!(m.positions.len(), 81, "9×9 grid"); + assert_eq!(m.uvs.len(), 81, "uv per vertex"); + assert_eq!(m.depths.len(), 81, "depth per vertex"); + // EVERY vertex (even unreferenced dry-cell verts) must be finite + sane, or the mesh AABB + // blows up (dry verts carry the FLT_MAX sentinel) and Bevy frustum-culls the whole chunk — + // the "partial water chunks vanish, fully-wet ocean is fine" bug. + for p in &m.positions { + assert!( + p[2].is_finite() && p[2].abs() < 10_000.0, + "all liquid verts sane for a clean AABB, got z={}", + p[2] + ); + } + assert!(!m.indices.is_empty(), "at least one wet cell"); + assert_eq!(m.indices.len() % 6, 0, "2 tris per wet cell"); + // Indices in range, and every referenced vertex has a finite, sane height — no + // FLT_MAX sentinel leaked in (the dry-cell gate worked). Elwynn water bodies sit at + // various elevations (Crystal Lake ≈ 144 yd, streams lower), all in a plausible band. + assert!(m.indices.iter().all(|&i| (i as usize) < m.positions.len())); + for &i in &m.indices { + let z = m.positions[i as usize][2]; + assert!( + z.is_finite() && z.abs() < 10_000.0, + "referenced height {z} sane" + ); + assert!( + (50.0..300.0).contains(&z), + "Elwynn water elevation {z} plausible" + ); + } + // Within one chunk a water surface is near-planar (a lake; a gentle stream slopes a + // little across the 33-yd chunk but not wildly). + let zs: Vec = m + .indices + .iter() + .map(|&i| m.positions[i as usize][2]) + .collect(); + let (zmin, zmax) = zs + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), &z| (lo.min(z), hi.max(z))); + assert!( + zmax - zmin < 30.0, + "per-chunk surface roughly flat ({zmin}..{zmax})" + ); + } + } +} diff --git a/crates/benilla-formats/src/loading_screen.rs b/crates/benilla-formats/src/loading_screen.rs new file mode 100644 index 00000000..1e45acef --- /dev/null +++ b/crates/benilla-formats/src/loading_screen.rs @@ -0,0 +1,116 @@ +//! LoadingScreens.dbc loader: resolves a `LoadingScreenID` (the FK in `Map.dbc` field 38, see +//! [`crate::MapCatalog::loading_screen_id`]) to the full-screen load-art **BLP path**. +//! +//! Layout — VERIFIED against build 5875 (raw-byte decode of extracted `DBFilesClient\ +//! LoadingScreens.dbc`, 2026-06-02): the WDBC header reports **40 records · 3 fields · 12 B/record** +//! (3 × 4 = 12, and `20 + 40·12 + 2926 == 3426` file bytes). Fields = `(ID, Name string, FileName +//! string)`; field 0 = id, field 2 = BLP path (e.g. `Interface\Glues\LoadingScreens\ +//! LoadScreenEasternKingdom.blp`). Open-world art is continent-wide — id 3 = Kalimdor, id 4 = +//! Azeroth/EasternKingdom — the other 38 rows are per-instance. The art is a 512×512 square scene +//! with the WoW logo composited in; the client stretch/crop-fills it to the screen. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +const LOADING_SCREENS: &str = "DBFilesClient\\LoadingScreens.dbc"; + +/// Resolved `LoadingScreenID → BLP path` map, built once at startup off the client's +/// LoadingScreens.dbc. +pub struct LoadingScreenCatalog { + paths: HashMap, +} + +impl LoadingScreenCatalog { + /// The load-art BLP path for `id` (a `Map.dbc` `LoadingScreenID`), or `None` if absent. + pub fn path(&self, id: u32) -> Option<&str> { + self.paths.get(&id).map(String::as_str) + } + + pub fn len(&self) -> usize { + self.paths.len() + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } +} + +/// 3 fields: ID (0), Name string (1, unused), FileName string (2 = BLP path). +fn schema() -> Schema { + let mut s = Schema::new("LoadingScreens"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("Name", FieldType::String)); + s.add_field(SchemaField::new("FileName", FieldType::String)); + s +} + +/// Read LoadingScreens.dbc off the patch chain into a [`LoadingScreenCatalog`]. +pub fn load_loading_screens(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(LOADING_SCREENS) + .with_context(|| format!("reading {LOADING_SCREENS}"))?; + let rs = parse(&bytes, schema(), "LoadingScreens")?; + let mut paths = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let (Some(id), Some(path)) = (u32_at(r, 0), str_at(&rs, r, 2)) { + paths.insert(id, path); + } + } + Ok(LoadingScreenCatalog { paths }) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::load_map_catalog; + + fn data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// Golden test of the full Map.dbc→LoadingScreens.dbc→BLP FK chain against the real client. + /// Verified pairs (decoded 2026-06-02): mapId 0 (Azeroth) → 4 → EasternKingdom art; mapId 1 + /// (Kalimdor) → 3 → Kalimdor art. Skips when the client isn't present. + #[test] + fn resolves_open_world_loading_art() { + let data = data_dir(); + if !data.is_dir() { + eprintln!("skipping: no WoW client at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let maps = load_map_catalog(&mut chain).expect("Map.dbc"); + let screens = load_loading_screens(&mut chain).expect("LoadingScreens.dbc"); + + // FK values (Map.dbc field 38). + assert_eq!(maps.loading_screen_id(0), Some(4), "Azeroth → 4"); + assert_eq!(maps.loading_screen_id(1), Some(3), "Kalimdor → 3"); + + // FK → BLP path (LoadingScreens.dbc field 2). + assert_eq!( + screens.path(4), + Some("Interface\\Glues\\LoadingScreens\\LoadScreenEasternKingdom.blp") + ); + assert_eq!( + screens.path(3), + Some("Interface\\Glues\\LoadingScreens\\LoadScreenKalimdor.blp") + ); + + // End-to-end resolution for both continents. + let resolve = |map_id| { + maps.loading_screen_id(map_id) + .and_then(|id| screens.path(id)) + }; + assert!(resolve(0) + .unwrap() + .ends_with("LoadScreenEasternKingdom.blp")); + assert!(resolve(1).unwrap().ends_with("LoadScreenKalimdor.blp")); + } +} diff --git a/crates/benilla-formats/src/lock.rs b/crates/benilla-formats/src/lock.rs new file mode 100644 index 00000000..070fac9c --- /dev/null +++ b/crates/benilla-formats/src/lock.rs @@ -0,0 +1,199 @@ +//! Lock.dbc — the requirements a lockable GameObject (or item) carries (decision 0239). A +//! GameObject's template `lockId` (a type-specific slot of its query `data[]`) indexes this table; +//! each lock has up to 8 requirement **slots**. Interacting with a locked object casts a *known* +//! spell whose `SPELL_EFFECT_OPEN_LOCK` `EffectMiscValue` matches a **skill** slot's `LockType` +//! index (mining / herbalism / lockpicking), or consumes the **item** slot's key. A `lockId` of 0, +//! or a row whose every slot is empty, means "no lock" — the object opens by `CMSG_GAMEOBJ_USE` +//! instead of a cast (the split the RE pinned; see `wow-5875-re` cursor-system.md §8). +//! +//! Layout verified against build 5875 (mangos `LockEntry`, `DBCStructure.h`, and the RE's +//! `[lockRec+0x24]` = `Index[0]` = column 9): **33 fields** — `ID@0`, `Type[8]@1..8`, +//! `Index[8]@9..16`, `Skill[8]@17..24`, `Action[8]@25..32` (present in the file, unused here). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +const LOCK: &str = "DBFilesClient\\Lock.dbc"; +/// The file's column count (must equal the DBC header `field_count` — `benilla-dbc` enforces it). +const LOCK_FIELDS: usize = 33; +/// A lock has up to 8 requirement slots (`MAX_LOCK_CASE`). +pub const MAX_LOCK_SLOTS: usize = 8; + +/// `Lock.dbc` `Type[i]` — a slot's key kind (mangos `LockKeyType`). +pub const LOCK_KEY_NONE: u32 = 0; +/// The slot is opened by holding a **key item**; `LockSlot::index` is that item's entry. +pub const LOCK_KEY_ITEM: u32 = 1; +/// The slot is opened by a **skill** (mining / herbalism / lockpicking); `LockSlot::index` is the +/// `LockType.dbc` index the opener spell's `EffectMiscValue` must match, `LockSlot::skill` the +/// required skill value. +pub const LOCK_KEY_SKILL: u32 = 2; + +/// One of a lock's up-to-8 requirement slots. An all-zero slot is empty (unused). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct LockSlot { + /// `LOCK_KEY_NONE` / `LOCK_KEY_ITEM` / `LOCK_KEY_SKILL`. + pub key_type: u32, + /// Key item entry (ITEM) or `LockType` index (SKILL); `0` when empty. + pub index: u32, + /// Required skill value (SKILL slots); `0` otherwise. + pub skill: u32, +} + +/// `lockId → its 8 requirement slots`, from Lock.dbc. +pub struct LockCatalog { + locks: HashMap, +} + +impl LockCatalog { + /// Build a catalog from explicit rows — tests and tools; the game path is + /// [`load_lock_catalog`]. + pub fn from_rows(rows: impl IntoIterator) -> Self { + Self { + locks: rows.into_iter().collect(), + } + } + + /// The 8 requirement slots for a `lockId`, or `None` if the id isn't in the table (treat as no + /// lock). A returned row may still be all-empty — [`LockCatalog::is_locked`] is the "must cast" + /// test. + pub fn slots(&self, lock_id: u32) -> Option<&[LockSlot; MAX_LOCK_SLOTS]> { + self.locks.get(&lock_id) + } + + /// Whether a `lockId` names a real lock (present, with at least one non-empty slot) — i.e. the + /// object opens by a cast, not `CMSG_GAMEOBJ_USE`. A `0` id or an absent/all-empty row is "no + /// lock". + pub fn is_locked(&self, lock_id: u32) -> bool { + lock_id != 0 + && self + .locks + .get(&lock_id) + .is_some_and(|s| s.iter().any(|slot| slot.key_type != LOCK_KEY_NONE)) + } + + pub fn len(&self) -> usize { + self.locks.len() + } + + pub fn is_empty(&self) -> bool { + self.locks.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("Lock"); + for i in 0..LOCK_FIELDS { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + s +} + +/// Load Lock.dbc from the patch chain into a [`LockCatalog`]. +pub fn load_lock_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(LOCK) + .with_context(|| format!("reading {LOCK}"))?; + let rs = parse(&bytes, schema(), "Lock.dbc")?; + let mut locks = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let mut slots = [LockSlot::default(); MAX_LOCK_SLOTS]; + for (i, slot) in slots.iter_mut().enumerate() { + slot.key_type = u32_at(r, 1 + i).unwrap_or(0); + slot.index = u32_at(r, 9 + i).unwrap_or(0); + slot.skill = u32_at(r, 17 + i).unwrap_or(0); + } + locks.insert(id, slots); + } + Ok(LockCatalog { locks }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Type/Index/Skill column offsets on the real build-5875 `Lock.dbc` — a column slip fails + /// loudly. Anchors: Copper Vein (lockId 38, a Mining node) and Silverleaf (lockId 29, an + /// Herbalism node), both skill 1 — the lowest of their profession. Skips without client data. + #[test] + fn real_lock_catalog_reads_skill_slots() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_lock_catalog(&mut chain).expect("load Lock.dbc"); + assert!(!cat.is_empty(), "Lock.dbc parsed empty"); + + // Copper Vein (gameobject_template 1731, chest.data0 = lockId 38): a MINING skill lock. The + // real row is `{Type[0]=2 SKILL, Index[0]=3 (Mining LockType), Skill[0]=0}` — `Type[0]` at + // col 1, `Index[0]` at col 9, `Skill[0]` at col 17; a column slip lands elsewhere. (Lock.dbc + // stores 0 in `Skill` for gathering nodes — the profession *spell* is the gate, not a value + // here; the server enforces the node's grey level.) + let vein = cat.slots(38).expect("lockId 38 (Copper Vein)"); + assert_eq!( + vein[0].key_type, LOCK_KEY_SKILL, + "copper vein is a skill lock" + ); + assert_eq!(vein[0].index, 3, "Mining is LockType index 3"); + assert_eq!( + vein[0].skill, 0, + "gathering nodes carry no Skill value in Lock.dbc" + ); + assert!( + vein[1..].iter().all(|s| s.key_type == LOCK_KEY_NONE), + "one slot only" + ); + assert!( + cat.is_locked(38), + "a mining vein is a real lock (must cast, not USE)" + ); + + // Silverleaf (gameobject_template 1617, lockId 29): an HERBALISM skill lock — LockType index + // 2, distinct from mining's 3. + let herb = cat.slots(29).expect("lockId 29 (Silverleaf)"); + assert_eq!( + herb[0].key_type, LOCK_KEY_SKILL, + "silverleaf is a skill lock" + ); + assert_eq!(herb[0].index, 2, "Herbalism is LockType index 2"); + assert_ne!(herb[0].index, vein[0].index, "herbalism ≠ mining LockType"); + + // A lockId of 0 is never a lock (opens by USE, not a cast). + assert!(!cat.is_locked(0)); + } + + /// The keyless-chest path: lockId 43 (Sunken Chest, Storage Chest, Worn Wooden Chest, …) is a + /// single SKILL slot naming LockType **13** — the one spell 6478 "Opening" opens, and "Opening" + /// is a default-known player spell, so any character can loot these. Confirms the routing matches + /// keyless chests (not just gathering nodes) to a spell the player already has. Skips without data. + #[test] + fn real_lock_catalog_reads_keyless_chest() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_lock_catalog(&mut chain).expect("load Lock.dbc"); + + let chest = cat.slots(43).expect("lockId 43 (simple chest)"); + assert!( + cat.is_locked(43), + "even a keyless chest is a real lock (cast, not USE)" + ); + // The requirement sits in slot 1 here (slot 0 empty) — the routing must scan all 8 slots, + // not just slot 0. LockType 13 = the "Opening" the default spell 6478 provides. + assert_eq!(chest[1].key_type, LOCK_KEY_SKILL); + assert_eq!( + chest[1].index, 13, + "the keyless-chest LockType spell 6478 opens" + ); + } +} diff --git a/crates/benilla-formats/src/lock_type.rs b/crates/benilla-formats/src/lock_type.rs new file mode 100644 index 00000000..8d4ac350 --- /dev/null +++ b/crates/benilla-formats/src/lock_type.rs @@ -0,0 +1,148 @@ +//! LockType.dbc — the small table that names a lock's *interaction kind* (Herbalism, Mining, Pick +//! Lock, Fishing, …), and — for the three that carry one — the **cursor** the client shows when you +//! hover a GameObject wearing that lock (decision 0236; wow-re cursor-system.md §4/§4a). The world +//! cursor's GameObject branch resolves a base-type GO's cursor by data: the GO template's `lockId` → +//! [`crate::LockCatalog`] row → its **first** requirement slot's `LockType` index → *this* table's +//! **CursorName** column. A non-empty CursorName (only `PickLock`/`GatherHerbs`/`Mine` in 5875) names +//! the cursor BLP directly; an empty one (every other lock kind — a plain chest, a door, fishing) +//! falls through to the generic **Interact** gear. `LockType.Id == 1` (Pick Lock) is additionally the +//! "never grayed" case the classifier special-cases. +//! +//! The localized **Name** block (`Name@1..8` + flags, enUS at field 1 — `[lockTypeRow + locale*4 + +//! 4]`, the exact read the lock-refusal toast performs at `0x5f34f9`) is the word the client fills +//! into the client-local "Requires %s" error for an unopenable skill lock — "Requires Herbalism" / +//! "Requires Mining" (wow-re cursor-system.md §8.8, decision 0545). +//! +//! Layout verified against build 5875 (byte-checked live: 19 records × 29 fields, record size 116): +//! `ID@0`, then the localized `Name` block, and **`CursorName@28`** (`[lockTypeRow+0x70]`, the exact +//! offset the RE pinned). Every field is 4 bytes, so the intervening columns are read as `UInt32` +//! filler — only `ID`, `Name` (enUS), and `CursorName` are consumed here. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +const LOCK_TYPE: &str = "DBFilesClient\\LockType.dbc"; +/// The file's column count (must equal the DBC header `field_count` — `benilla-dbc` enforces it). +const LOCK_TYPE_FIELDS: usize = 29; +/// The **CursorName** column (`[lockTypeRow+0x70]` = field 28, VERIFIED both by the RE offset and by +/// a live byte-dump of the 5875 file). +const CURSOR_NAME_FIELD: usize = 28; +/// The localized **Name** block's enUS column (field 1 — the toast's `[lockTypeRow + locale*4 + 4]` +/// with locale 0). +const NAME_FIELD: usize = 1; + +/// `LockType.Id → CursorName` for the rows that carry one — the data half of the GameObject cursor — +/// plus every row's localized `Name` (the lock-refusal toast's "Requires %s" fill). +/// Only the three interaction kinds with a distinct cursor keep one (`PickLock`/`GatherHerbs`/`Mine`); +/// every other row's CursorName is empty and simply absent here (→ the generic Interact gear). +pub struct LockTypeCatalog { + cursors: HashMap, + names: HashMap, +} + +impl LockTypeCatalog { + /// The `Interface\Cursor\.blp` stem for a `LockType` index, or `None` when that lock kind + /// carries no distinct cursor (→ the caller falls back to the Interact gear). The index is the + /// value the client reads straight out of the lock row's first requirement slot; a value that + /// isn't a real LockType id (e.g. a key-item entry, or `0` for an empty slot) simply misses. + pub fn cursor_name(&self, lock_type_id: u32) -> Option<&str> { + self.cursors.get(&lock_type_id).map(String::as_str) + } + + /// The localized `Name` of a lock kind ("Herbalism", "Mining", "Pick Lock", …) — the word the + /// lock-refusal toast fills into "Requires %s" (wow-re cursor-system.md §8.8; the client falls + /// back to the literal `"UNKNOWN"` when the row is missing — that fallback is the caller's). + pub fn name(&self, lock_type_id: u32) -> Option<&str> { + self.names.get(&lock_type_id).map(String::as_str) + } + + pub fn len(&self) -> usize { + self.cursors.len() + } + + pub fn is_empty(&self) -> bool { + self.cursors.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("LockType"); + for i in 0..LOCK_TYPE_FIELDS { + // Only ID(0), Name-enUS(1) and CursorName(28) are read; the rest are 4-byte filler + // (localization columns) declared UInt32 so the record size still matches the file header. + let ty = if i == CURSOR_NAME_FIELD || i == NAME_FIELD { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("F{i}"), ty)); + } + s +} + +/// Load LockType.dbc from the patch chain into a [`LockTypeCatalog`]. +pub fn load_lock_type_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(LOCK_TYPE) + .with_context(|| format!("reading {LOCK_TYPE}"))?; + let rs = parse(&bytes, schema(), "LockType.dbc")?; + let mut cursors = HashMap::new(); + let mut names = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(name) = str_at(&rs, r, CURSOR_NAME_FIELD) { + cursors.insert(id, name); + } + if let Some(name) = str_at(&rs, r, NAME_FIELD) { + names.insert(id, name); + } + } + Ok(LockTypeCatalog { cursors, names }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three cursor-bearing rows on the real build-5875 `LockType.dbc`, byte-anchored: a column + /// slip (or an off-by-one on the CursorName offset) lands on empty/other columns and fails loudly. + /// Skips without client data. + #[test] + fn real_lock_types_name_their_cursors() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_lock_type_catalog(&mut chain).expect("load LockType.dbc"); + + // The full cursor-bearing set in 5875 — Pick Lock (1), Herbalism (2), Mining (3). + assert_eq!(cat.cursor_name(1), Some("PickLock")); + assert_eq!(cat.cursor_name(2), Some("GatherHerbs")); + assert_eq!(cat.cursor_name(3), Some("Mine")); + // Every other lock kind (Open, Fishing, Disarm Trap, …) carries no cursor → Interact gear. + assert_eq!(cat.cursor_name(5), None); // Open (the keyless-chest LockType 13 is likewise blank) + assert_eq!(cat.cursor_name(13), None); + assert_eq!(cat.cursor_name(19), None); // Fishing + assert_eq!(cat.cursor_name(0), None); // an empty lock slot's index + assert_eq!( + cat.len(), + 3, + "only three LockType rows carry a CursorName in 5875" + ); + + // The localized Name column — the lock-refusal toast's "Requires %s" fill (decision 0545). + // A column slip here would print e.g. "Requires Herbs" (ResourceName@10) on screen. + assert_eq!(cat.name(1), Some("Pick Lock")); + assert_eq!(cat.name(2), Some("Herbalism")); + assert_eq!(cat.name(3), Some("Mining")); + assert_eq!(cat.name(19), Some("Fishing")); + assert_eq!(cat.name(0), None); + } +} diff --git a/crates/benilla-formats/src/maps.rs b/crates/benilla-formats/src/maps.rs new file mode 100644 index 00000000..d99269d8 --- /dev/null +++ b/crates/benilla-formats/src/maps.rs @@ -0,0 +1,120 @@ +//! Map.dbc loader: resolves a server-side `mapId` to its **MPQ directory name** (`0` → +//! `"Azeroth"`, `1` → `"Kalimdor"`, `36` → `"DeadminesInstance"`, …) so the cross-map teleport +//! handler can call `MapTiles::load(chain, dir)` for the new world. +//! +//! Layout — VERIFIED against build 5875 (`xxd` on extracted `DBFilesClient\Map.dbc`, +//! 2026-05-29; field 38 confirmed 2026-06-02): the WDBC header reports **44 records · 42 fields +//! · 168 B/record**. Load-bearing for us: `ID` (0), `Directory` (1), and **`LoadingScreenID` +//! (38)** — an FK into `LoadingScreens.dbc` (see [`crate::LoadingScreenCatalog`]) selecting the +//! map's full-screen load art. Verified empirically: field 38 ∈ the LoadingScreens id-set for 39 +//! of 44 maps (the 5 zeros are dev/test maps), with exact known pairs (mapId 0 `Azeroth` → 4, +//! mapId 1 `Kalimdor` → 3, `DeadminesInstance` → 142). The remaining dwords are `MapName_loc` +//! (9), `MapDescription0_loc` (9), `MapDescription1_loc` (9), plus a few ints we don't need. +//! Reading them as `UInt32` placeholders is fine — DBC fields are 4 bytes regardless of declared +//! type, so the schema only has to add up to 42 × 4 = 168. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; + +const MAP: &str = "DBFilesClient\\Map.dbc"; + +/// Field index of the `LoadingScreenID` FK (see module docs). 0 means "no art" (dev/test maps). +const LOADING_SCREEN_FIELD: usize = 38; + +/// Resolved per-map data, built once at startup off the client's Map.dbc. +pub struct MapCatalog { + dirs: HashMap, + /// `mapId → MapName_Lang[enUS]` (field 4, `+0x10` — the offset the binary's map-name reader + /// `0x4a65a0` uses against this same patch-2 layout). The world map's continent dropdown + /// displays THIS ("Eastern Kingdoms"), not the WorldMapArea art-folder string ("Azeroth") — + /// wow-re Q3 verdict, 2026-07-07. + names: HashMap, + /// `mapId → LoadingScreenID` FK into `LoadingScreens.dbc` (only maps with a non-zero FK). + loading_screens: HashMap, +} + +impl MapCatalog { + /// MPQ directory name (the `Directory` column) for `map_id`, or `None` if the DBC has no + /// such row. Use with `MapTiles::load(chain, dir)`. + pub fn directory(&self, map_id: u32) -> Option<&str> { + self.dirs.get(&map_id).map(String::as_str) + } + + /// The localized display name (`MapName_Lang`, enUS) for `map_id` — "Eastern Kingdoms", + /// "Kalimdor", "The Deadmines", … + pub fn name(&self, map_id: u32) -> Option<&str> { + self.names.get(&map_id).map(String::as_str) + } + + /// `LoadingScreenID` for `map_id` — the FK to resolve against [`crate::LoadingScreenCatalog`] + /// for the load-art BLP. `None` for dev/test maps that carry no screen. This is the same + /// mechanism for *every* map kind (open world, instance, battleground) — only the art row + /// differs. + pub fn loading_screen_id(&self, map_id: u32) -> Option { + self.loading_screens.get(&map_id).copied() + } + + pub fn len(&self) -> usize { + self.dirs.len() + } + + pub fn is_empty(&self) -> bool { + self.dirs.is_empty() + } +} + +/// Field index of the enUS `MapName_Lang` string (`+0x10`, see [`MapCatalog::names`]). +const MAP_NAME_FIELD: usize = 4; + +/// 42 fields total; field 0 = ID, field 1 = Directory string, field 4 = MapName (enUS), +/// field 38 = LoadingScreenID (FK). Remaining fields are placeholders (4-byte dwords; the schema +/// only needs to total 168 B). +fn map_schema() -> Schema { + let mut s = Schema::new("Map"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("Directory", FieldType::String)); + for i in 2..42 { + match i { + MAP_NAME_FIELD => s.add_field(SchemaField::new("MapName", FieldType::String)), + LOADING_SCREEN_FIELD => { + s.add_field(SchemaField::new("LoadingScreenID", FieldType::UInt32)) + } + _ => s.add_field(SchemaField::new(format!("_pad{i}"), FieldType::UInt32)), + } + } + s +} + +/// Read Map.dbc off the patch chain into a [`MapCatalog`]. +pub fn load_map_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(MAP) + .with_context(|| format!("reading {MAP}"))?; + let rs = parse(&bytes, map_schema(), "Map")?; + let mut dirs = HashMap::with_capacity(rs.records().len()); + let mut names = HashMap::with_capacity(rs.records().len()); + let mut loading_screens = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(dir) = str_at(&rs, r, 1) { + dirs.insert(id, dir); + } + if let Some(name) = str_at(&rs, r, MAP_NAME_FIELD).filter(|n| !n.is_empty()) { + names.insert(id, name); + } + // 0 = "no loading screen" (dev/test maps); only record real FKs. + if let Some(ls) = u32_at(r, LOADING_SCREEN_FIELD).filter(|&v| v != 0) { + loading_screens.insert(id, ls); + } + } + Ok(MapCatalog { + dirs, + names, + loading_screens, + }) +} diff --git a/crates/benilla-formats/src/minimap_translate.rs b/crates/benilla-formats/src/minimap_translate.rs new file mode 100644 index 00000000..bf9f793c --- /dev/null +++ b/crates/benilla-formats/src/minimap_translate.rs @@ -0,0 +1,150 @@ +//! `textures\Minimap\md5translate.trs` — the minimap tile hash catalog: a plain-text index mapping +//! each authored minimap tile name to the content-hashed filename actually stored under +//! `textures\Minimap\` (Blizzard's dedup scheme — many tiles across zones/instances share one hash; +//! decision 0203 phase 0). +//! +//! Format — VERIFIED against build 5875 (727 561 B, extracted via `benilla-extract` this session, +//! 2026-07-07): CRLF-terminated text, 235 `dir: ` section headers, 8401 tab-separated data +//! lines `\.blp\t.blp`. Every data line already repeats its full left-hand +//! directory (checked exhaustively: all 8401 left-hand paths start, case-insensitively, with the +//! preceding `dir:` header) — the header is cosmetic for parsing, so keying straight off the left +//! column needs no header-tracking state. Two path shapes share the file and the same table, +//! un-special-cased: **ADT tiles** (`\map_.blp`, one per streamed terrain tile — e.g. +//! `AhnQiraj\map27_46.blp`) and **WMO icon tiles** (`WMO\...\___.blp`, the +//! minimap building-corner icons). All 8401 left-hand paths are unique case-insensitively. +//! +//! Verified example: `Azeroth\map32_48.blp → ea283abc0bf9637c3fad5e840a65b38b.blp`, and that hash is +//! itself readable at `textures\Minimap\ea283abc0bf9637c3fad5e840a65b38b.blp` (decodes as a 256×256 +//! BLP2 — confirmed this session). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; + +use crate::Chain; + +const MD5_TRANSLATE: &str = "textures\\Minimap\\md5translate.trs"; + +/// The parsed `md5translate.trs`: every left-hand path (lowercased) to its hashed `.blp` filename. +pub struct MinimapTranslate { + /// `"\.blp"` (lowercased) → the hashed filename on disk under `textures\Minimap\` + /// (e.g. `"ea283abc0bf9637c3fad5e840a65b38b.blp"`). + entries: HashMap, +} + +impl MinimapTranslate { + /// The hashed minimap tile filename for `map_dir`'s ADT tile `(x, y)`, or `None` if this tile + /// was never authored (open ocean / unstreamed tiles have no minimap art). `map_dir` is + /// case-insensitive (MPQ path convention) — e.g. `tile("Azeroth", 32, 48)` resolves the + /// verified `ea283abc0bf9637c3fad5e840a65b38b.blp`. Join the result onto + /// `textures\Minimap\` to read the tile itself. + pub fn tile(&self, map_dir: &str, x: u32, y: u32) -> Option<&str> { + let key = format!("{map_dir}\\map{x}_{y}.blp").to_ascii_lowercase(); + self.entries.get(&key).map(String::as_str) + } + + /// The hashed filename for an arbitrary logical tile path (case-insensitive, `\`-separated), or + /// `None` if unauthored — e.g. the WMO interior tiles + /// `wmo\KhazModan\Cities\Ironforge\ironforge_001_00_00.blp`. The ADT [`Self::tile`] is the + /// `map_` special case of this. + pub fn get(&self, logical_path: &str) -> Option<&str> { + self.entries + .get(&logical_path.to_ascii_lowercase()) + .map(String::as_str) + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +/// Parse `md5translate.trs`'s text body (CRLF or LF line endings; a `dir:` header line is skipped +/// — see the module doc, every data line already carries its full directory). +fn parse_trs(text: &str) -> HashMap { + let mut entries = HashMap::new(); + for raw in text.split('\n') { + let line = raw.trim_end_matches('\r').trim(); + if line.is_empty() || line.starts_with("dir:") { + continue; + } + let Some((left, right)) = line.split_once('\t') else { + continue; + }; + entries.insert(left.to_ascii_lowercase(), right.to_string()); + } + entries +} + +/// Read `md5translate.trs` off the patch chain into a [`MinimapTranslate`]. +pub fn load_minimap_translate(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(MD5_TRANSLATE) + .context("reading md5translate.trs")?; + let text = String::from_utf8_lossy(&bytes); + Ok(MinimapTranslate { + entries: parse_trs(&text), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_dir_headers_and_tab_separated_rows_case_insensitively() { + let text = "dir: AhnQiraj\r\n\ + AhnQiraj\\map27_46.blp\t1fcd95d6d410e7557d6b62081c5e87b5.blp\r\n\ + dir: Azeroth\r\n\ + Azeroth\\map32_48.blp\tea283abc0bf9637c3fad5e840a65b38b.blp\r\n"; + let entries = parse_trs(text); + assert_eq!(entries.len(), 2); + let cat = MinimapTranslate { entries }; + assert_eq!( + cat.tile("Azeroth", 32, 48), + Some("ea283abc0bf9637c3fad5e840a65b38b.blp") + ); + assert_eq!( + cat.tile("azeroth", 32, 48), + Some("ea283abc0bf9637c3fad5e840a65b38b.blp"), + "map_dir is case-insensitive (MPQ path convention)" + ); + assert_eq!(cat.tile("Azeroth", 99, 99), None); + } + + /// Real chain: the verified Azeroth tile resolves, and the hashed file it names is itself + /// readable in the chain as a 256×256 BLP2. Skips without client data. + #[test] + fn real_md5translate_resolves_azeroth_tile_and_hash_is_readable() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_minimap_translate(&mut chain).expect("load md5translate.trs"); + assert_eq!(cat.len(), 8401, "all 8401 data rows parse to unique keys"); + + let hash = cat + .tile("Azeroth", 32, 48) + .expect("Azeroth\\map32_48 resolves"); + assert_eq!(hash, "ea283abc0bf9637c3fad5e840a65b38b.blp"); + + let tile_path = format!("textures\\Minimap\\{hash}"); + let (w, h, _rgba) = + crate::read_texture_rgba(&mut chain, &tile_path).expect("hashed tile decodes as BLP"); + assert_eq!((w, h), (256, 256)); + + // The WMO interior tile key format the minimap builds (`___.blp`, the + // stem being the `.wmo` path minus `World\` + extension) resolves case-insensitively against + // the real trs — the end-to-end link the interior renderer's tile lookup depends on. + assert!( + cat.get("wmo\\KhazModan\\Cities\\Ironforge\\ironforge_001_00_00.blp") + .is_some(), + "the Ironforge group-1 interior tile resolves" + ); + } +} diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs new file mode 100644 index 00000000..3d154838 --- /dev/null +++ b/crates/benilla-formats/src/models/anim.rs @@ -0,0 +1,843 @@ +//! M2 skeleton + animation parsing (decision 0019): the rest skeleton (bones, parent + pivot) and +//! every sequence's per-bone keyframe tracks, which the skinned-entity path turns into Bevy clips. +//! Split out of the model parser as its own concern. + +use std::io::Cursor; + +use anyhow::Result; +use benilla_m2::parse_m2; + +use super::{le_f32, le_u16, le_u32}; + +/// One bone of a model's rest skeleton (decision 0019): its parent bone index (`-1` = root) and pivot +/// point in **raw WoW model space** (the render boundary maps it to Bevy space). Vanilla M2 has no +/// inverse-bind-matrix array — the rest pose is identity TRS and the **pivot encodes bind position** +/// (VERIFIED wow-5875-re), so the skinned-entity path builds bone matrices pivot-relative up the chain. +#[derive(Debug, Clone, Copy)] +pub struct SkeletonBone { + pub parent: i16, + pub pivot: [f32; 3], + /// `KeyBoneID` (`-1` = none): 0/1 arms L/R, 2/3 shoulders L/R, … — the per-arm animation masks + /// find their subtree roots through it. + pub key_bone: i16, + /// The bone's billboard arm (flags `0x08/0x10/0x20/0x40`), `None` for an ordinary bone — the + /// palette-level camera facing a rigged host applies per frame (children inherit it). + pub billboard: Option, + /// Bone flag `0x04` — the bone keeps the MODEL ROOT's orientation instead of inheriting its + /// parent's rotation (its pivot still rides the parent's full matrix). Carried by the unskinned + /// attach-helper bones (HandArrow/Bullet 126/127 — the nocked arrow lies flat along the facing + /// instead of twisting with the draw hand). The client's bone-palette build honors the flag; + /// the attach-child transform then inherits it (wow-re `nocked-ammo-cancel.md` §E4). + pub ignore_parent_rotation: bool, +} + +/// A model's bone hierarchy — the rest skeleton the skinned-entity path turns into a joint-entity +/// tree + inverse bind poses. Bones are in M2 file order; a vertex's `joints` index into this list. +/// Empty for a boneless model. +#[derive(Debug, Clone, Default)] +pub struct Skeleton { + pub bones: Vec, +} + +/// Parse the M2 bone hierarchy (parent + pivot per bone) into a [`Skeleton`]. Straight off +/// `benilla-m2`'s bone parse (vanilla bone record stride 0x6c: parent i16 @+0x08, pivot C3 @+0x60, +/// VERIFIED wow-5875-re). A separate byte-in entry point alongside [`parse_m2_render_submeshes`], so +/// the asset loader builds the skeleton beside the meshes without changing that signature. +pub fn parse_m2_skeleton(bytes: &[u8]) -> Result { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + let bones = format + .model() + .bones + .iter() + .map(|b| SkeletonBone { + parent: b.parent, + pivot: [b.pivot.x, b.pivot.y, b.pivot.z], + key_bone: b.key_bone, + billboard: crate::BillboardKind::from_bone_flags(b.flags.bits()), + ignore_parent_rotation: b.flags.bits() & 0x4 != 0, + }) + .collect(); + Ok(Skeleton { bones }) +} + +/// One M2 attachment-point record (decision 0072 — held items): the attach id (`0` shield, `1` +/// right hand, `2` left hand, sheath/hip/back pairs at `26..33`), the bone it rides, and its +/// **raw WoW model-space** position (see `benilla_m2::M2Attachment` — the same shape, re-exposed +/// here as the bytes-in entry point alongside [`parse_m2_skeleton`], so callers that only take +/// `benilla-formats` don't need a direct `benilla-m2` dependency). +#[derive(Debug, Clone, Copy)] +pub struct M2Attachment { + pub id: u16, + pub bone: u16, + pub position: [f32; 3], +} + +/// Parse the M2 attachment-point table (see [`M2Attachment`]). Straight off `benilla-m2`'s already +/// bone-range-checked parse; empty for a model with no attachment points. +pub fn parse_m2_attachments(bytes: &[u8]) -> Result> { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + Ok(format + .model() + .attachments + .iter() + .map(|a| M2Attachment { + id: a.id, + bone: a.bone, + position: a.position, + }) + .collect()) +} + +/// One M2 animation-event **positional marker** (`benilla_m2::M2EventMarker` re-exposed as the +/// bytes-in entry point, like [`M2Attachment`]): the 4CC, the bone it rides, and its **raw WoW +/// model-space** position. The client resolves positions off this table by 4CC, first match +/// (`0x7130e0`/`0x7131b0`) — the cast-release launch points `$CSL`/`$CSR`/`$CST` (casting hand +/// left/right/two-hand, `0x60c9b0`'s cascade) and the ranged release `$BWR` ride it; the fire +/// *times* are the per-sequence [`AnimEvent`] stream. +#[derive(Debug, Clone, Copy)] +pub struct EventMarker { + /// The identifier 4CC, stored forward (`*b"$CSL"`). + pub ident: [u8; 4], + pub bone: u16, + pub position: [f32; 3], +} + +/// Parse the M2 event table's positional markers (see [`EventMarker`]). Straight off +/// `benilla-m2`'s already bone-range-checked parse, file order preserved (queries take the first +/// ident match); empty for a model with no events. +pub fn parse_m2_event_markers(bytes: &[u8]) -> Result> { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + Ok(format + .model() + .event_markers + .iter() + .map(|m| EventMarker { + ident: m.ident, + bone: m.bone, + position: m.position, + }) + .collect()) +} + +/// A bow M2's **bowstring anchors** — the `$WTT` (top) / `$WTB` (bottom) EVENT records the +/// client's string drawer `0x611ff0` spans (wow-re `nocked-ammo-cancel.md` §G2: `0x7131b0` finds +/// each marker, transforms its local position by its bone's live matrix, and a 2-segment line +/// list runs top → middle → bottom). Each anchor is `(bone index, raw WoW model-space position)` +/// — the limb-tip helper bones (8/9 on the standard bows), which the bow's own BowPull/BowRelease +/// sequences bend. The markers are pure position records (no timestamps), which is why they never +/// surface in the per-sequence [`AnimEvent`] stream. +#[derive(Clone, Copy)] +pub struct StringAnchors { + pub top: (u16, [f32; 3]), + pub bottom: (u16, [f32; 3]), +} + +/// Parse a model's `$WTT`/`$WTB` bowstring anchors from the raw M2 event table (stride 44: +/// identifier 4CC @+0, bone @+8, position C3 @+12 — the same record `parse_m2_animations` reads +/// timestamps from). `None` when either marker is absent (every non-bow model). +pub fn parse_m2_string_anchors(b: &[u8]) -> Option { + let (ev_count, ev_ofs) = (le_u32(b, 0x114) as usize, le_u32(b, 0x118) as usize); + let (mut top, mut bottom) = (None, None); + for e in 0..ev_count { + let erec = ev_ofs + e * 44; + if erec + 44 > b.len() { + break; + } + let ident = b.get(erec..erec + 4)?; + let anchor = ( + le_u32(b, erec + 8) as u16, + [ + le_f32(b, erec + 12), + le_f32(b, erec + 16), + le_f32(b, erec + 20), + ], + ); + match ident { + b"$WTT" => top = Some(anchor), + b"$WTB" => bottom = Some(anchor), + _ => {} + } + } + Some(StringAnchors { + top: top?, + bottom: bottom?, + }) +} + +/// One row of the M2's baked **PlayableAnimationLookup** table (decision 0082 — missing-animation-clip +/// resolution): re-exposed from `benilla-m2`'s [`benilla_m2::M2PlayableAnim`] (identical shape) as the +/// bytes-in entry point alongside [`parse_m2_skeleton`]/[`parse_m2_attachments`], so callers that only +/// take `benilla-formats` don't need a direct `benilla-m2` dependency. +#[derive(Debug, Clone, Copy)] +pub struct PlayableAnim { + /// The `AnimationData.dbc` id this model actually plays for the row's requested id. + pub resolved_id: u16, + /// Direction/variant playback code — plumbed through, not yet applied (see + /// [`benilla_m2::M2PlayableAnim::dir_flags`]). + pub dir_flags: u16, +} + +/// Parse the M2's [`PlayableAnim`] table (decision 0082, wow-re `anim-id-resolution.md`, +/// byte-verified `0x711bf0`): the model's own precomputed answer to "if the game requests +/// `AnimationData.dbc` id X, which id do I actually play, and in which direction/variant" — the +/// source `benilla-assets`' `ModelAnimations::resolve` PATH 1 reads. Straight off `benilla-m2`'s +/// header array (`count@+0x2c`/`ofs@+0x30`); empty for a model with no table (a boneless/malformed +/// M2), which the resolver then degrades to identity for. +pub fn parse_m2_playable_animation_lookup(bytes: &[u8]) -> Result> { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + Ok(format + .model() + .playable_animation_lookup + .iter() + .map(|p| PlayableAnim { + resolved_id: p.resolved_id, + dir_flags: p.dir_flags, + }) + .collect()) +} + +/// One bone's keyframe tracks for an animation sequence (decision 0019), in **raw WoW model space**: +/// translation/scale are `[f32;3]`, rotation is an uncompressed quaternion `[x,y,z,w]` (vanilla v256). +/// Times are **seconds**, rebased to the sequence start so each clip runs `0..duration`. Only the +/// channels the bone actually animates are non-empty; a bone absent from [`ModelAnimation::bones`] +/// holds its bind pose. +#[derive(Debug, Clone)] +pub struct BoneKeys { + pub bone: u16, + pub translation: Vec<(f32, [f32; 3])>, + pub rotation: Vec<(f32, [f32; 4])>, + pub scale: Vec<(f32, [f32; 3])>, +} + +/// One bone channel driven by a **global sequence**: a free-running loop *independent* of the playing +/// animation, wrapped at `period_ms` off the model's own clock (VERIFIED wow-5875-re `animation.md` / +/// `doodad-anim-host.md`: global sequences loop with zero arming, clock = `[CM2Model+0x2c]+0xc` mod +/// `globalSequences[gseq]`). Keys are absolute ms within `[0, period_ms]`, ascending. The canonical +/// consumer is the character **eye blink** — an eyelid bone whose SCALE track holds `0` (lid retracted, +/// eye open) for most of the loop and pops to `1` (lid full-size, eye shut) for ~100 ms. +#[derive(Debug, Clone)] +pub struct GlobalSeqChannel { + pub period_ms: u32, + pub keys: Vec<(u32, T)>, +} + +/// A bone's global-sequence-driven channels (any of translation/rotation/scale; each carries its own +/// global sequence and thus its own period). Bones with no such channel are absent from the list. These +/// are exactly the tracks [`parse_m2_animations`] drops (it reads only the playing sequence's band); the +/// runtime samples these on the free clock and composes them over the sequence pose. +#[derive(Debug, Clone)] +pub struct GlobalSeqBone { + pub bone: u16, + pub translation: Option>, + pub rotation: Option>, + pub scale: Option>, +} + +/// One animation **event keyframe**: a 4CC-tagged trigger on the sequence timeline (decision 0070 +/// slice 3 — the anim-driven sound/effect surface). Sound-relevant tags: `$SND`/`$DSL`/`$DSO` +/// (play kit `data`), the footstep family (`$FL0..3`/`$FR`/`$RL`/`$RR`/`$SL`/`$SR`/`$BL`/`$BR` + +/// `$FSD`), `$CSS` weapon swing, `$HIT` impact, `$DTH` death thud, `$FD1..4` fidgets, +/// `$AH0..3` custom attacks, `$BTH` breath. Non-sound tags pass through untouched. +#[derive(Debug, Clone, Copy)] +pub struct AnimEvent { + /// Seconds from the sequence start (same clock as the bone keys). + pub time: f32, + /// The identifier 4CC, stored forward (`*b"$FL0"`) — byte-verified on DireWolf.m2. + pub ident: [u8; 4], + /// The payload — a SoundEntries id for `$SND`/`$DSL`/`$DSO`, else 0. + pub data: u32, +} + +/// One animation sequence's per-bone keyframes (decision 0019): its `AnimationData.dbc` id, duration, +/// loop flag, and the bones that move. **Stand is `anim_id` 0** — the idle the real client arms by default +/// (VERIFIED wow-5875-re) — but its *record index* is not fixed: Stand is `animationLookup[0]`, which is +/// record 0 only for some models (a chicken's Stand is record 2, a horse's record 1; those models' record +/// 0 is a different sequence). Consumers select always by `anim_id`, never by record index; walk (4), +/// run (5), death, emotes, … are other ids. +#[derive(Debug, Clone)] +pub struct ModelAnimation { + /// `AnimationData.dbc` id, the selection key (0 Stand, 4 Walk, 5 Run, 13 WalkBackwards, …). + pub anim_id: u16, + pub duration: f32, + pub looping: bool, + /// The sequence's authored **design movement speed** (`M2Sequence.moveSpeed` @+0x0c, yd/s) — the + /// speed the locomotion was animated for. The real client scales a locomotion clip's playback rate + /// by `unitSpeed / (moveSpeed · modelScale)` (VERIFIED wow-5875-re `0x5fe2f0`: a §5-converged pair), + /// so a unit moving faster than the design speed cycles its legs proportionally faster — without it + /// a backpedal (slow design speed) played at 1× looks far too slow. `0.0` for a non-locomotion + /// sequence (idle/emote), which the selector leaves at rate 1×. + pub move_speed: f32, + /// The sequence's **blend-in time** (`M2Sequence.blendTime` @+0x20, **seconds**) — how long the real + /// client cross-fades *into* this animation from the previous pose (VERIFIED wow-5875-re: op4 snapshots + /// the live pose `+0x98→+0xc4` and decays it over the blend-in, `rf29-playback-setters.md`). The spawn + /// site uses it as the cross-fade duration so a gait change eases instead of snapping (Walk/Run 0.25 s, + /// Stand 0.5 s, jump/sit transitions 0.15 s). `0.0` ⇒ an instant cut. + pub blend_time: f32, + /// The sequence's bounds-sphere **centre** — the `M2Sequence` CAaBox (min @+0x24, max @+0x30) centre, + /// **raw WoW model space**. The real client's mouse-pick broad phase tests the cursor ray against + /// exactly this sphere for the unit's *current* animation — world-placed + scaled, **no pad** + /// (VERIFIED wow-5875-re `0x7089c0`, the pick-volume RE) — before the per-triangle posed-mesh test. + pub bounds_center: [f32; 3], + /// The sequence's bounds-sphere **radius** (`M2Sequence` @+0x3c, model-local yards). `0.0` ⇒ + /// unauthored (the real client falls back to the header sphere). + pub bounds_radius: f32, + /// The sequence's CAaBox **min corner** (`M2Sequence` @+0x24), raw WoW model space — the box + /// [`Self::bounds_center`] is the centre of. The unit **blob shadow** sizes its projection box + /// from exactly this box for the *current* animation, clamped into ±5 per axis (VERIFIED + /// wow-5875-re `unit-blob-shadow.md`: `0x711a20` → clamp `0x6992c0`/`0x699250`). + pub bounds_min: [f32; 3], + /// The sequence's CAaBox **max corner** (`M2Sequence` @+0x30). See [`Self::bounds_min`]. + pub bounds_max: [f32; 3], + /// The sequence's **variation frequency** (`M2Sequence.frequency` @+0x14) — this variation's + /// weight in the client's per-play roll (VERIFIED wow-5875-re `anim-id-resolution.md`: op4 + /// with variationIdx −1 rolls `_rand()` (0..0x7fff) and walks the id's variation chain — + /// `roll < frequency` picks, else `roll -= frequency` and advance). + pub frequency: u16, + /// The sequence's **replay range** (`M2Sequence` minReplay @+0x18 / maxReplay @+0x1c) — the + /// client rolls a play count `R = max(1, min + ⌊rand·(max−min)/32768⌋)` at every arm (the + /// second `_rand` site in op4, `0x712692..0x7126cd`) and **multiplies it into the play + /// window** (`0x7126d8`): a clamp-flag one-shot runs its timeline `R` times before freezing; + /// a loop-flag sequence ignores it (VERIFIED wow-5875-re `loop-replay-fidget.md`). `(0, 0)` + /// (the overwhelming majority) rolls to `R = 1`. + pub min_replay: u32, + pub max_replay: u32, + pub bones: Vec, + /// Event keyframes in this sequence's window, rebased like the bone keys (seconds from start). + pub events: Vec, +} + +/// Read one bone `M2Track`'s keyframes for the sequence spanning `[seq_start_ms, seq_end_ms]` (absolute +/// ms), rebased to seconds from the start. `read_value` decodes one value at a byte offset; `stride` is +/// its size. The v256 `M2Track` (0x1c): `interp_type`@0, `global_seq`@2, then three `M2Array`s — +/// interpolation_ranges@0x04, **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`). +/// +/// Keys are selected by **absolute timestamp within the sequence's time band**, NOT via +/// `interpolation_ranges`: the flat timestamp/value arrays concatenate every sequence's keys +/// (timestamps absolute, VERIFIED), the sequences occupy disjoint bands, and this is exactly what the +/// real client's kernel does (rebase playback time into `[seqStart, seqEnd]`, then search the +/// timestamps). The `interpolation_ranges` window proved unreliable in real vanilla art — its end index +/// can point past the sequence into a later one (Chicken/Bear/Kobold/…: a key 14–64 s away), which froze +/// those creatures at a garbage pose: the range's far key is a clamp *endpoint*, never a playable +/// in-clip key. A **global-sequence** track (`global_seq != 0xffff`) loops on its own clock and is +/// skipped here (the global-sequence post-pass is deferred). +/// +/// A band with **no keys at all** still has an authored pose: the real sampler clamps/lerps between +/// the keys bracketing the band (that is what the per-sequence `interpolation_ranges` pairs encode — +/// HumanMale bone 27 rot has 4 keys and `ranges[Run] = (2, 3)`, both outside Run's band), so a keyed +/// bone is **never** left unsampled. Dropping the track instead froze the bone at whatever the +/// *previous* clip left (the task-#14 tilt: a Stand-variation waist twist rode through the entire +/// run, exposed the day the 0123 variation roll first armed non-head Stands). We emit one constant +/// key holding the **nearest** authored key's value — the value the real clamp lands on whenever the +/// band sits close to one bracket (vanilla bands are 0.3–4 s; bracket gaps are minutes), a named +/// approximation of the mid-gap lerp otherwise. +fn read_bone_track( + b: &[u8], + track: usize, + seq_start_ms: u32, + seq_end_ms: u32, + stride: usize, + read_value: impl Fn(&[u8], usize) -> T, +) -> Vec<(f32, T)> { + if track + 0x1c > b.len() { + return Vec::new(); + } + let (times_n, times_o) = ( + le_u32(b, track + 0x0c) as usize, + le_u32(b, track + 0x10) as usize, + ); + let vals_o = le_u32(b, track + 0x18) as usize; + if le_u16(b, track + 0x02) != 0xffff { + // A **global-sequence** track: driven by its own looping clock, independent of the playing + // sequence. The single-key case is a pure CONSTANT channel — this is how vanilla art authors + // the stowed-weapon attach-bone orientations (HumanMale bones 29/30/58–62: one rotation key + // at t=0 on global-seq 0, itself 0 ms long — the blade-down / shield-on-back quaternions; + // dumped 2026-07-03, the floating-stowed-sword bug). Emit the constant into every sequence's + // window (`keyframe_curve` holds a lone key across the clip). A *time-varying* multi-key + // global track (a fidget glow on its own clock) still needs the global clock — deferred. + if times_n == 1 && vals_o + stride <= b.len() { + return vec![(0.0, read_value(b, vals_o))]; + } + return Vec::new(); + } + let mut out = Vec::new(); + // The closest out-of-band key, by distance to the band — the clamp value if the band is empty. + let mut nearest: Option<(u32, usize)> = None; + for k in 0..times_n { + let (t_off, v_off) = (times_o + k * 4, vals_o + k * stride); + if t_off + 4 > b.len() || v_off + stride > b.len() { + break; + } + let ts = le_u32(b, t_off); + if ts < seq_start_ms || ts > seq_end_ms { + // A key in another sequence's band — not playable here, but remembered as the + // clamp candidate (see the module doc: an empty band still has an authored pose). + let d = if ts < seq_start_ms { + seq_start_ms - ts + } else { + ts - seq_end_ms + }; + if nearest.is_none_or(|(best, _)| d < best) { + nearest = Some((d, k)); + } + continue; + } + out.push(((ts - seq_start_ms) as f32 / 1000.0, read_value(b, v_off))); + } + if out.is_empty() { + if let Some((_, k)) = nearest { + let v_off = vals_o + k * stride; + if v_off + stride <= b.len() { + out.push((0.0, read_value(b, v_off))); + } + } + } + out +} + +/// The weapon-grip finger poses: each requested `bone`'s rotation at the **HandsClosed** (`AnimationData` +/// id 15) frame, **clamped** to the nearest key at-or-before that frame. Scoped to the grip on purpose — +/// a weapon-hand finger bone is keyed in *adjacent* global-timeline bands but NOT the HandsClosed frame +/// itself (on HumanMale bone 102 is keyed at 43333/70000 ms, both curled, not the 60000 ms frame), so the +/// general per-sequence in-band read ([`read_bone_track`]) correctly drops it — yet the real client +/// evaluates the global track there and gets the curled pose (wow-re `hand-grip-mechanism.md`). Doing the +/// clamp *only* for these few finger bones is what makes the weapon hand close without re-introducing the +/// distant-key bleed the in-band read deliberately guards against. Returns `(bone, quat[x,y,z,w])` for the +/// bones that carry a plain rotation track; empty if the model has no HandsClosed sequence. +pub fn hand_grip_finger_poses(bytes: &[u8], bones: &[u16]) -> Vec<(u16, [f32; 4])> { + let b = bytes; + if b.len() < 0x40 || &b[0..4] != b"MD20" { + return Vec::new(); + } + let (seq_count, seq_ofs) = (le_u32(b, 0x1c) as usize, le_u32(b, 0x20) as usize); + let (bone_count, bone_ofs) = (le_u32(b, 0x34) as usize, le_u32(b, 0x38) as usize); + // HandsClosed (anim id 15) → its frame's absolute start ms. + let mut frame = None; + for s in 0..seq_count { + let rec = seq_ofs + s * 0x44; + if rec + 0x44 > b.len() { + break; + } + if le_u16(b, rec) == 15 { + frame = Some(le_u32(b, rec + 0x04)); + break; + } + } + let Some(frame) = frame else { + return Vec::new(); + }; + let mut out = Vec::new(); + for &bone in bones { + let bi = bone as usize; + if bi >= bone_count { + continue; + } + let track = bone_ofs + bi * 0x6c + 0x28; // the bone's rotation M2Track (+0x28 in the 0x6c record) + if track + 0x1c > b.len() || le_u16(b, track + 0x02) != 0xffff { + continue; // out of range, or a global-sequence track (own clock — not this) + } + let (nts, ots) = ( + le_u32(b, track + 0x0c) as usize, + le_u32(b, track + 0x10) as usize, + ); + let vals_o = le_u32(b, track + 0x18) as usize; + if nts == 0 { + continue; + } + // Clamp: the last key with timestamp <= the frame (keys are time-ascending); else the first. + let mut k = 0usize; + for i in 0..nts { + let t_off = ots + i * 4; + if t_off + 4 > b.len() { + break; + } + if le_u32(b, t_off) <= frame { + k = i; + } else { + break; + } + } + let v = vals_o + k * 16; // rotation stride 16 (a 4×f32 quaternion) + if v + 16 > b.len() { + continue; + } + out.push(( + bone, + [ + le_f32(b, v), + le_f32(b, v + 4), + le_f32(b, v + 8), + le_f32(b, v + 12), + ], + )); + } + out +} + +/// Parse **all** of a model's animation sequences into per-bone keyframes (decision 0019). Offsets +/// VERIFIED against wow-5875-re: sequences @MD20 `0x1c`/`0x20` (stride 0x44; id@+0x00, start@+0x04, +/// end@+0x08, flags@+0x10 with **bit0 SET ⇒ clamp/one-shot, CLEAR ⇒ loop**); bones @`0x34`/`0x38` +/// (stride 0x6c; the three `M2Track`s at +0x0c/+0x28/+0x44). Empty for a model with no sequences; +/// degenerate (zero-length) sequences are skipped. +/// +/// Cost note: each sequence scans every bone track's full timestamp array (the time-band select), so +/// this is `O(sequences · bones · keys)` — fine at async load for the bounded vanilla rigs, a candidate +/// for a one-pass per-sequence key index if a packed-mob load ever shows up hot. +pub fn parse_m2_animations(b: &[u8]) -> Vec { + if b.len() < 0x40 || &b[0..4] != b"MD20" { + return Vec::new(); + } + let (seq_count, seq_ofs) = (le_u32(b, 0x1c) as usize, le_u32(b, 0x20) as usize); + let (bone_count, bone_ofs) = (le_u32(b, 0x34) as usize, le_u32(b, 0x38) as usize); + let vec3 = |b: &[u8], o: usize| [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)]; + let quat = |b: &[u8], o: usize| { + [ + le_f32(b, o), + le_f32(b, o + 4), + le_f32(b, o + 8), + le_f32(b, o + 12), + ] + }; + // The model's event track (events @MD20 0x114/0x118 pre-Wrath, stride 44: identifier 4CC @+0, + // data @+4, bone @+8, position @+12, M2TrackBase @+24 whose timestamp M2Array sits @+0x0c — + // BYTE-VERIFIED on DireWolf.m2: 19 events, forward-stored tags `$CSS`/`$DTH`/`$FL0`…, absolute + // global-timeline ms). Read once here; each sequence below selects + rebases its window, + // exactly like the bone tracks. + let (ev_count, ev_ofs) = (le_u32(b, 0x114) as usize, le_u32(b, 0x118) as usize); + let mut model_events: Vec<([u8; 4], u32, Vec)> = Vec::with_capacity(ev_count); + for e in 0..ev_count { + let erec = ev_ofs + e * 44; + if erec + 44 > b.len() { + break; + } + let ident: [u8; 4] = match b.get(erec..erec + 4).and_then(|s| s.try_into().ok()) { + Some(i) => i, + None => break, + }; + let data = le_u32(b, erec + 4); + let (nts, ots) = (le_u32(b, erec + 36) as usize, le_u32(b, erec + 40) as usize); + let mut times = Vec::with_capacity(nts); + for t in 0..nts { + let o = ots + t * 4; + if o + 4 > b.len() { + break; + } + times.push(le_u32(b, o)); + } + model_events.push((ident, data, times)); + } + + let mut out = Vec::new(); + for s in 0..seq_count { + let rec = seq_ofs + s * 0x44; + if rec + 0x44 > b.len() { + break; + } + let anim_id = le_u16(b, rec); + let (start, end, flags) = ( + le_u32(b, rec + 0x04), + le_u32(b, rec + 0x08), + le_u32(b, rec + 0x10), + ); + // M2Sequence.moveSpeed @+0x0c (f32, yd/s): the locomotion design speed the playback-rate scaler + // divides the unit's live speed by (VERIFIED wow-5875-re `0x711a20`/`0x5fe2f0`). `0.0` for a + // non-locomotion sequence. + let move_speed = le_f32(b, rec + 0x0c); + // M2Sequence.blendTime @+0x20 (u32, ms → seconds): the cross-fade-in duration into this sequence. + let blend_time = le_u32(b, rec + 0x20) as f32 / 1000.0; + // M2Sequence bounds: CAaBox min @+0x24 / max @+0x30, sphere radius @+0x3c — the mouse-pick + // broad-phase sphere for the model's current animation (wow-re pick-volume RE, `0x7089c0`). + let (bmin, bmax) = (vec3(b, rec + 0x24), vec3(b, rec + 0x30)); + let bounds_center = [ + (bmin[0] + bmax[0]) * 0.5, + (bmin[1] + bmax[1]) * 0.5, + (bmin[2] + bmax[2]) * 0.5, + ]; + let bounds_radius = le_f32(b, rec + 0x3c); + // M2Sequence.frequency @+0x14: this variation's weight in the per-play roll. + let frequency = le_u16(b, rec + 0x14); + // M2Sequence minReplay/maxReplay @+0x18/+0x1c: the play-count roll's range. + let (min_replay, max_replay) = (le_u32(b, rec + 0x18), le_u32(b, rec + 0x1c)); + let duration = end.saturating_sub(start) as f32 / 1000.0; + if duration <= 0.0 { + continue; + } + let looping = flags & 1 == 0; // VERIFIED polarity: bit0 clear loops, set clamps + let mut bones = Vec::new(); + for i in 0..bone_count { + let brec = bone_ofs + i * 0x6c; + if brec + 0x60 > b.len() { + break; + } + let translation = read_bone_track(b, brec + 0x0c, start, end, 12, vec3); + let rotation = read_bone_track(b, brec + 0x28, start, end, 16, quat); + let scale = read_bone_track(b, brec + 0x44, start, end, 12, vec3); + if !(translation.is_empty() && rotation.is_empty() && scale.is_empty()) { + bones.push(BoneKeys { + bone: i as u16, + translation, + rotation, + scale, + }); + } + } + // This sequence's event keys: the same time-band select + rebase as the bone tracks. + let mut events = Vec::new(); + for (ident, data, times) in &model_events { + for &ts in times { + if ts >= start && ts <= end { + events.push(AnimEvent { + time: (ts - start) as f32 / 1000.0, + ident: *ident, + data: *data, + }); + } + } + } + events.sort_by(|a, b| a.time.total_cmp(&b.time)); + + out.push(ModelAnimation { + anim_id, + duration, + looping, + move_speed, + blend_time, + bounds_center, + bounds_radius, + bounds_min: bmin, + bounds_max: bmax, + frequency, + min_replay, + max_replay, + bones, + events, + }); + } + out +} + +/// Read one bone track (`{interp, gseq, …, key{n,ofs}, val{n,ofs}}`) as a **global-sequence** channel — +/// `None` unless it is tagged to a global sequence (`gseq != 0xffff`) with a non-zero period and >1 key. +/// The single-key global track is the CONSTANT case (stowed-weapon rest quats) [`read_bone_track`] +/// already folds into every clip; it is not a loop and is excluded here. +fn read_global_channel( + b: &[u8], + track: usize, + stride: usize, + period_of: &impl Fn(u16) -> Option, + read_value: impl Fn(&[u8], usize) -> T, +) -> Option> { + if track + 0x1c > b.len() { + return None; + } + let gseq = le_u16(b, track + 0x02); + if gseq == 0xffff { + return None; + } + let period_ms = period_of(gseq)?; + let n = le_u32(b, track + 0x0c) as usize; + let ts_o = le_u32(b, track + 0x10) as usize; + let val_o = le_u32(b, track + 0x18) as usize; + if n <= 1 { + return None; // a lone key is a constant channel, not a loop + } + let mut keys = Vec::with_capacity(n); + for k in 0..n { + let (t_off, v_off) = (ts_o + k * 4, val_o + k * stride); + if t_off + 4 > b.len() || v_off + stride > b.len() { + break; + } + keys.push((le_u32(b, t_off), read_value(b, v_off))); + } + (keys.len() > 1).then_some(GlobalSeqChannel { period_ms, keys }) +} + +/// Parse every bone's **global-sequence** animation channels — the free-clock loops +/// [`parse_m2_animations`] deliberately drops (it reads only the *playing* sequence's time band). +/// A channel qualifies when its M2Track is tagged to a global sequence (`gseq != 0xffff`) with a +/// non-zero period and more than one key. The canonical consumer is the character eye-blink (an eyelid +/// bone's looping SCALE); resting fidget pulses ride the same mechanism. Global-sequence array at MD20 +/// `0x14/0x18` (VERIFIED), bones at `0x34/0x38` (stride `0x6c`), tracks translation `+0x0c` / rotation +/// `+0x28` / scale `+0x44` (the shared v256 `M2Track`). +pub fn parse_m2_global_sequence_bones(b: &[u8]) -> Vec { + if b.len() < 0x40 || &b[0..4] != b"MD20" { + return Vec::new(); + } + let (gseq_count, gseq_ofs) = (le_u32(b, 0x14) as usize, le_u32(b, 0x18) as usize); + let period_of = |gseq: u16| -> Option { + let i = gseq as usize; + if i >= gseq_count { + return None; + } + let o = gseq_ofs.checked_add(i * 4)?; + if o + 4 > b.len() { + return None; + } + let d = le_u32(b, o); + (d > 0).then_some(d) + }; + let vec3 = |b: &[u8], o: usize| [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)]; + let quat = |b: &[u8], o: usize| { + [ + le_f32(b, o), + le_f32(b, o + 4), + le_f32(b, o + 8), + le_f32(b, o + 12), + ] + }; + let (bone_count, bone_ofs) = (le_u32(b, 0x34) as usize, le_u32(b, 0x38) as usize); + let mut out = Vec::new(); + for i in 0..bone_count { + let brec = bone_ofs + i * 0x6c; + if brec + 0x60 > b.len() { + break; + } + let translation = read_global_channel(b, brec + 0x0c, 12, &period_of, vec3); + let rotation = read_global_channel(b, brec + 0x28, 16, &period_of, quat); + let scale = read_global_channel(b, brec + 0x44, 12, &period_of, vec3); + if translation.is_some() || rotation.is_some() || scale.is_some() { + out.push(GlobalSeqBone { + bone: i as u16, + translation, + rotation, + scale, + }); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The character eye-blink, straight off the real `HumanMale.m2`: exactly one global-sequence bone + /// (75, the eyelid), a **scale** channel on a real global sequence, whose keys hold `0` (lid gone, + /// eye open) at the loop start and pop to `1` (lid full, eye shut) ~33 ms later — the blink. Guards + /// the header offsets (global sequences `0x14/0x18`, bones `0x34/0x38`, scale track `+0x44`) and the + /// gseq-vs-in-band split against a silent regression. Skips when the client data isn't present. + #[test] + fn human_male_eyelid_blink_global_sequence() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Character\\Human\\Male\\HumanMale.m2") + .expect("read HumanMale.m2"); + let gs = parse_m2_global_sequence_bones(&bytes); + + assert_eq!( + gs.len(), + 1, + "HumanMale has one global-seq bone (the eyelid)" + ); + let eyelid = &gs[0]; + assert_eq!(eyelid.bone, 75); + assert!( + eyelid.translation.is_none() && eyelid.rotation.is_none(), + "the eyelid blink is scale-only" + ); + let scale = eyelid.scale.as_ref().expect("eyelid has a scale channel"); + assert!( + scale.period_ms > 1000, + "a real multi-second loop, not the empty 0/1 ms table (got {} ms)", + scale.period_ms + ); + assert!(scale.keys.len() >= 4); + assert_eq!(scale.keys[0].0, 0); + assert!( + scale.keys[0].1.iter().all(|&c| c.abs() < 1e-3), + "loop starts eye-open (scale 0), got {:?}", + scale.keys[0].1 + ); + assert!( + scale + .keys + .iter() + .any(|(_, v)| v.iter().all(|&c| (c - 1.0).abs() < 1e-3)), + "the loop has a shut frame (scale 1) — the blink" + ); + } + + /// An empty band clamps to the nearest authored key instead of dropping the track (the task-#14 + /// tilt): HumanMale bone 27 (waist) rot has 4 keys, all outside Run's band — the real sampler + /// pins it via `interpolation_ranges` (Run's pair is `(2, 3)`, both out-of-band), ours must emit + /// the constant so a Stand-variation twist can't ride through the run. And the guarantee that + /// makes the fix total: on every sequence of the model, any bone keyed *anywhere* stays keyed. + #[test] + fn empty_band_clamps_to_nearest_key_never_drops_the_bone() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Character\\Human\\Male\\HumanMale.m2") + .expect("read HumanMale.m2"); + let seqs = parse_m2_animations(&bytes); + + // Run (anim 5): bone 27's rotation must be present as a lone constant key. + let run = seqs + .iter() + .find(|s| s.anim_id == 5) + .expect("HumanMale has Run"); + let waist = run + .bones + .iter() + .find(|b| b.bone == 27) + .expect("bone 27 carried into the Run clip"); + assert_eq!( + waist.rotation.len(), + 1, + "an empty band emits exactly the one clamp key" + ); + assert_eq!(waist.rotation[0].0, 0.0); + + // Totality: a bone keyed in ANY sequence is keyed in EVERY sequence (per channel family). + let mut keyed = [ + std::collections::HashSet::new(), + std::collections::HashSet::new(), + ]; + for s in &seqs { + for b in &s.bones { + if !b.translation.is_empty() { + keyed[0].insert(b.bone); + } + if !b.rotation.is_empty() { + keyed[1].insert(b.bone); + } + } + } + for (i, name) in ["translation", "rotation"].iter().enumerate() { + for s in &seqs { + for &bone in &keyed[i] { + let present = s.bones.iter().any(|b| { + b.bone == bone + && if i == 0 { + !b.translation.is_empty() + } else { + !b.rotation.is_empty() + } + }); + assert!( + present, + "seq idx anim {} drops bone {bone}'s {name} — a stale-pose freeze", + s.anim_id + ); + } + } + } + } +} diff --git a/crates/benilla-formats/src/models/anim_summary.rs b/crates/benilla-formats/src/models/anim_summary.rs new file mode 100644 index 00000000..31b672fa --- /dev/null +++ b/crates/benilla-formats/src/models/anim_summary.rs @@ -0,0 +1,311 @@ +//! Per-model **animation-channel summary** — how much of an M2 actually animates, across every +//! channel family the renderer might ever drive (bone sequences, global-sequence bones, transparency/ +//! color tracks, texture transforms, particle/ribbon emitters). Split out of [`super::anim`] as its +//! own concern: the rest of that module parses the skeleton + a *playing* sequence's keyframes for the +//! render/skin path; this one is a read-only diagnostic over the *whole* model, built to ground the +//! doodad-animation scope/perf decision (`benilla-extract m2anim`/`doodadscan`) — most placed doodads +//! (trees, rocks, fences, wall props) carry zero animated channels and can render as static meshes +//! forever, and this is the instrument that counts how many actually don't, and by which mechanism. + +use std::io::Cursor; + +use anyhow::{Context, Result}; +use benilla_m2::parse_m2; + +use crate::Chain; + +use super::{le_u32, model_path, parse_m2_animations, parse_m2_global_sequence_bones}; + +/// Raw header read: the model's **texture-transform** (UV-animation) array count — MD20 `0x74` +/// (count) / `0x78` (offset). VERIFIED wow-5875-re (`system/models/models.md`'s full 32-descriptor +/// header field map labels this slot `texReplace@0x74`, and the element-def section separately +/// confirms it IS the textureTransform array: "**textureTransform** (header `0x74`, runtime 0x98) = +/// 3 M2Tracks translation(vec3) / rotation(quat) / scaling(vec3)" — a UV-space TRS animated over +/// time). `benilla-m2` doesn't parse the track contents (no consumer yet), so this reads only the +/// count: whether the model authors any UV animation at all. +pub fn m2_texture_transform_count(b: &[u8]) -> usize { + if b.len() < 0x78 || &b[0..4] != b"MD20" { + return 0; + } + le_u32(b, 0x74) as usize +} + +/// Raw header read: the model's **ribbon emitter** array count — MD20 `0x134` (count) / `0x138` +/// (offset), file stride `0xdc`. VERIFIED wow-5875-re (`system/models/models.md`: "ribbons@0x134" in +/// the full header field map, corroborated by the emitter-def section's "**ribbon** (header `0x134`, +/// file stride `0xdc`) → runtime `CRibbonEmitter`" and the loader's own copy loop, `0x70ebd0` +/// `base+=0xdc @ ribbon 0x138`). Ribbon records aren't parsed here (deferred — wow-re item-12, the +/// most version-variant MD20 record); the count alone says whether a model authors any trail effect +/// (weapon glow trails, banner streamers). +pub fn m2_ribbon_emitter_count(b: &[u8]) -> usize { + if b.len() < 0x138 || &b[0..4] != b"MD20" { + return 0; + } + le_u32(b, 0x134) as usize +} + +/// One particle emitter's **bone linkage**: which bone hosts it, and whether that bone's *chain* +/// (the bone itself or any ancestor — bone motion composes down the hierarchy) actually animates. +/// The join that grounds emitter bone-follow (0130 phase 4): an emitter on a static chain sits at +/// the rest pose forever, so only `chain_seq0 || chain_gseq` emitters can visibly move. +#[derive(Debug, Clone, Copy)] +pub struct EmitterBoneLink { + pub bone: u16, + /// The emitter's raw M2 flag word (`+0x04`) — carried for corpus inspection (which simulation- + /// space / render modes placed content actually authors), semantics live with the consumer. + pub flags: u32, + /// A bone in the chain carries a >1-key T/R/S track in sequence 0's band. + pub chain_seq0: bool, + /// A bone in the chain carries a global-sequence channel. + pub chain_gseq: bool, +} + +impl EmitterBoneLink { + /// The emitter's host bone chain animates at all — its emission point moves off the rest pose. + pub fn chain_animated(&self) -> bool { + self.chain_seq0 || self.chain_gseq + } +} + +/// Per-model animation-channel summary (see the module doc). +#[derive(Debug, Clone)] +pub struct M2AnimSummary { + /// Sequence count (file order — record count in the `animations` M2Array). + pub sequence_count: usize, + /// Sequence **0** (file order — the model's first-authored sequence, NOT necessarily + /// `AnimationData.dbc` id 0/Stand) has at least one bone with a >1-key T/R/S track inside its own + /// time band: the model visibly moves when this sequence plays/loops. + pub seq0_has_bone_motion: bool, + /// How many distinct bones carry a >1-key track (any of T/R/S) in sequence 0's band. + pub seq0_animated_bone_count: usize, + /// How many sequences share sequence 0's `anim_id` — its **variation chain** length (file-order + /// contiguous, retail exporters). The real client's effective load arm rolls `variationIdx = −1` + /// (a frequency-weighted pick over this chain, wow-re `doodad-anim-host.md` §4a), so a count > 1 + /// means instances of this model should NOT all play the same first-sequence variation. + pub seq0_variation_count: usize, + /// Every bone's **global-sequence** channel: `(bone, "T"/"R"/"S", period_ms)` — a free-running + /// loop independent of the playing sequence (see [`super::GlobalSeqBone`]); e.g. the character + /// eye-blink. + pub global_seq_channels: Vec<(u16, &'static str, u32)>, + /// `M2Color` **alpha** tracks: `(total records, records with >1 key)`. A constant (≤1 key) track + /// never changes, so only the second number reflects real animation. + pub color_alpha_tracks: (usize, usize), + /// `M2Color` **RGB** tracks: `(total records, records with >1 key)`. + pub color_rgb_tracks: (usize, usize), + /// `M2TextureWeight` transparency tracks: `(total records, records with >1 key)`. + pub transparency_tracks: (usize, usize), + /// Raw header count of the **texture-transform** (UV animation) array (see + /// [`m2_texture_transform_count`]) — presence-only, the track contents aren't parsed. + pub texture_transform_count: usize, + /// Particle emitter count ([`crate::parse_m2_particle_emitters`]). + pub particle_emitter_count: usize, + /// Each particle emitter's host bone + whether its chain animates (same order as the emitter + /// records; `len() == particle_emitter_count`). See [`EmitterBoneLink`]. + pub emitter_bones: Vec, + /// Raw header count of the **ribbon emitter** array (see [`m2_ribbon_emitter_count`]) — + /// presence-only, the records aren't parsed. + pub ribbon_emitter_count: usize, +} + +impl M2AnimSummary { + /// `true` iff **no** channel family animates anything: no seq-0 bone motion, no global-sequence + /// bones, no animated transparency/color, and no texture transforms/particles/ribbons authored at + /// all. Such a model can render as a permanently-static mesh with zero animation cost. + pub fn is_fully_static(&self) -> bool { + !self.seq0_has_bone_motion + && self.global_seq_channels.is_empty() + && self.transparency_tracks.1 == 0 + && self.color_rgb_tracks.1 == 0 + && self.color_alpha_tracks.1 == 0 + && self.texture_transform_count == 0 + && self.particle_emitter_count == 0 + && self.ribbon_emitter_count == 0 + } +} + +/// Parse an M2's animation-channel summary (see [`M2AnimSummary`]) from raw bytes. +pub fn parse_m2_animation_summary(bytes: &[u8]) -> Result { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + let model = format.model(); + + let seqs = parse_m2_animations(bytes); + let sequence_count = seqs.len(); + let seq0_variation_count = seqs + .first() + .map(|s0| seqs.iter().filter(|s| s.anim_id == s0.anim_id).count()) + .unwrap_or(0); + let seq0_animated_bone_count = seqs + .first() + .map(|seq0| { + seq0.bones + .iter() + .filter(|b| b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1) + .count() + }) + .unwrap_or(0); + + let mut global_seq_channels = Vec::new(); + for b in parse_m2_global_sequence_bones(bytes) { + if let Some(t) = &b.translation { + global_seq_channels.push((b.bone, "T", t.period_ms)); + } + if let Some(r) = &b.rotation { + global_seq_channels.push((b.bone, "R", r.period_ms)); + } + if let Some(s) = &b.scale { + global_seq_channels.push((b.bone, "S", s.period_ms)); + } + } + + // The emitter → bone-chain join (0130 phase 4): which bones move (seq0 band / global + // sequence), then walk each emitter's bone up its parent chain — motion composes down the + // hierarchy, so an emitter on a static child of a swinging parent still moves. + let seq0_bones: std::collections::HashSet = seqs + .first() + .map(|seq0| { + seq0.bones + .iter() + .filter(|b| b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1) + .map(|b| b.bone) + .collect() + }) + .unwrap_or_default(); + let gseq_bones: std::collections::HashSet = + global_seq_channels.iter().map(|&(b, _, _)| b).collect(); + let skeleton = crate::parse_m2_skeleton(bytes)?; + let chain_flags = |bone: u16| -> (bool, bool) { + let (mut seq0, mut gseq) = (false, false); + let mut b = bone; + // Hop guard: a malformed parent loop can't spin us — a real chain is ≤ bone count deep. + for _ in 0..=skeleton.bones.len() { + let Some(rec) = skeleton.bones.get(b as usize) else { + break; // out-of-range host bone (or -1 parent cast) — chain ends + }; + seq0 |= seq0_bones.contains(&b); + gseq |= gseq_bones.contains(&b); + if rec.parent < 0 { + break; + } + b = rec.parent as u16; + } + (seq0, gseq) + }; + let emitter_bones: Vec = crate::parse_m2_particle_emitters(bytes)? + .iter() + .map(|e| { + let (chain_seq0, chain_gseq) = chain_flags(e.bone); + EmitterBoneLink { + bone: e.bone, + flags: e.flags, + chain_seq0, + chain_gseq, + } + }) + .collect(); + + // A track is "animated" iff it is genuinely time-varying — multi-key AND not all-equal (an + // all-equal multi-key track is a constant the renderer folds statically). + let count_animated = |tracks: &[benilla_m2::M2ScalarTrack]| -> (usize, usize) { + ( + tracks.len(), + tracks + .iter() + .filter(|t| t.keys.len() > 1 && t.constant().is_none()) + .count(), + ) + }; + + Ok(M2AnimSummary { + sequence_count, + seq0_has_bone_motion: seq0_animated_bone_count > 0, + seq0_animated_bone_count, + seq0_variation_count, + global_seq_channels, + color_alpha_tracks: count_animated(&model.color_alpha_tracks), + transparency_tracks: count_animated(&model.transparency_tracks), + color_rgb_tracks: ( + model.color_rgb_tracks.len(), + model + .color_rgb_tracks + .iter() + .filter(|t| t.keys.len() > 1) + .count(), + ), + texture_transform_count: m2_texture_transform_count(bytes), + particle_emitter_count: emitter_bones.len(), + emitter_bones, + ribbon_emitter_count: m2_ribbon_emitter_count(bytes), + }) +} + +/// Read an M2's animation-channel summary from the chain (see [`M2AnimSummary`]). Uses the same path +/// normalisation as [`load_m2_mesh`](super::load_m2_mesh) so `.mdx`/`.mdl` doodad paths (MDDF/MODD) +/// resolve identically. +pub fn load_m2_animation_summary(chain: &mut Chain, raw_path: &str) -> Result { + let path = model_path(raw_path); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading M2 {path}"))?; + parse_m2_animation_summary(&bytes) + .with_context(|| format!("parsing M2 animation summary {path}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal MD20 header (0x138 bytes — through the ribbon array descriptor) with the + /// texture-transform count at `0x74` and the ribbon count at `0x134` — the two raw header reads + /// [`m2_texture_transform_count`]/[`m2_ribbon_emitter_count`] guard against an offset regression. + fn header_with_counts(tex_transform_count: u32, ribbon_count: u32) -> Vec { + let mut b = vec![0u8; 0x138]; + b[0..4].copy_from_slice(b"MD20"); + b[4..8].copy_from_slice(&256u32.to_le_bytes()); + b[0x74..0x78].copy_from_slice(&tex_transform_count.to_le_bytes()); + b[0x134..0x138].copy_from_slice(&ribbon_count.to_le_bytes()); + b + } + + #[test] + fn texture_transform_and_ribbon_counts_read_the_verified_header_offsets() { + let b = header_with_counts(3, 7); + assert_eq!(m2_texture_transform_count(&b), 3); + assert_eq!(m2_ribbon_emitter_count(&b), 7); + } + + #[test] + fn texture_transform_and_ribbon_counts_are_zero_on_a_too_short_or_non_md20_buffer() { + assert_eq!(m2_texture_transform_count(&[]), 0); + assert_eq!(m2_ribbon_emitter_count(&[]), 0); + let short = header_with_counts(3, 7); + assert_eq!(m2_texture_transform_count(&short[..0x50]), 0); + assert_eq!(m2_ribbon_emitter_count(&short[..0x50]), 0); + let mut not_md20 = header_with_counts(3, 7); + not_md20[0..4].copy_from_slice(b"XXXX"); + assert_eq!(m2_texture_transform_count(¬_md20), 0); + assert_eq!(m2_ribbon_emitter_count(¬_md20), 0); + } + + /// A hermetic (no real client data required) end-to-end check of [`parse_m2_animation_summary`]'s + /// static/dynamic classification: an all-zero-array header (no sequences, no bones, no emitters, no + /// transforms) must report fully static with every count at zero. + #[test] + fn empty_model_summary_is_fully_static() { + let b = header_with_counts(0, 0); + let summary = parse_m2_animation_summary(&b).expect("an all-zero-array header parses"); + assert_eq!(summary.sequence_count, 0); + assert!(!summary.seq0_has_bone_motion); + assert_eq!(summary.seq0_animated_bone_count, 0); + assert_eq!(summary.seq0_variation_count, 0); + assert!(summary.global_seq_channels.is_empty()); + assert_eq!(summary.transparency_tracks, (0, 0)); + assert_eq!(summary.color_rgb_tracks, (0, 0)); + assert_eq!(summary.color_alpha_tracks, (0, 0)); + assert_eq!(summary.texture_transform_count, 0); + assert_eq!(summary.particle_emitter_count, 0); + assert!(summary.emitter_bones.is_empty()); + assert_eq!(summary.ribbon_emitter_count, 0); + assert!(summary.is_fully_static()); + } +} diff --git a/crates/benilla-formats/src/models/bounds.rs b/crates/benilla-formats/src/models/bounds.rs new file mode 100644 index 00000000..199cad27 --- /dev/null +++ b/crates/benilla-formats/src/models/bounds.rs @@ -0,0 +1,119 @@ +//! An M2's authored bounds ([`M2Bounds`]) — the header sphere/box, the vertex-derived radius our +//! renderer computes, and the selection-ring **footprint** read from the model's Stand-animation +//! bounding box (the exact model-local input the real client's living-unit selection ring uses). +//! No render-batch concern here — just the bounds/measurement reads [`load_m2_bounds`]/ +//! [`parse_m2_bounds`] expose to placement + the selection ring. + +use std::io::Cursor; + +use anyhow::{Context, Result}; +use benilla_bytes::ByteExt; +use benilla_m2::parse_m2; + +use crate::Chain; + +use super::model_path; + +/// An M2's authored bounds (read straight from the model header) alongside the vertex-derived radius +/// our renderer currently computes — so the distance-fade size bucket can be checked against the value +/// the reference's placement builder actually uses (`FUN_00694bc0` → `rec+0x68`). `sphere_radius` and +/// `bbox_*` are in model-local yards (multiply by the placement scale for the world radius). +#[derive(Clone, Copy, Debug)] +pub struct M2Bounds { + /// `M2Header.bounding_sphere_radius` — the authored bounding-sphere radius (the reference's source). + pub sphere_radius: f32, + /// `M2Header.bounding_box_min/max` — the authored AABB; its centre is the sphere centre. + pub bbox_min: [f32; 3], + pub bbox_max: [f32; 3], + /// Max vertex distance from the model **origin** — what `build_model` currently uses as the radius. + pub vert_max_from_origin: f32, + /// The selection-ring **footprint** (model-local, pre-scale): `sqrt(0.5 · sqrt(dx² + dy²))`, where + /// `dx,dy` are the horizontal (X,Y) extents of the unit's **Stand** animation bounding box (the M2 + /// sequence CAaBox). This is the exact model-local input the real client's living-unit selection ring + /// uses — byte-verified + Unicorn-emulated to the reference pixels (wow-re selection-ring RE, + /// `0x608e00`/`0x60aee0`): the ring's world radius = this × `OBJECT_FIELD_SCALE_X`. The nested sqrt is + /// a range-compressor (which is why the render-sphere never fit). Falls back to the header render-box + /// XY extents for a model with no animation sequences. + pub ring_footprint: f32, + /// Model-space **Z** of attachment id 17 — the reference's follow-camera pivot height (wow-re + /// `follow-camera`: `feet + (attach17.z + 0.0972)·scale`). `None` for a model with no slot-17 + /// attachment; the camera then falls back to a fraction of the box. See [`M2Bounds::pivot_z`]. + pub pivot_z: Option, +} + +/// Read an M2's authored bounds + the vertex-derived radius (see [`M2Bounds`]). Uses the same path +/// normalisation as [`super::load_m2_mesh`] so `.mdx`/`.mdl` doodad paths resolve identically. +pub fn load_m2_bounds(chain: &mut Chain, raw_path: &str) -> Result { + let path = model_path(raw_path); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading M2 {path}"))?; + parse_m2_bounds(&bytes).with_context(|| format!("parsing M2 bounds {path}")) +} + +/// Read the horizontal (X,Y) extents of the **Stand** animation's bounding box from a raw M2 — the input +/// the real client's living-unit selection ring is sized from (wow-re selection-ring RE, `0x60aee0`). +/// The animation `M2Sequence` array is at MD20 `0x1c`(count)/`0x20`(offset), stride `0x44`, with the +/// sequence's `CAaBox` at record `+0x24` (min C3 `+0x24`, max C3 `+0x30`). Stand is animation **id 0**, +/// whose *sequence index* is `animationLookup[0]` (array at `0x24`/`0x28`, `u16` each) — NOT necessarily +/// record 0 (a chicken's record 0 is a flap, its Stand sits at index 2). Returns `(dx, dy)`, or `None` +/// when the model has no sequences / lookup or the indices are out of range (caller falls back to the +/// header box). VERIFIED: reproduces the reference ring radii to ~1 mm. +fn stand_box_xy(bytes: &[u8]) -> Option<(f32, f32)> { + let anim_count = bytes.u32_at(0x1c)? as usize; + let anim_ofs = bytes.u32_at(0x20)? as usize; + let lookup_count = bytes.u32_at(0x24)? as usize; + let lookup_ofs = bytes.u32_at(0x28)? as usize; + if anim_count == 0 { + return None; + } + // Stand (anim id 0) → its sequence index via animationLookup[0]; fall back to record 0 if the lookup + // is absent, out of bounds, or points nowhere (0xffff / out of range). + let idx = if lookup_count > 0 { + match bytes.u16_at(lookup_ofs) { + Some(i) if (i as usize) != 0xffff && (i as usize) < anim_count => i as usize, + _ => 0, + } + } else { + 0 + }; + let rec = anim_ofs.checked_add(idx * 0x44)?; + // CAaBox @ rec+0x24: min C3 (+0x24), max C3 (+0x30). Horizontal (X,Y) extents; squared in the ring + // formula so an unsorted box is harmless. + let dx = bytes.f32_at(rec + 0x30)? - bytes.f32_at(rec + 0x24)?; + let dy = bytes.f32_at(rec + 0x34)? - bytes.f32_at(rec + 0x28)?; + Some((dx, dy)) +} + +/// Read an **in-memory** M2's authored bounds + vertex-derived radius (see [`M2Bounds`]). The bytes-in +/// entry point for the Bevy `AssetLoader`; [`load_m2_bounds`] is the chain-reading wrapper. +pub fn parse_m2_bounds(bytes: &[u8]) -> Result { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + let model = format.model(); + let h = &model.header; + let vert_max_from_origin = model + .vertices + .iter() + .map(|v| { + let p = v.position; + (p.x * p.x + p.y * p.y + p.z * p.z).sqrt() + }) + .fold(0.0_f32, f32::max); + // Selection-ring footprint: `sqrt(0.5 · sqrt(dx² + dy²))` of the Stand animation box's horizontal + // extents (the real client's living-unit ring input), falling back to the header render-box XY for a + // model with no animation sequences (the client's static-model path). + let (rx, ry) = stand_box_xy(bytes).unwrap_or(( + h.bounding_box_max[0] - h.bounding_box_min[0], + h.bounding_box_max[1] - h.bounding_box_min[1], + )); + let ring_footprint = (0.5 * (rx * rx + ry * ry).sqrt()).sqrt(); + Ok(M2Bounds { + sphere_radius: h.bounding_sphere_radius, + bbox_min: h.bounding_box_min, + bbox_max: h.bounding_box_max, + vert_max_from_origin, + ring_footprint, + pivot_z: model.pivot_attach_z, + }) +} diff --git a/crates/benilla-formats/src/models/collision.rs b/crates/benilla-formats/src/models/collision.rs new file mode 100644 index 00000000..b715c2a4 --- /dev/null +++ b/crates/benilla-formats/src/models/collision.rs @@ -0,0 +1,193 @@ +//! Collision-hull parsing: the coarse M2 bounding hull and the WMO group collision/camera-collision +//! triangle gathers (the avian colliders the streamer bakes). Split out of the model parser. + +use std::collections::HashMap; +use std::io::Cursor; + +use anyhow::{Context, Result}; +use benilla_m2::parse_m2; +use benilla_wmo::{parse_wmo, ParsedWmo}; + +use crate::Chain; + +use super::model_path; + +/// Collision geometry in **raw model-local coords** — the same space as [`super::RenderSubmesh::positions`] +/// (Z-up ≈ WoW axes). The benilla bake applies the *identical* placement transform the renderer +/// uses, so the collider coincides with the visible model. `indices` is a triangle list into +/// `positions`. Both vecs empty ⇒ the source carries no collision geometry (caller skips it). +#[derive(Debug, Clone, Default)] +pub struct CollisionMesh { + pub positions: Vec<[f32; 3]>, + pub indices: Vec, +} + +impl CollisionMesh { + pub fn is_empty(&self) -> bool { + self.indices.is_empty() + } + /// Triangle count (`indices.len() / 3`). + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } +} + +/// Decode an M2 doodad's **collision hull** — the coarse solid shape (trunk/walls) the client collides +/// against, separate from and much smaller than the render mesh (verified: hull span ≪ render bbox, so +/// you're blocked by the trunk but walk through the canopy). Source = the M2 header +/// `bounding_{vertices,triangles}` arrays (raw bytes in `raw_data`): vertices are `C3Vector` (12 B), +/// triangles a `u16` index list — one collision normal per face, which we don't need (parry derives +/// face normals). The Legion `PCOL` `collision_mesh_data` is always absent in 1.12. A doodad with no +/// hull (`nBoundingTriangles == 0` — many small props) returns an empty mesh: **collide-iff-hull** is +/// the sole gate (verified). Validated 2026-06-02. +pub fn load_m2_collision_hull(chain: &mut Chain, raw_path: &str) -> Result { + let path = model_path(raw_path); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading M2 {path}"))?; + parse_m2_collision_hull(&bytes).with_context(|| format!("parsing M2 {path}")) +} + +/// Parse an M2 doodad's collision hull straight from its file bytes (the byte-fed twin of +/// [`load_m2_collision_hull`], for the asset-loader path that already holds the bytes). Empty mesh ⇒ +/// no hull (`nBoundingTriangles == 0`). See [`load_m2_collision_hull`] for the format details. +pub fn parse_m2_collision_hull(bytes: &[u8]) -> Result { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2 hull: {e}"))?; + let rd = &format.model().raw_data; + let positions: Vec<[f32; 3]> = rd + .bounding_vertices + .chunks_exact(12) + .map(|c| { + [ + f32::from_le_bytes([c[0], c[1], c[2], c[3]]), + f32::from_le_bytes([c[4], c[5], c[6], c[7]]), + f32::from_le_bytes([c[8], c[9], c[10], c[11]]), + ] + }) + .collect(); + let n = positions.len() as u32; + let indices: Vec = rd + .bounding_triangles + .chunks_exact(2) + .map(|c| u32::from(u16::from_le_bytes([c[0], c[1]]))) + .collect(); + // Guard (verified true on every real 1.12 hull): whole triangles, all indices in range. Bail to + // "no hull" rather than emit broken geometry if a future/odd file violates it. + if indices.is_empty() || !indices.len().is_multiple_of(3) || indices.iter().any(|&i| i >= n) { + return Ok(CollisionMesh::default()); + } + Ok(CollisionMesh { positions, indices }) +} + +/// MOPY per-face flags (1.12) and the two collision gather masks — both **VERIFIED** from `WoW.exe` 5875 +/// (objdump; see wow-5875-re `system/collision/collision.md` → "The WMO per-face MOPY collision mask"). +/// The skip predicate is a pure function of a 32-bit collision *class* word built per query; the two +/// classes benilla reproduces, one collider mesh each: +/// - **WALKING / movement** (the player body): skip a face **iff `flags & 0x04`** (DETAIL/decal). Reject +/// mask `0x84` from class word `0x10_0111` (collision `0x6315f0`); WMO BSP leaf `0x6bc700`. +/// - **CAMERA / LOS** (third-person occlusion): skip a face **iff `flags & 0x02`** (NOCAMCOLLIDE). It does +/// NOT exclude DETAIL, so the camera collides with visible decals/overhangs the player walks *under* +/// (e.g. forge pipes). Reject mask `0x82` from class word `0x10_0171` (ui `0x50e570`). +/// +/// So the difference is exactly: walking drops DETAIL; the camera instead drops NOCAMCOLLIDE. (The older +/// `FUN_006bca50` citation was a superseded prototype lead, corrected +/// when wow-re took ownership of the WMO leaf.) +const MOPY_DETAIL: u8 = 0x04; +/// The bit the **camera/LOS** gather excludes — NOCAMCOLLIDE, the faces the third-person camera passes +/// through. See [`MOPY_DETAIL`] for the full walking-vs-camera mask derivation. +const MOPY_NOCAMCOLLIDE: u8 = 0x02; + +/// Decode a WMO's **collidable triangles** — the faces the client collides against — flattened across +/// every group file into one mesh in **raw WMO-local coords** (same space as [`load_wmo`]'s render +/// submeshes; the benilla bake reuses the identical MODF placement transform). Selection = the +/// binary-VERIFIED client filter: a face collides **iff `(flags & 0x04 DETAIL) == 0`** (exclude +/// render-only decals only) — the walking-gather mask 0x84 read from `WoW.exe` 5875 (`FUN_006bca50`, +/// 3 agents/objdump). This is *more* inclusive than the old +/// server-vmap-extractor rule `COLLISION || (RENDER && !DETAIL)`, which wrongly dropped the +/// flags-0x00 / 0x40-only faces the real client collides. *(Whole-group skips — mogpFlags +/// unreachable/antiportal — are a later refinement; including all rendered groups is safe for ground-snap.)* +pub fn load_wmo_collision_tris(chain: &mut Chain, raw_path: &str) -> Result { + let root_path = raw_path.to_ascii_lowercase(); + let bytes = chain + .read_file(&root_path) + .with_context(|| format!("reading WMO {root_path}"))?; + let ParsedWmo::Root(root) = parse_wmo(&mut Cursor::new(&bytes)) + .map_err(|e| anyhow::anyhow!("parsing {root_path}: {e}"))? + else { + anyhow::bail!("{root_path} is not a WMO root file"); + }; + let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); + let (mut positions, mut indices): (Vec<[f32; 3]>, Vec) = (Vec::new(), Vec::new()); + for gi in 0..root.n_groups { + let group_path = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&group_path) else { + continue; + }; + accumulate_wmo_group_collision(&gbytes, &mut positions, &mut indices); + } + Ok(CollisionMesh { positions, indices }) +} + +/// Accumulate one WMO **group** file's *walking*-collidable triangles (raw WMO-local coords) into +/// `positions`/`indices` — the player-body gather: a face collides **unless** it's a DETAIL/decal face +/// (`flags & 0x04`). The byte-fed unit shared by [`load_wmo_collision_tris`] (chain) and the asset +/// loader (which already holds each group's bytes). See [`accumulate_wmo_group_camera_collision`] for +/// the camera/LOS gather, which keeps DETAIL but drops NOCAMCOLLIDE instead. +pub fn accumulate_wmo_group_collision( + group_bytes: &[u8], + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + accumulate_wmo_group_faces(group_bytes, MOPY_DETAIL, positions, indices); +} + +/// Accumulate one WMO **group** file's *camera/LOS*-collidable triangles (raw WMO-local coords): the +/// third-person-camera gather — a face collides **unless** it's NOCAMCOLLIDE (`flags & 0x02`). Unlike +/// the walking gather it keeps DETAIL faces (visible decals/overhangs like forge pipes), so the camera +/// can't slip behind them. See [`accumulate_wmo_group_collision`] and [`MOPY_DETAIL`]. +pub fn accumulate_wmo_group_camera_collision( + group_bytes: &[u8], + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + accumulate_wmo_group_faces(group_bytes, MOPY_NOCAMCOLLIDE, positions, indices); +} + +/// Shared WMO group-face gather: accumulate every triangle whose MOPY flags don't intersect `skip_mask`, +/// remapping verts to a compact subset *per group* (equal local indices in different groups are distinct +/// verts). A group that doesn't parse is skipped. The two collision audiences differ only in `skip_mask` +/// — `0x04` DETAIL for walking, `0x02` NOCAMCOLLIDE for the camera (see [`MOPY_DETAIL`]). +fn accumulate_wmo_group_faces( + group_bytes: &[u8], + skip_mask: u8, + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return; + }; + let n_tri = group.vertex_indices.len() / 3; + let mut map: HashMap = HashMap::new(); + for t in 0..n_tri { + let Some(mopy) = group.material_info.get(t) else { + continue; + }; + if mopy.flags & skip_mask != 0 { + continue; + } + for k in 0..3 { + let Some(&vidx) = group.vertex_indices.get(t * 3 + k) else { + continue; + }; + let Some(p) = group.vertex_positions.get(vidx as usize) else { + continue; + }; + let global = *map.entry(vidx).or_insert_with(|| { + positions.push([p.x, p.y, p.z]); + (positions.len() - 1) as u32 + }); + indices.push(global); + } + } +} diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs new file mode 100644 index 00000000..5fa2fe30 --- /dev/null +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -0,0 +1,167 @@ +//! The generic keyed-loop bake shared by the material-alpha (`mat_anim`) and texture-transform +//! (`tex_anim`) channels: ONE kernel-faithful sampler (k0 = last key ≤ t, step holds, linear +//! lerps, clamp past the last key — no wrap-lerp) and ONE clock resolution (gseq wrap vs seq-0 +//! band rebase), so the byte-verified sampling semantics (wow-re `eval.md`, and the step-boundary +//! fix) live in exactly one place. + +use benilla_m2::M2Track; + +/// A value a keyed loop can linearly interpolate. +pub trait Lerp: Copy { + fn lerp(a: Self, b: Self, f: f32) -> Self; +} +impl Lerp for f32 { + fn lerp(a: Self, b: Self, f: f32) -> Self { + a + (b - a) * f + } +} +impl Lerp for [f32; 2] { + fn lerp(a: Self, b: Self, f: f32) -> Self { + [f32::lerp(a[0], b[0], f), f32::lerp(a[1], b[1], f)] + } +} +impl Lerp for [f32; 3] { + fn lerp(a: Self, b: Self, f: f32) -> Self { + [ + f32::lerp(a[0], b[0], f), + f32::lerp(a[1], b[1], f), + f32::lerp(a[2], b[2], f), + ] + } +} + +/// One baked keyed loop, seconds: sample at `t mod period` (linear or step per [`Self::step`]), +/// holding the first/last key outside the keyed span. `period == 0` ⇒ a constant (single key). +#[derive(Clone, Debug, PartialEq)] +pub struct KeyAnim { + /// Loop period (secs): the global sequence's duration, or the first sequence's band length. + /// `0.0` for a constant. + pub period: f32, + /// Step interpolation (`interp == 0`): hold each key until the next; else linear. + pub step: bool, + /// `(secs from loop start, value)`, time-ascending. + pub keys: Vec<(f32, V)>, +} + +impl KeyAnim { + /// Sample at `elapsed` seconds on the loop clock; `empty` is the channel's identity for the + /// keyless case (the bake never emits that — each channel's `sample` supplies it). + pub(super) fn sample_or(&self, elapsed: f32, empty: V) -> V { + let Some(&(t0, v0)) = self.keys.first() else { + return empty; + }; + if self.period <= 0.0 || self.keys.len() == 1 { + return v0; + } + let t = elapsed.rem_euclid(self.period); + if t <= t0 { + return v0; + } + // k0 = the last key with time ≤ t (the kernel's search), mirroring `BoneScaleAnim::sample`. + let mut k0 = 0; + for (i, &(tk, _)) in self.keys.iter().enumerate() { + if tk <= t { + k0 = i; + } else { + break; + } + } + let (ta, va) = self.keys[k0]; + // Step, or past the final key: hold (the kernel's search clamps; no wrap-lerp to key 0). + if self.step || k0 + 1 >= self.keys.len() { + return va; + } + let (tb, vb) = self.keys[k0 + 1]; + if tb <= ta { + return va; + } + V::lerp(va, vb, (t - ta) / (tb - ta)) + } +} + +/// Bake one typed track to a [`KeyAnim`], or `None` when it contributes nothing the static path +/// doesn't already handle. `proj` maps the on-disk key value into the baked domain (identity for +/// scalars; vec3 → the UV xy). The two predicates name the channel's semantics: +/// +/// - `drop_constant(c)`: an **all-keys-equal** track with value `c` vanishes — because `c` is the +/// channel identity, or because the caller's *static* path already folded it away (the alpha +/// combine's constant-0 cull). +/// - `is_identity(v)`: `v` contributes nothing at runtime — used for the band-empty held key and a +/// lone in-band key, where the static path has NOT looked (a held non-identity value still bakes, +/// including a held alpha 0, which must keep hiding the batch every frame). +/// +/// `gseq_durations` is the model's global-sequence table (ms); `seq0` the file-order-first +/// sequence's absolute `(start_ms, end_ms)` band — the loop a placed doodad plays. +pub(super) fn bake_track( + track: &M2Track, + gseq_durations: &[u32], + seq0: Option<(u32, u32)>, + proj: impl Fn(T) -> V, + drop_constant: impl Fn(V) -> bool, + is_identity: impl Fn(V) -> bool, +) -> Option> { + if track.keys.is_empty() { + return None; + } + let keys: Vec<(u32, V)> = track.keys.iter().map(|&(t, v)| (t, proj(v))).collect(); + let step = track.interp == 0; + // An all-equal track is a constant: dropped when the static path owns it, else a period-0 key. + let (_, first) = keys[0]; + if keys.iter().all(|&(_, v)| v == first) { + if drop_constant(first) { + return None; + } + return Some(KeyAnim { + period: 0.0, + step, + keys: vec![(0.0, first)], + }); + } + if track.gseq != 0xffff { + // Global-sequence clock: keys are already loop-relative ms; wrap on the table duration + // (a 0/absent duration degrades to the last key's time — a defensive clamp, not observed). + let period_ms = gseq_durations + .get(track.gseq as usize) + .copied() + .filter(|&d| d > 0) + .unwrap_or_else(|| keys.last().map(|&(t, _)| t).unwrap_or(0).max(1)); + return Some(KeyAnim { + period: period_ms as f32 / 1000.0, + step, + keys: keys.iter().map(|&(t, v)| (t as f32 / 1000.0, v)).collect(), + }); + } + // Sequence-timeline track: keep the keys inside the first sequence's absolute band, rebased — + // the loop a placed doodad's one-time arm plays (wow-re `doodad-anim-host.md`). No keys in the + // band ⇒ the channel holds the nearest earlier key while that sequence plays; bake it constant + // unless that held value is the channel identity (an absent earlier key contributes nothing). + let (start, end) = seq0?; + let in_band: Vec<(f32, V)> = keys + .iter() + .filter(|&&(t, _)| t >= start && t <= end) + .map(|&(t, v)| ((t - start) as f32 / 1000.0, v)) + .collect(); + if in_band.is_empty() { + let held = keys + .iter() + .take_while(|&&(t, _)| t < start) + .last() + .map(|&(_, v)| v)?; + if is_identity(held) { + return None; + } + return Some(KeyAnim { + period: 0.0, + step, + keys: vec![(0.0, held)], + }); + } + if in_band.len() == 1 && is_identity(in_band[0].1) { + return None; + } + Some(KeyAnim { + period: ((end - start) as f32 / 1000.0).max(0.001), + step, + keys: in_band, + }) +} diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs new file mode 100644 index 00000000..c6e0a943 --- /dev/null +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -0,0 +1,709 @@ +//! The M2 render-batch assembly loop: [`parse_m2_render_submeshes`] walks a parsed M2's skin +//! batches building one [`RenderSubmesh`] each — resolving its texture/blend/render-flags, baking the +//! batch's `alpha_anim` (decision 0130 phase 2, via [`mat_anim`](super::mat_anim)) and `uv_anim` +//! (phase 3, via [`tex_anim`](super::tex_anim)), and splitting a batch across billboard bones so each +//! glow card rotates about its own pivot. [`load_m2_mesh`]/[`load_m2_mesh_skinned`] are the +//! chain-reading entry points that feed it. + +use std::io::Cursor; + +use anyhow::{Context, Result}; +use benilla_bytes::ByteExt; +use benilla_m2::parse_m2; +use benilla_m2::M2TextureType; + +use crate::Chain; + +use super::mat_anim; +use super::tex_anim; +use super::{le_u16, le_u32, model_path, remap_submesh}; +use super::{ + AlphaAnim, Billboard, BillboardKind, BoneScaleAnim, CharSkinSlot, FogPolicy, ModelBlend, + RenderSubmesh, +}; + +/// Directory portion of an internal path (everything before the last separator), `""` if none. +fn parent_dir(path: &str) -> &str { + match path.rfind(['\\', '/']) { + Some(i) => &path[..i], + None => "", + } +} + +/// Resolve a batch's texture to a `.blp` path. `Hardcoded` textures carry their own filename; +/// `Monster1/2/3` are blank in the M2 and come from the creature's skin variations (resolved to +/// `\.blp`), falling back to any embedded filename. +fn resolve_texture( + tex: &benilla_m2::M2Texture, + dir: &str, + skins: &[Option], +) -> (Option, Option, Option) { + let embedded = { + let f = tex.filename.string.to_string_lossy(); + (!f.is_empty()).then(|| f.into_owned()) + }; + let variation = |i: usize| { + skins + .get(i) + .and_then(|o| o.clone()) + .map(|name| format!("{dir}\\{name}.blp")) + }; + // Character runtime texture slots — no embedded path; the client fills them per-player from the + // appearance (decisions 0041 / 0044 / 0045) or, for `Object`, from the runtime-bound item/cape + // skin (decision 0072). M2 texture type 1 = the composited body atlas; type 2 = the object skin; + // type 6 = the hair-mesh texture; type 8 = the extra skin (the tauren fur BLP). Flagged so the + // spawn site can swap in the right material. + let char_slot = match tex.texture_type { + M2TextureType::Other(1) => Some(CharSkinSlot::Body), + M2TextureType::Other(2) => Some(CharSkinSlot::Object), + M2TextureType::Other(6) => Some(CharSkinSlot::Hair), + M2TextureType::Other(8) => Some(CharSkinSlot::SkinExtra), + _ => None, + }; + // Skin slots resolve to the variation if supplied, else the (usually empty) embedded name; the slot + // is reported so a skin-less load can fill it at spawn. + match tex.texture_type { + M2TextureType::Monster1 => (variation(0).or(embedded), Some(0), None), + M2TextureType::Monster2 => (variation(1).or(embedded), Some(1), None), + M2TextureType::Monster3 => (variation(2).or(embedded), Some(2), None), + _ => (embedded, None, char_slot), + } +} + +/// A fix16 visibility track's **static** value: `None` if time-varying (can't decide visibility at +/// build time), else `Some(constant)` — an empty track (0 keys) means the factor doesn't apply, so +/// it reads as `1.0`. Used to fold a batch's colour-alpha / transparency-weight into the cull test. +fn track_constant(track: &benilla_m2::M2ScalarTrack) -> Option { + if track.keys.is_empty() { + return Some(1.0); + } + track.constant() +} + +/// Normalise a vertex's 4 raw M2 bone weights (`u8`, summing ~255) to `f32` summing to 1.0 — what the +/// GPU skin blend expects. A vertex with no weight (sum 0, e.g. a stray static vertex) binds fully to +/// bone 0 so it rides the root rather than collapsing to the origin. +fn normalize_weights(w: [u8; 4]) -> [f32; 4] { + let sum = w.iter().map(|&x| f32::from(x)).sum::(); + if sum <= 0.0 { + return [1.0, 0.0, 0.0, 0.0]; + } + [ + f32::from(w[0]) / sum, + f32::from(w[1]) / sum, + f32::from(w[2]) / sum, + f32::from(w[3]) / sum, + ] +} + +/// Read bone `bone_idx`'s **scale** track (M2Track at `bone + 0x44`) as a global-sequence +/// [`BoneScaleAnim`], straight from the raw M2 bytes. Returns `None` unless the track has >1 keys AND is +/// tagged to a global sequence (`gseq != 0xffff`) — the always-looping pulse case (see [`BoneScaleAnim`]). +/// +/// MD20/bone offsets VERIFIED against wow-5875-re (`fields.md`: bones `count@0x34/ofs@0x38`, stride +/// `0x6c`, scale track `+0x44`; global sequences `count@0x14/ofs@0x18`) and the real `Lamppost.m2`. The +/// 28-byte vanilla M2Track tail: `interp@0`, `gseq@0x2`, `key{count@0xc, ofs@0x10}`, `val{count@0x14, +/// ofs@0x18}` (C3Vector values, stride 12) — the same track shape the particle emission tracks use. +fn parse_bone_scale_anim(bytes: &[u8], bone_idx: usize) -> Option { + let bone_count = bytes.u32_at(0x34)? as usize; + let bones_ofs = bytes.u32_at(0x38)? as usize; + if bone_idx >= bone_count { + return None; + } + let track = bones_ofs.checked_add(bone_idx * 0x6c)?.checked_add(0x44)?; + let interp = bytes.u16_at(track)? != 0; + let gseq = bytes.u16_at(track + 0x02)?; + if gseq == 0xffff { + return None; // armed-animation track, not a global-sequence loop — out of scope here + } + let nkeys = bytes.u32_at(track + 0x0c)? as usize; + let ts_ofs = bytes.u32_at(track + 0x10)? as usize; + let nval = bytes.u32_at(track + 0x14)? as usize; + let val_ofs = bytes.u32_at(track + 0x18)? as usize; + if nkeys <= 1 || nval < nkeys { + return None; // static (≤1 key) — nothing to animate + } + // Resolve the global-sequence duration this track loops over. + let gseq_count = bytes.u32_at(0x14)? as usize; + let gseq_o = bytes.u32_at(0x18)? as usize; + if gseq as usize >= gseq_count { + return None; + } + let duration_ms = bytes.u32_at(gseq_o.checked_add(gseq as usize * 4)?)?; + if duration_ms == 0 { + return None; + } + let keys = (0..nkeys) + .map(|k| { + let t = bytes.u32_at(ts_ofs.checked_add(k * 4)?)?; + let v = val_ofs.checked_add(k * 12)?; + Some(( + t, + [bytes.f32_at(v)?, bytes.f32_at(v + 4)?, bytes.f32_at(v + 8)?], + )) + }) + .collect::>>()?; + Some(BoneScaleAnim { + duration_ms, + interp, + keys, + }) +} + +/// Read bone `bone_idx`'s **translation** track (M2Track at `bone + 0x0c`) restricted to +/// one sequence's absolute time `band`, rebased to it as a loop: the window an arm plays on the +/// bone — anim 0 is the client's one-time load arm (the questgiver `?` marker's bob), anim 190 the +/// marker's raised variant (wow-re `questgiver-marker.md` Q4 / `doodad-anim-host.md`). Returns +/// `None` unless the track is a **sequence** track (`gseq == 0xffff` — the gseq case is the +/// armed-free loop `parse_bone_scale_anim` handles for scale) holding >1 key inside the band. +/// Offsets as [`parse_bone_scale_anim`]; the translation track sits at bone `+0x0c` (VERIFIED +/// wow-5875-re `fields.md`: the three `M2Track`s at `+0x0c/+0x28/+0x44`). +fn parse_bone_seq_translation( + bytes: &[u8], + bone_idx: usize, + band: (u32, u32), +) -> Option { + let (start, end) = band; + let duration_ms = end.checked_sub(start).filter(|d| *d > 0)?; + let bone_count = bytes.u32_at(0x34)? as usize; + let bones_ofs = bytes.u32_at(0x38)? as usize; + if bone_idx >= bone_count { + return None; + } + let track = bones_ofs.checked_add(bone_idx * 0x6c)?.checked_add(0x0c)?; + let interp = bytes.u16_at(track)? != 0; + if bytes.u16_at(track + 0x02)? != 0xffff { + return None; // a global-sequence loop — not the armed-sequence case + } + let nkeys = bytes.u32_at(track + 0x0c)? as usize; + let ts_ofs = bytes.u32_at(track + 0x10)? as usize; + let nval = bytes.u32_at(track + 0x14)? as usize; + let val_ofs = bytes.u32_at(track + 0x18)? as usize; + if nval < nkeys { + return None; + } + // The flat timestamp array concatenates every sequence's keys on one absolute timeline + // (`read_bone_track`'s law) — keep only this band's, rebased to its start. + let mut keys = Vec::new(); + for k in 0..nkeys { + let t = bytes.u32_at(ts_ofs.checked_add(k * 4)?)?; + if t < start || t > end { + continue; + } + let v = val_ofs.checked_add(k * 12)?; + keys.push(( + t - start, + [bytes.f32_at(v)?, bytes.f32_at(v + 4)?, bytes.f32_at(v + 8)?], + )); + } + if keys.len() <= 1 { + return None; // static in this band — nothing to animate + } + Some(BoneScaleAnim { + duration_ms, + interp, + keys, + }) +} + +/// Load an M2 model from the chain, split into renderable submeshes (one per skin batch). Doodads +/// pass no skins; creatures use [`load_m2_mesh_skinned`] to fill their `Monster1/2/3` slots. +pub fn load_m2_mesh(chain: &mut Chain, raw_path: &str) -> Result> { + load_m2_mesh_skinned(chain, raw_path, &[]) +} + +/// Like [`load_m2_mesh`], but fills the model's `Monster1/2/3` texture slots from `skins` +/// (CreatureDisplayInfo `textureVariation` names, in order). +pub fn load_m2_mesh_skinned( + chain: &mut Chain, + raw_path: &str, + skins: &[Option], +) -> Result> { + let path = model_path(raw_path); + let dir = parent_dir(&path).to_string(); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading M2 {path}"))?; + parse_m2_render_submeshes(&bytes, &dir, skins).with_context(|| format!("parsing M2 {path}")) +} + +/// Parse an **in-memory** M2 into render submeshes. `dir` is the model's directory (to resolve its +/// relative texture references); `skins` fill the `Monster1/2/3` texture slots (creature variations). +/// The bytes-in entry point used by the Bevy `AssetLoader`; [`load_m2_mesh_skinned`] is the +/// chain-reading wrapper. +pub fn parse_m2_render_submeshes( + bytes: &[u8], + dir: &str, + skins: &[Option], +) -> Result> { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + let model = format.model(); + let skin = model + .parse_embedded_skin(bytes, 0) + .map_err(|e| anyhow::anyhow!("embedded skin: {e}"))?; + + let vertex = |g: u32| { + let v = &model.vertices[g as usize]; + ( + [v.position.x, v.position.y, v.position.z], + [v.normal.x, v.normal.y, v.normal.z], + [v.tex_coords.x, v.tex_coords.y], + [1.0, 1.0, 1.0, 1.0], // M2 has no MOCV; cleared below so no ATTRIBUTE_COLOR is emitted + ) + }; + // `triangles` are local indices into `indices` (→ global vertex). A submesh owns a slice of it. + let lookup = skin.indices(); + let tris = skin.triangles(); + let global = |t_range: std::ops::Range| -> Vec { + tris.get(t_range) + .unwrap_or(&[]) + .iter() + .filter_map(|&t| lookup.get(t as usize).copied()) + .map(u32::from) + .filter(|&g| (g as usize) < model.vertices.len()) + .collect() + }; + + // Every sequence's (anim id, absolute time band): entry stride 0x44, anim id u16 at +0x00, + // band start/end u32 at +0x04/+0x08. The first entry's band is the loop a placed doodad's + // one-time arm plays; sequence-timeline colour/weight tracks bake against it (`mat_anim`); + // the full list feeds the billboard bones' per-sequence translation loops. + let seq_bands: Vec<(u16, (u32, u32))> = { + let (n, o) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); + (0..n) + .map_while(|i| { + let e = o + i * 0x44; + (e + 0x44 <= bytes.len()).then(|| { + ( + le_u16(bytes, e), + (le_u32(bytes, e + 0x04), le_u32(bytes, e + 0x08)), + ) + }) + }) + .collect() + }; + let seq0_band = seq_bands.first().map(|&(_, band)| band); + + let sections = skin.submeshes(); + let mut out = Vec::new(); + for batch in skin.batches() { + let Some(section) = sections.get(batch.skin_section_index as usize) else { + continue; + }; + let start = section.triangle_start as usize; + let global_indices = global(start..start + section.triangle_count as usize); + if global_indices.is_empty() { + continue; + } + let (texture, skin_slot, char_slot) = model + .raw_data + .texture_lookup_table + .get(batch.texture_combo_index as usize) + .and_then(|&ti| model.textures.get(ti as usize)) + .map(|t| resolve_texture(t, dir, skins)) + .unwrap_or((None, None, None)); + let material = model.materials.get(batch.material_index as usize); + let blend = match material.map(|m| m.blend_mode.bits()) { + Some(0) | None => ModelBlend::Opaque, + Some(1) => ModelBlend::AlphaTest, + // Modes 5/6 are the MULTIPLY blends (wow-re `m2-depth-blend-state`, the DAT_00811fe0 + // remap): 5 Mod → DST_COLOR/ZERO, 6 Mod2x → DST_COLOR/SRC_COLOR — the ARMORREFLECT + // weapon/armor sheen layers. Collapsing them into alpha-Blend painted the reflect + // texture OVER the blade instead of modulating it (decision 0528). + Some(5) => ModelBlend::Mod, + Some(6) => ModelBlend::Mod2x, + Some(_) => ModelBlend::Blend, + }; + // M2 blend mode 3 (NoAlphaAdd) / 4 (Add) are **additive** — the renderer adds the batch's colour + // instead of mixing the background in (glow cards, coronae). Verified: the lamppost glow card + // (GLOW32.BLP, warm amber) carries blend mode 4. + let additive = matches!(material.map(|m| m.blend_mode.bits()), Some(3) | Some(4)); + // M2 render flag 0x04 = "Two-sided (no backface culling)" (wowdev.wiki M2 § Render flags). + // Unset ⇒ the real client backface-culls this batch (visible from one side only). + let two_sided = material.is_some_and(|m| m.flags.bits() & 0x04 != 0); + // M2 render flag 0x01 = "Unlit" — lighting disabled, so the batch shows at full texture + // brightness regardless of sun/ambient: lamp glass, window panes, glow cards. Reuses the same + // fullbright `emissive` path as WMO self-illuminated glass. Verified: the lantern/wagon glass + // batches (ElwynnLantern01.blp) carry 0x01; their bodies carry 0x00. + let emissive = material.is_some_and(|m| m.flags.bits() & 0x01 != 0); + // M2 render flags **0x10 (no depth-write)** / **0x08 (no depth-test)** — the real client keys + // per-batch depth state on these bits, NOT on the blend mode (VERIFIED, wow-re + // `m2-depth-blend-state`): every batch writes depth + tests LEQUAL unless its own bit clears it. + let no_depth_write = material.is_some_and(|m| m.flags.bits() & 0x10 != 0); + let no_depth_test = material.is_some_and(|m| m.flags.bits() & 0x08 != 0); + // The batch's fog COLOUR policy (wow-re rf-weather-emission-timeline ROUND 4, byte-cited): the + // M2 batch state setter `0x70baf0` disables fog outright when render flag 0x02 is set + // (`0x70bb24`); otherwise the fog colour follows the blend mode via the policy table + // `DAT_811fc4 = {1,1,1,2,2,3,4}` dispatched at `0x70bddf`/jump table `0x70c17c` — blend modes + // 0/1/2 (opaque/alphakey/alpha) fog toward the scene colour, 3/4 (Add/AddAlpha) toward BLACK + // (an additive batch FADES under a veil instead of gaining grey — the storm-veil level-up fix), + // 5 (Mod) toward WHITE, 6 (Mod2x) toward GREY. Fog start/end stay the scene values regardless. + let fog_policy = match material { + Some(m) if m.flags.bits() & 0x02 != 0 => FogPolicy::Off, + Some(m) => match m.blend_mode.bits() { + 3 | 4 => FogPolicy::Black, + 5 => FogPolicy::White, + 6 => FogPolicy::Grey, + _ => FogPolicy::Scene, + }, + None => FogPolicy::Scene, + }; + // Static visibility cull (VERIFIED, wow-re `m2-alpha-combine-cull`): the real client multiplies + // a per-batch alpha `A = instanceAlpha · colorAlpha · transparencyWeight` and **skips the batch + // when `A ≤ 0`** — *before* the blend mode is even read, so it culls even an Opaque batch. A + // constant-0 colour-alpha or transparency-weight track is therefore invisible every frame: e.g. + // `OrgrimmarFloatingEmbers`' 84-yd emitter box (weight track `[0.0]`). Drop it at build time + // (instanceAlpha is 1 here; the runtime doodad-fade alpha is the renderer's separate concern). + // Animated tracks (>1 key) can't be decided statically, so we keep them. + let color_alpha = if (batch.color_index as usize) < model.color_alpha_tracks.len() { + track_constant(&model.color_alpha_tracks[batch.color_index as usize]) + } else { + Some(1.0) // colorIndex out of range (incl. 0xffff = none) ⇒ colour factor not applied + }; + let weight = if batch.texture_count != 0 { + model + .transparency_lookup + .get(batch.weight_combo_index as usize) + .and_then(|&t| model.transparency_tracks.get(t as usize)) + .map_or(Some(1.0), track_constant) + } else { + Some(1.0) // textureCount 0 ⇒ transparency factor not applied (verified gate) + }; + if let (Some(c), Some(w)) = (color_alpha, weight) { + if c * w <= 0.0 { + continue; // constant-invisible batch — the real client never draws it + } + } + // The runtime half of the same combine (decision 0130 phase 2): whatever the static test + // above could NOT fold away — a time-varying colour-alpha/weight loop, or a dimming non-1 + // constant — bakes to an [`AlphaAnim`] the app samples per instance on the model clock. + let alpha_anim = { + let color = model + .color_alpha_tracks + .get(batch.color_index as usize) + .and_then(|t| mat_anim::bake_scalar_anim(t, &model.global_sequences, seq0_band)); + let weight = if batch.texture_count != 0 { + model + .transparency_lookup + .get(batch.weight_combo_index as usize) + .and_then(|&t| model.transparency_tracks.get(t as usize)) + .and_then(|t| mat_anim::bake_scalar_anim(t, &model.global_sequences, seq0_band)) + } else { + None // textureCount 0 ⇒ transparency factor not applied (verified gate) + }; + (color.is_some() || weight.is_some()).then_some(AlphaAnim { color, weight }) + }; + // The batch's UV-animation loop (decision 0130 phase 3): batch combo → texAnimLookup → + // translation track, on the same clocks. Carried on the submesh; the renderer applies it. + let uv_anim = tex_anim::bake_uv_anim(model, batch.texture_transform_combo_index, seq0_band); + // The batch's animated RGB tint (the M2Color colour track's runtime half): only a + // time-varying track bakes — a `Some` here *replaces* the static vertex tint below (a + // spell effect's white-hot flash cooling to red would otherwise freeze on its first key). + let rgb_anim = model + .color_rgb_tracks + .get(batch.color_index as usize) + .and_then(|t| mat_anim::bake_rgb_anim(t, &model.global_sequences, seq0_band)); + // M2 billboard bones: a batch's vertices ride billboard bones (glow cards, chains) that the + // real client re-orients to the camera each frame — **each bone independently, about its own + // pivot**. One batch often packs several such cards: a candelabra's candle glows share a single + // glow-texture batch, each quad skinned to its own per-candle bone. So we **split the batch + // into one submesh per billboard bone** (its pivot the rotation centre). Collapsing them onto a + // single pivot (the old first-vertex approach) swings the off-pivot cards in arcs as the camera + // turns, and the scale pulse slides them — the candelabra "glows flung across the room" bug. + // Triangles not on a billboard bone stay one ordinary submesh, so non-billboard batches and a + // lamppost's single glow are unchanged (one group). + let make_billboard = |bone_idx: usize| -> Option { + let bone = model.bones.get(bone_idx)?; + Some(Billboard { + pivot: [bone.pivot.x, bone.pivot.y, bone.pivot.z], + bone: bone_idx as u16, + kind: BillboardKind::from_bone_flags(bone.flags.bits())?, + // The glow-card pulse: this bone's global-sequence scale track (e.g. the lamppost's + // 0.86…1.04 breathe over its 1333 ms global sequence). `None` for a static card. + scale_anim: parse_bone_scale_anim(bytes, bone_idx), + // The bone's per-sequence translation loops (the questgiver bob: anim 0 low, + // anim 190 raised) — carried on the batch; only the marker spawn site arms one + // (see the field doc). + seq_translations: seq_bands + .iter() + .filter_map(|&(id, band)| { + Some((id, parse_bone_seq_translation(bytes, bone_idx, band)?)) + }) + .collect(), + }) + }; + // A vertex's primary bone, but only when it is a billboard bone (else `None` → the ordinary + // group). Group this batch's triangles (chunks of 3 global indices) by that key, in first- + // appearance order and preserving triangle order within each group. + let primary_billboard_bone = |g: u32| -> Option { + let b = model.vertices.get(g as usize)?.bone_indices[0] as usize; + model + .bones + .get(b) + .is_some_and(|bone| bone.is_billboard()) + .then_some(b) + }; + let mut groups: Vec<(Option, Vec)> = Vec::new(); + for tri in global_indices.chunks_exact(3) { + let key = primary_billboard_bone(tri[0]); + if let Some(pos) = groups.iter().position(|(k, _)| *k == key) { + groups[pos].1.extend_from_slice(tri); + } else { + groups.push((key, tri.to_vec())); + } + } + // M2Color tint (header 0x54, the colour record's RGB track @ +0x00, selected by + // `texUnit.colorIndex`): the per-batch colour the real client multiplies into the vertex + // colour. A **constant** track bakes into this batch's vertex colours — dedup-safe (per-mesh, + // not per-material) and the model shader already folds `ATTRIBUTE_COLOR` into `base_color` → + // `albedo` on every path incl. the fullbright glow cards. **Load-bearing for additive glows on + // a neutral glow texture**: the Orgrimmar bonfire's base glow is `GenericGlow_Alpha_128` (a + // white-cored radial, no colour of its own), its warmth living entirely in this M2Color + // `(0.93,0.66,0.10)` — without it the additive draw washes the bright core to white. + // `0xffff`/out-of-range ⇒ no tint (the common case). A **time-varying** track instead rides + // `rgb_anim` above (the vertex bake is skipped — the two would double-apply); the render side + // seeds the material tint at the first key, so a lane that doesn't animate materials shows + // exactly the old static bake. RGB only; alpha stays 1.0 — batch visibility/transparency is + // already governed by the alpha-combine cull, the transparency weight, and the runtime doodad + // fade, so folding the (possibly animated) M2Color alpha in here too would double-count. + let color_tint: Option<[f32; 4]> = match &rgb_anim { + Some(_) => None, + None => model + .color_rgb_tracks + .get(batch.color_index as usize) + .and_then(|t| t.keys.first()) + .map(|&(_, rgb)| [rgb[0], rgb[1], rgb[2], 1.0]), + }; + for (bone, idx) in groups { + let (mut sub, globals) = remap_submesh( + idx.into_iter(), + vertex, + texture.clone(), + blend, + two_sided, + false, // M2 has no interior/exterior group concept + emissive, + ); + sub.billboard = bone.and_then(make_billboard); + sub.additive = additive; // M2 blend mode 3/4 → additive (glow cards) + sub.no_depth_write = no_depth_write; // M2 render flag 0x10 + sub.no_depth_test = no_depth_test; // M2 render flag 0x08 + sub.fog_policy = fog_policy; // render flag 0x02 / the per-blend fog table + sub.skin_slot = skin_slot; // so a skin-less load can fill this batch at spawn + sub.geoset_id = section.id; // the skinSectionId — character geoset selection keys on it + sub.char_slot = char_slot; // character runtime slot (body/hair) — filled per-player at spawn + // Skeletal skin binding (decision 0019), per local vertex in `globals` order: the M2 + // vertex's 4 bone indices (global bone-array indices → joint indices directly) + their + // normalised weights. The skinned-mesh builder uploads these; the static mesh ignores them. + sub.joints = globals + .iter() + .map(|&g| { + let bi = model.vertices[g as usize].bone_indices; + [ + u16::from(bi[0]), + u16::from(bi[1]), + u16::from(bi[2]), + u16::from(bi[3]), + ] + }) + .collect(); + sub.weights = globals + .iter() + .map(|&g| normalize_weights(model.vertices[g as usize].bone_weights)) + .collect(); + + // Tint via vertex colours when the batch carries an M2Color; else none — M2 has no MOCV, so + // an empty vec means the renderer skips ATTRIBUTE_COLOR and the texture shows untinted. + match color_tint { + Some(c) => sub.vertex_colors = vec![c; sub.positions.len()], + None => sub.vertex_colors.clear(), + } + sub.alpha_anim = alpha_anim.clone(); // every billboard-split group shares the batch's loops + sub.uv_anim = uv_anim.clone(); + sub.rgb_anim = rgb_anim.clone(); + out.push(sub); + } + } + // Invisible interaction-zone placeholder (the `SpellObject_InvisibleTrap` class — Fire-Festival + // fury zones, rallying-cry triggers, tonk-game consoles…): a flat (degenerate render box) quad + // whose every batch is **opaque** and textured solely with the engine utility-white `WHITE1.BLP`. + // The real 1.12 client DRAWS this mesh, but at per-instance render alpha ≈0 (the doodad-fade alpha + // slot drives it to ~0 here) so it is invisible — VERIFIED by a reference apitrace (wow-re + // `object-layer/scratch/go-render-gate.md`, the firing-4 capstone: drawn alpha-blended at + // α=8.5e-05). What *drives* that ≈0 alpha is an un-RE'd world-scene-render detail (open handoff), + // so benilla reproduces the observed invisibility by recognising the placeholder asset and dropping + // its geometry. Scoped tight on purpose: verified against all 1602 GameObject display models, ONLY + // this placeholder matches — visible flat decals (orc sleep mats, pentagram circles, AQ door runes) + // carry real textures + Blend/AlphaTest and are kept. Empirical stop-gap, not the client mechanism + // (decision 0030). + if is_white1_placeholder( + model.header.bounding_box_min, + model.header.bounding_box_max, + &out, + ) { + out.clear(); + } + Ok(out) +} + +/// The invisible-interaction-zone placeholder fingerprint: a model with a **degenerate render box** +/// (zero extent on some axis — a flat quad) whose **every** render batch is opaque and textured solely +/// with the engine utility-white `WHITE1.BLP` (`SpellObject_InvisibleTrap` & kin). Such a model is the +/// real client's invisible-by-near-zero-alpha placeholder, drawn but unseen; benilla drops its geometry +/// to match. Returns `false` for an empty batch set, a 3D box, or any non-opaque / non-`WHITE1` batch +/// (so visible flat decals are kept). See the call site + decision 0030. +fn is_white1_placeholder(bbox_min: [f32; 3], bbox_max: [f32; 3], subs: &[RenderSubmesh]) -> bool { + if subs.is_empty() { + return false; + } + let degenerate = (0..3).any(|k| (bbox_max[k] - bbox_min[k]).abs() < 1e-3); + degenerate + && subs.iter().all(|s| { + matches!(s.blend, ModelBlend::Opaque) + && !s.additive + && s.texture + .as_deref() + .is_some_and(|t| t.to_ascii_lowercase().ends_with("white1.blp")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data test skips when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The tauren body, straight off the real client data: its M2 type-8 (extra skin — the fur) + /// batches must surface as [`CharSkinSlot::SkinExtra`] parts, in both authored flavors — the + /// opaque single-sided fur core and the alpha-cut two-sided fringe cards — so the spawn site can + /// bind the CharSections `_Extra` BLP per flavor. Unmapped, these batches carry no texture and + /// draw flat white (the "cows are white" bug). + #[test] + fn tauren_fur_batches_carry_the_skin_extra_slot() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let subs = load_m2_mesh(&mut chain, "Character\\Tauren\\Male\\TaurenMale.m2") + .expect("parse TaurenMale"); + let extra: Vec<_> = subs + .iter() + .filter(|s| s.char_slot == Some(CharSkinSlot::SkinExtra)) + .collect(); + assert!( + extra.len() > 20, + "the tauren fur is many batches, got {}", + extra.len() + ); + assert!( + extra.iter().all(|s| s.texture.is_none()), + "type 8 has no embedded path — the spawn site fills it" + ); + assert!( + extra + .iter() + .any(|s| matches!(s.blend, ModelBlend::Opaque) && !s.two_sided), + "the opaque single-sided fur core flavor exists" + ); + assert!( + extra + .iter() + .any(|s| matches!(s.blend, ModelBlend::AlphaTest) && s.two_sided), + "the alpha-cut two-sided fringe flavor exists" + ); + } + + /// The Whirlwind Axe (display 22734 → `Axe_2H_Horde_C_01.m2`), straight off the real client + /// data: three batches — handle + blade on the runtime Object skin, and the blade's + /// ARMORREFLECT layer as **Mod2x** (material `{flags 0x10, blendMode 6}` — the multiply blend, + /// decision 0528) with no depth write and NOT additive. Collapsing mode 6 into alpha-Blend is + /// exactly the "flat pale blade" regression this pins against. + #[test] + fn whirlwind_axe_reflect_layer_is_mod2x() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let subs = load_m2_mesh( + &mut chain, + "Item\\ObjectComponents\\Weapon\\Axe_2H_Horde_C_01.m2", + ) + .expect("parse Axe_2H_Horde_C_01"); + assert_eq!(subs.len(), 3, "handle + blade + reflect layer"); + let object: Vec<_> = subs + .iter() + .filter(|s| s.char_slot == Some(CharSkinSlot::Object)) + .collect(); + assert_eq!( + object.len(), + 2, + "handle + blade bind the item's Object skin" + ); + let reflect = subs + .iter() + .find(|s| { + s.texture + .as_deref() + .is_some_and(|t| t.to_ascii_uppercase().contains("ARMORREFLECT")) + }) + .expect("the ARMORREFLECT layer exists"); + assert_eq!(reflect.blend, ModelBlend::Mod2x); + assert!(!reflect.additive, "Mod2x is a multiply, not an add"); + assert!(reflect.no_depth_write, "render flag 0x10"); + } + + /// The questgiver `?` marker, straight off the real client data: ONE cylindrical-billboard + /// bone with a 3-key translation bob per sequence — anim **0** (the load arm's low bob) and + /// anim **190**, the authored **raised** bob the client arms while the unit shows an overhead + /// name (wow-re `questgiver-marker.md` Q4; m2bones-probed key values: anim 0 spans WoW z + /// 0.000→−0.089, anim 190 z +0.517→+0.427 — same key COUNT, different values, which is + /// exactly how the earlier "seq 190 = same bob" count-only probe went wrong). Guards the + /// band-restricted translation bake end-to-end. + #[test] + fn questionmark_marker_batch_carries_both_bob_loops() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let subs = load_m2_mesh(&mut chain, "Interface\\Buttons\\TalkToMeQuestionMark.mdx") + .expect("parse TalkToMeQuestionMark"); + let bb = subs + .iter() + .find_map(|s| s.billboard.as_ref()) + .expect("the ? marker rides a billboard bone"); + assert!(matches!(bb.kind, BillboardKind::LockZ)); + let bob = |id: u16| { + bb.seq_translations + .iter() + .find(|(a, _)| *a == id) + .map(|(_, l)| l) + }; + let low = bob(0).expect("the load-arm loop bakes"); + assert_eq!(low.duration_ms, 1533, "the first sequence's band"); + assert!(low.keys.len() >= 2, "a moving track: {:?}", low.keys); + let (a, b) = (low.sample(0), low.sample(low.duration_ms / 2)); + assert_ne!(a, b, "the sampled offset varies across the loop"); + assert!( + low.keys.iter().all(|(_, v)| v[2] <= 0.0), + "anim 0 bobs at/below the attach point: {:?}", + low.keys + ); + let raised = bob(190).expect("the raised loop bakes"); + assert_eq!(raised.duration_ms, 1533, "same period as the low bob"); + assert!( + raised.keys.iter().all(|(_, v)| v[2] > 0.4), + "anim 190 is the authored raised bob: {:?}", + raised.keys + ); + } +} diff --git a/crates/benilla-formats/src/models/mat_anim.rs b/crates/benilla-formats/src/models/mat_anim.rs new file mode 100644 index 00000000..ff505368 --- /dev/null +++ b/crates/benilla-formats/src/models/mat_anim.rs @@ -0,0 +1,300 @@ +//! The animated **material-alpha** bake (decision 0130 phase 2): each M2 batch's colour-alpha and +//! transparency-weight tracks, baked to loopable second-domain keys the runtime samples per instance. +//! +//! Byte ground (wow-re `m2-alpha-combine-cull.md`, VERIFIED): the per-batch alpha is +//! `A = instanceAlpha × colors[colorIndex].alpha × transparency[transLookup[idx]].weight`, both +//! tracks animation-evaluated each frame; `A ≤ 0` skips the batch before the blend mode is read, and +//! an Opaque batch with `0 < A < 1` still draws opaque (no blend promotion). Clocks (wow-re +//! `eval.md`/`doodad-anim-host.md`): a `gseq`-tagged track wraps `global_sequences[gseq]`; an +//! ordinary track keys inside the playing sequence's absolute time band — for a placed doodad that +//! is the file-order-first sequence, looping forever. +//! +//! The sampler + clock resolution live in [`super::key_anim`] (shared with the texture-transform +//! bake); this module owns only the alpha channel's semantics. + +use benilla_m2::{M2ScalarTrack, M2Vec3Track}; + +use super::key_anim::{bake_track, KeyAnim}; + +/// One baked scalar loop, seconds — see [`KeyAnim`]. The alpha channels' instantiation. +pub type ScalarAnim = KeyAnim; + +impl KeyAnim { + /// Sample at `elapsed` seconds on the loop clock (`1.0` — the multiplicative identity — for a + /// defensively-empty loop the bake never emits). + pub fn sample(&self, elapsed: f32) -> f32 { + self.sample_or(elapsed, 1.0) + } +} + +/// One baked RGB tint loop — the M2Color **colour** track's instantiation (the per-batch tint the +/// real client multiplies into the vertex colour, animation-evaluated like the alpha). +pub type RgbAnim = KeyAnim<[f32; 3]>; + +impl KeyAnim<[f32; 3]> { + /// Sample at `elapsed` seconds on the loop clock (white — the tint identity — for a + /// defensively-empty loop the bake never emits). + pub fn sample(&self, elapsed: f32) -> [f32; 3] { + self.sample_or(elapsed, [1.0, 1.0, 1.0]) + } +} + +/// A batch's animated-alpha pair: the colour-alpha factor and the transparency-weight factor — +/// multiplied together (and by the instance fade) at sample time, per the verified combine. Either +/// side absent ⇒ that factor is constant `1` (or already baked/culled statically). +#[derive(Clone, Debug, PartialEq)] +pub struct AlphaAnim { + pub color: Option, + pub weight: Option, +} + +impl AlphaAnim { + /// The combined factor at `elapsed` seconds since the instance's clock origin. + pub fn sample(&self, elapsed: f32) -> f32 { + let c = self.color.as_ref().map_or(1.0, |a| a.sample(elapsed)); + let w = self.weight.as_ref().map_or(1.0, |a| a.sample(elapsed)); + c * w + } +} + +/// Bake one scalar track to a [`ScalarAnim`], or `None` when it contributes nothing the static path +/// doesn't already handle: no keys (factor doesn't apply), a constant 1 (identity), or a constant +/// ≤ 0 (the static cull already dropped the batch). A dimming constant IS kept — the real combine +/// dims the batch by it, which the static vertex bake never did — and a band-empty track holding a +/// non-1 value bakes that hold, **including a held 0** (the batch must stay hidden every frame; the +/// static cull never saw it because the full track isn't constant). +pub(super) fn bake_scalar_anim( + track: &M2ScalarTrack, + gseq_durations: &[u32], + seq0: Option<(u32, u32)>, +) -> Option { + bake_track( + track, + gseq_durations, + seq0, + |v| v, + |c| (c - 1.0).abs() < f32::EPSILON || c <= 0.0, + |v| (v - 1.0).abs() < f32::EPSILON, + ) +} + +/// Bake one M2Color **RGB** track to an [`RgbAnim`], or `None` unless the track genuinely varies +/// inside the model's clock (a spell effect's white-hot flash cooling to red). Constants — and +/// band-empty/single-key holds — stay `None` so the **static vertex-colour bake** (`m2_batches`, +/// decision 0029) keeps owning them unchanged; only a time-varying track moves the tint to the +/// animated material channel (and skips the vertex bake, so the two never double-apply). +pub(super) fn bake_rgb_anim( + track: &M2Vec3Track, + gseq_durations: &[u32], + seq0: Option<(u32, u32)>, +) -> Option { + bake_track(track, gseq_durations, seq0, |v| v, |_| true, |_| true) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data test skips when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The Stormwind mage portal, straight off the real client data: its shimmer is authored as + /// animated transparency-weight tracks (3 of its 4 records are time-varying — `doodadscan`'s + /// material-channel listing), so `parse_m2_render_submeshes` must emit at least one batch with a + /// live weight loop whose sampled value actually varies. Guards the whole bake chain — the full + /// scalar-track parse (benilla-m2), the transLookup two-hop, the gseq/band clock resolution — + /// against a silent regression. Skips when the client data isn't present. + #[test] + fn mage_portal_bakes_a_time_varying_weight_loop() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("World\\generic\\activedoodads\\mageportals\\StormwindMagePortal01.m2") + .expect("read StormwindMagePortal01.m2"); + let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + let animated: Vec<&ScalarAnim> = subs + .iter() + .filter_map(|s| s.alpha_anim.as_ref()) + .filter_map(|a| a.weight.as_ref()) + .filter(|w| w.period > 0.0 && w.keys.len() > 1) + .collect(); + assert!( + !animated.is_empty(), + "the portal's shimmer batches carry time-varying weight loops" + ); + let w = animated[0]; + // The loop genuinely moves: two samples a quarter-period apart differ. + let (a, b) = (w.sample(0.0), w.sample(w.period * 0.25)); + assert!( + (a - b).abs() > 1e-3, + "sampled weight varies over the loop (got {a} vs {b}, period {})", + w.period + ); + assert!( + w.keys.iter().all(|&(_, v)| (0.0..=1.0).contains(&v)), + "fix16 weights decode into [0, 1]" + ); + } + + /// Battle Shout's ground model, straight off the real client data: six additive crescent + /// quads whose whole look is material animation — staggered colour-alpha pulses, dimming + /// weight constants (0.3/0.2/0.4), and RGB tracks cooling white→red over the 0.9 s clip. + /// Every batch must bake a time-varying colour-alpha loop AND a time-varying RGB loop, with + /// the static vertex tint skipped (the material channel owns it — a first-key vertex bake + /// would freeze four of six crescents white). Guards the RGB half of the bake chain end to + /// end. Skips when the client data isn't present. + #[test] + fn battle_shout_base_bakes_alpha_and_rgb_loops() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Spells\\BattleShout_Cast_Base.m2") + .expect("read BattleShout_Cast_Base.m2"); + let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + assert_eq!(subs.len(), 6, "six crescent batches"); + for (i, s) in subs.iter().enumerate() { + let a = s.alpha_anim.as_ref().unwrap_or_else(|| { + panic!("batch {i}: the colour-alpha/weight loops must bake"); + }); + let c = a.color.as_ref().expect("time-varying colour-alpha"); + assert!( + c.period > 0.0 && c.keys.len() > 1, + "batch {i}: alpha varies" + ); + let w = a.weight.as_ref().expect("dimming weight constant"); + let wv = w.sample(0.0); + assert!( + (0.19..=0.41).contains(&wv), + "batch {i}: weight dims to 0.2–0.4 (got {wv})" + ); + let rgb = s.rgb_anim.as_ref().unwrap_or_else(|| { + panic!("batch {i}: the time-varying RGB tint must bake"); + }); + assert!( + rgb.period > 0.0 && rgb.keys.len() > 1, + "batch {i}: RGB varies" + ); + // The authored cool-down: red-dominant by mid-life on every crescent. + let mid = rgb.sample(rgb.period * 0.5); + assert!( + mid[0] > 0.6 && mid[1] < 0.1 && mid[2] < 0.1, + "batch {i}: mid-life tint is red (got {mid:?})" + ); + assert!( + s.vertex_colors.is_empty(), + "batch {i}: the static vertex tint is skipped when the RGB animates" + ); + } + } + + fn track(gseq: u16, interp: u16, keys: &[(u32, f32)]) -> M2ScalarTrack { + M2ScalarTrack { + interp, + gseq, + keys: keys.to_vec(), + } + } + + /// The bake's contribution gate: keyless and constant-1 tracks vanish; a dimming constant is + /// kept as period-0 (the combine the static vertex bake never applied); constant-0 is the + /// static cull's job, not a runtime anim. + #[test] + fn bake_keeps_only_what_the_static_path_cannot_do() { + assert_eq!(bake_scalar_anim(&track(0xffff, 1, &[]), &[], None), None); + assert_eq!( + bake_scalar_anim(&track(0xffff, 1, &[(0, 1.0)]), &[], None), + None + ); + assert_eq!( + bake_scalar_anim(&track(0xffff, 1, &[(0, 0.0), (500, 0.0)]), &[], None), + None + ); + let dim = bake_scalar_anim(&track(0xffff, 1, &[(0, 0.4)]), &[], None).unwrap(); + assert_eq!(dim.period, 0.0); + assert_eq!(dim.sample(123.0), 0.4); + } + + /// A gseq-tagged track wraps the global-sequence duration — the fire-flicker shape: value + /// pulses over a 1.5 s loop regardless of any playing sequence. + #[test] + fn gseq_track_wraps_the_table_duration() { + let a = bake_scalar_anim( + &track(1, 1, &[(0, 0.2), (750, 1.0), (1500, 0.2)]), + &[9999, 1500], + None, + ) + .unwrap(); + assert_eq!(a.period, 1.5); + assert!((a.sample(0.375) - 0.6).abs() < 1e-4); // linear midpoint + assert!((a.sample(1.5 + 0.375) - 0.6).abs() < 1e-4); // wraps + } + + /// A sequence-timeline track keeps only the first sequence's band, rebased to seconds — keys + /// belonging to other sequences (outside the band) don't bleed in. + #[test] + fn sequence_track_bakes_the_first_band_only() { + let t = track(0xffff, 1, &[(1000, 0.0), (1500, 1.0), (5000, 0.3)]); + let a = bake_scalar_anim(&t, &[], Some((1000, 2000))).unwrap(); + assert_eq!(a.period, 1.0); + assert_eq!(a.keys, vec![(0.0, 0.0), (0.5, 1.0)]); + assert!((a.sample(0.25) - 0.5).abs() < 1e-6); + // Past the last in-band key: hold, no wrap-lerp. + assert_eq!(a.sample(0.9), 1.0); + } + + /// A band-empty track holds its nearest earlier key — including a held **0**, which must keep + /// hiding the batch (the static cull never saw it: the full track isn't constant). The phase-2 + /// bake dropped this hold; the shared core keeps it. + #[test] + fn band_empty_track_holds_a_zero_and_keeps_hiding() { + let t = track(0xffff, 1, &[(100, 0.0), (5000, 1.0)]); + let a = bake_scalar_anim(&t, &[], Some((1000, 2000))).unwrap(); + assert_eq!(a.period, 0.0); + assert_eq!(a.sample(42.0), 0.0); + // A held 1 (or nothing held) still contributes nothing. + let one = track(0xffff, 1, &[(100, 1.0), (5000, 0.3)]); + assert_eq!(bake_scalar_anim(&one, &[], Some((1000, 2000))), None); + } + + /// Step interpolation holds each key until the next (the kernel's `interp == 0` leg). + #[test] + fn step_tracks_hold_between_keys() { + let a = bake_scalar_anim(&track(0, 0, &[(0, 0.2), (1000, 1.0)]), &[2000], None).unwrap(); + assert!(a.step); + assert_eq!(a.sample(0.999), 0.2); + assert_eq!(a.sample(1.0), 1.0); + } + + /// The combined pair multiplies its two factors — the verified combine's track half. + #[test] + fn alpha_anim_multiplies_color_and_weight() { + let both = AlphaAnim { + color: Some(ScalarAnim { + period: 0.0, + step: false, + keys: vec![(0.0, 0.5)], + }), + weight: Some(ScalarAnim { + period: 0.0, + step: false, + keys: vec![(0.0, 0.5)], + }), + }; + assert!((both.sample(7.0) - 0.25).abs() < 1e-6); + let neither = AlphaAnim { + color: None, + weight: None, + }; + assert_eq!(neither.sample(0.0), 1.0); + } +} diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs new file mode 100644 index 00000000..91603fd9 --- /dev/null +++ b/crates/benilla-formats/src/models/mod.rs @@ -0,0 +1,140 @@ +//! Model loading — the shared `models` module face (decision 0021). +//! +//! Parsing is delegated to `benilla-m2` (M2) / `benilla-wmo` (WMO). Both produce a list of +//! [`RenderSubmesh`] — one per render batch, each with its own (remapped) vertices, texture, and blend +//! mode. M2 models and WMO groups are split into batches that each have their own material (a tree = +//! opaque trunk + alpha leaves; a building = many wall/floor/roof textures), so they render separately +//! or the wrong texture/blend lands on parts of the model. Vertices are model space (Z-up ≈ WoW axes). +//! +//! Split by concern: this file keeps the top-level dispatch ([`load_object_model`]) + the byte/remap +//! helpers the submodules share; [`types`] holds the shared render types ([`RenderSubmesh`], +//! [`ModelBlend`], billboard/light data), [`m2_batches`] the M2 render-batch assembly loop, [`bounds`] +//! the authored-bounds/selection-ring reads, [`anim`] skeleton + animation, [`wmo`] the WMO parse, and +//! [`collision`] the collision hulls. + +use std::collections::HashMap; + +use anyhow::Result; + +use crate::Chain; + +mod anim; +mod anim_summary; +mod bounds; +mod collision; +mod key_anim; +mod m2_batches; +mod mat_anim; +mod records; +mod tex_anim; +mod types; +mod wmo; +pub use anim::*; +pub use anim_summary::*; +pub use bounds::*; +pub use collision::*; +pub use m2_batches::*; +pub use mat_anim::{AlphaAnim, RgbAnim, ScalarAnim}; +pub use records::*; +pub use tex_anim::UvAnim; +pub use types::*; +pub use wmo::*; + +/// Build a submesh from a stream of global vertex indices, remapping to a local vertex subset so +/// each batch is a compact standalone mesh. `vertex` fetches `(position, normal, uv, colour)` for a +/// global index. Also returns, parallel to the submesh's local vertices, each one's **global** index +/// — so the M2 path can fill the per-vertex skin binding ([`RenderSubmesh::joints`]) afterward (the +/// skin lives on the M2 vertex, not in the geometry closure, and WMO has none). +fn remap_submesh( + global_indices: impl Iterator, + vertex: impl Fn(u32) -> ([f32; 3], [f32; 3], [f32; 2], [f32; 4]), + texture: Option, + blend: ModelBlend, + two_sided: bool, + interior: bool, + emissive: bool, +) -> (RenderSubmesh, Vec) { + let mut map: HashMap = HashMap::new(); + let (mut positions, mut normals, mut uvs, mut indices, mut vertex_colors) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut globals: Vec = Vec::new(); + for g in global_indices { + let local = *map.entry(g).or_insert_with(|| { + let (p, n, uv, c) = vertex(g); + positions.push(p); + normals.push(n); + uvs.push(uv); + vertex_colors.push(c); + globals.push(g); + (positions.len() - 1) as u32 + }); + indices.push(local); + } + ( + RenderSubmesh { + positions, + normals, + uvs, + indices, + texture, + skin_slot: None, + geoset_id: 0, // set by the caller per batch (the M2 path; WMO leaves it 0) + char_slot: None, // set by the caller per batch (M2 only) + blend, + two_sided, + vertex_colors, + joints: Vec::new(), // M2 path fills these from `globals`; WMO leaves them empty + weights: Vec::new(), // + interior, + emissive, + sidn: None, // set by the WMO path from MOMT SIDN (0x10) + its authored colour + window: false, // set by the WMO path from MOMT WINDOW (0x20) + additive: false, // set by the M2 path from the blend mode (3/4) + no_depth_write: false, // set by the M2 path from render flag 0x10 (WMO keeps standard depth) + no_depth_test: false, // set by the M2 path from render flag 0x08 + fog_policy: FogPolicy::Scene, // refined by the M2 path (flag 0x02 + the blend table); WMO batches fog with the scene + billboard: None, // set by the M2 path after detecting the batch's bone + alpha_anim: None, // set by the M2 path from the batch's colour/weight tracks + uv_anim: None, // set by the M2 path from the batch's texture transform + rgb_anim: None, // set by the M2 path from the batch's colour RGB track + wmo_batch: None, // set by the WMO path from the MOGP batch-section counts + }, + globals, + ) +} + +/// Normalize a model path for the chain: lowercase (case-insensitive lookup), `.mdx`/`.mdl` → `.m2`. +pub(crate) fn model_path(raw: &str) -> String { + let lower = raw.to_ascii_lowercase(); + match lower + .strip_suffix(".mdx") + .or_else(|| lower.strip_suffix(".mdl")) + { + Some(stem) => format!("{stem}.m2"), + None => lower, + } +} + +/// Panicking little-endian readers, kept **only** for raw offset walks that predate the decision-0064 +/// migration to `benilla_bytes::ByteExt` — [`anim`]'s bone/track walk, [`anim_summary`]'s header +/// reads, and [`m2_batches`]'s sequence-0 time-band lookup all still import these three. +pub(crate) fn le_u16(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +pub(crate) fn le_u32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +pub(crate) fn le_f32(b: &[u8], o: usize) -> f32 { + f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +/// Load a world model by path, dispatching on extension: `.wmo` → [`load_wmo`], otherwise +/// (`.mdx`/`.mdl`/`.m2`) → [`load_m2_mesh`]. Used for GameObject display models, which are mostly M2 +/// with a few WMO. +pub fn load_object_model(chain: &mut Chain, raw_path: &str) -> Result> { + if raw_path.to_ascii_lowercase().ends_with(".wmo") { + load_wmo(chain, raw_path) + } else { + load_m2_mesh(chain, raw_path) + } +} diff --git a/crates/benilla-formats/src/models/records.rs b/crates/benilla-formats/src/models/records.rs new file mode 100644 index 00000000..49c6f53e --- /dev/null +++ b/crates/benilla-formats/src/models/records.rs @@ -0,0 +1,449 @@ +//! Raw **authored-record reads** straight off the model bytes — the cosmetic MD20 arrays +//! `benilla-m2` deliberately skips (it parses only the render path) plus the WMO root's MOLT +//! lights: M2 dynamic lights (decision 0016), the WMO fixture lights, and the model's authored +//! **portrait camera** (the unit-frame bake's framing source). Each is a small header-offset walk +//! over the vanilla record shape, byte-verified in wow-5875-re; all positions stay raw WoW model +//! space (the render boundary bakes to Bevy space). + +use benilla_bytes::ByteExt; + +/// An M2 **light** (MD20 lights array, byte-verified against wow-5875-re +/// `models/scratch/m2-dynamic-lights.md`). A `light_type == 1` (point) light on a placed prop +/// (campfire/torch/brazier/candle/forge) is committed by the real client as a hardware `GL_LIGHT` with +/// the fixed attenuation `1 / (0.7·d + 0.03·d²)` — constant term 0, so intensity peaks hard at the +/// source: the tight interior "hot-spot" (decision 0016). It lights every lit surface — terrain, M2 +/// doodads/NPCs, and WMO walls/floors (decision 0273; the committed light is diffuse-only, its ambient +/// and specular are zero *on world props* — the glue background scenes DO author their ambient +/// through one dedicated ambient-only light, so both track pairs are parsed). `position` is model +/// space (WoW axes), relative to `bone` (`-1` = model origin); the spawn site applies the prop +/// transform. Colour/intensity are each track's representative (first) value — per-frame flicker is +/// a later refinement; the authored attenuation start/end are parsed for a cull hint, but the GL +/// curve is fixed and ignores them. +#[derive(Debug, Clone, Copy)] +pub struct M2Light { + pub light_type: u16, + pub bone: i16, + pub position: [f32; 3], + pub ambient_color: [f32; 3], + pub ambient_intensity: f32, + pub diffuse_color: [f32; 3], + pub diffuse_intensity: f32, + pub attenuation_start: f32, + pub attenuation_end: f32, + /// The light **bone's local +Z axis in model space** at the track origin (first rotation keys + /// composed up the parent chain) — a DIRECTIONAL light's direction basis. The byte law (wow-re + /// `glue/scratch/glue-model-lighting.md §3`, correcting `m2-dynamic-lights.md §2`): the gather + /// reads **row 2 of the light bone's pose matrix** — never the def position, which is the + /// POINT branch's input only — and the on-surface **to-light** vector nets to + /// `normalize(Bz·BM_rot)`. `[0,0,1]` for a boneless/rotationless chain (identity pose). + pub bone_z: [f32; 3], + /// The **ON-gating** verdict (wow-re `m2-dynamic-lights.md` §9.4, VERIFIED mechanism): both + /// runtime gate bytes load as `1`, and the per-frame animate loop rewrites the visibility byte + /// **only when the visibility track has keys** (`716413`: zero keys ⇒ sample skipped ⇒ the load + /// default 1 stands). So a light is dark iff its asset ships a first visibility key of literally + /// `0`. `true` = exactly that shape (never cast it); `false` = keyless, or a nonzero first key. + /// The track's values are **bytes** (`71646d: +0xec = visibilityValue[k0]`), not floats. + pub visibility_off: bool, +} + +impl M2Light { + /// `type == 1` — an omnidirectional point light (the hot-spot caster). Type 0 (directional) feeds the + /// ambient accumulator, not a discrete GL light (decision 0016 / wow-5875-re m2-dynamic-lights). + pub fn is_point(&self) -> bool { + self.light_type == 1 + } + + /// The spawn gate: a **point** light whose visibility track doesn't hold it dark (§9.4). Every + /// site that turns authored M2 lights into scene lights filters on this. + pub fn casts(&self) -> bool { + self.is_point() && !self.visibility_off + } +} + +/// A WMO **MOLT** light (root chunk; byte-verified against real assets — Goldshire inn/forge, Northshire +/// Abbey carry 10/3/42 of them). The artists place omni (`light_type == 0`) lights at the interior +/// fixtures (fireplaces, forge, candles, chandeliers); they are warm-coloured, and each wall fixture +/// doodad gets a companion MOLT at its flames (NSabbey: one per candelabra, ~3.4 yd above the MODD +/// origin). At runtime 1.12 commits them into the scene dynamic-light DB every frame for every visible +/// WMO (wow-re `wmo-molt-runtime`), lighting terrain, doodads/NPCs, and the building's own surfaces +/// over their baked MOCV (decision 0273 — confirmed live in the reference GL trace: an NSabbey batch +/// drew under `colour × intensity` point lights at the fixed 0/0.7/0.03 falloff). `position` is WMO +/// model space (WoW axes). SMOLight stride 0x30. +#[derive(Debug, Clone, Copy)] +pub struct WmoLight { + pub light_type: u8, + pub use_atten: bool, + pub color: [f32; 3], + pub position: [f32; 3], + pub intensity: f32, + pub attenuation_start: f32, + pub attenuation_end: f32, +} + +impl WmoLight { + /// `type == 0` — an omnidirectional (point) MOLT light: the interior fixture lights. (1 = spot, + /// 2 = directional, 3 = ambient — none seen in the vanilla human interiors audited.) + pub fn is_omni(&self) -> bool { + self.light_type == 0 + } +} + +/// First value of a vanilla 28-byte M2Track (values `count@0x14`, `ofs@0x18`) as `f32` / `C3Vector`. +/// The representative (static-or-first-key) value — enough to place + colour a light; per-frame track +/// evaluation (the flame flicker) is layered on later. `None` if the track is empty / out of bounds. +fn track_first_f32(bytes: &[u8], track: usize) -> Option { + let nval = bytes.u32_at(track + 0x14)? as usize; + let ofs = bytes.u32_at(track + 0x18)? as usize; + if nval == 0 { + return None; + } + bytes.f32_at(ofs) +} +/// First value of a vanilla 28-byte M2Track whose values are **bytes** — the light visibility track +/// ([`M2Light::visibility_off`]). `None` for a keyless track (which the animate loop leaves at the +/// load default, i.e. ON). +fn track_first_u8(bytes: &[u8], track: usize) -> Option { + let nval = bytes.u32_at(track + 0x14)? as usize; + let ofs = bytes.u32_at(track + 0x18)? as usize; + if nval == 0 { + return None; + } + bytes.get(ofs).copied() +} +fn track_first_vec3(bytes: &[u8], track: usize) -> Option<[f32; 3]> { + let nval = bytes.u32_at(track + 0x14)? as usize; + let ofs = bytes.u32_at(track + 0x18)? as usize; + if nval == 0 { + return None; + } + Some([ + bytes.f32_at(ofs)?, + bytes.f32_at(ofs + 4)?, + bytes.f32_at(ofs + 8)?, + ]) +} + +/// Hamilton product `a·b` of two `[x, y, z, w]` quaternions (the vanilla M2 key layout). +fn quat_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { + [ + a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1], + a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0], + a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3], + a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2], + ] +} + +/// `q · (0,0,1) · q⁻¹` — the +Z axis rotated by `q`, expanded closed-form. +fn quat_rotate_z(q: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = q; + [ + 2.0 * (x * z + w * y), + 2.0 * (y * z - w * x), + 1.0 - 2.0 * (x * x + y * y), + ] +} + +/// First key of a vanilla 28-byte M2Track whose values are `[x,y,z,w]` f32 quaternions (the v256 +/// bone-rotation key shape). `None` if the track is empty / out of bounds. +fn track_first_quat(bytes: &[u8], track: usize) -> Option<[f32; 4]> { + let nval = bytes.u32_at(track + 0x14)? as usize; + let ofs = bytes.u32_at(track + 0x18)? as usize; + if nval == 0 { + return None; + } + Some([ + bytes.f32_at(ofs)?, + bytes.f32_at(ofs + 4)?, + bytes.f32_at(ofs + 8)?, + bytes.f32_at(ofs + 12)?, + ]) +} + +/// A bone's **model-space +Z axis at the track origin**: first rotation keys composed up the +/// parent chain (`global = parent ∘ local`), applied to `(0,0,1)`. Vanilla bone table +/// `count@0x34`/`ofs@0x38`, record stride `0x6c`: flags `u32@4` (bit `0x04` = keep the model +/// root's orientation — stop inheriting), parent `i16@8`, rotation M2Track `@0x28`. Rotationless +/// chains yield `[0,0,1]` (the identity pose — vanilla's rest skeleton is pure translations). +/// Sequence-0 *animation* of a light bone's rotation is not tracked (no UI_* scene authors one). +fn bone_z_axis(bytes: &[u8], bone: i16) -> [f32; 3] { + let (Some(count), Some(ofs)) = (bytes.u32_at(0x34), bytes.u32_at(0x38)) else { + return [0.0, 0.0, 1.0]; + }; + let (count, ofs) = (count as usize, ofs as usize); + // Collect local rotations leaf→root, cycle-guarded by the bone count. + let mut chain: Vec<[f32; 4]> = Vec::new(); + let mut idx = bone; + for _ in 0..=count { + if idx < 0 || idx as usize >= count { + break; + } + let rec = ofs + idx as usize * 0x6c; + if let Some(q) = track_first_quat(bytes, rec + 0x28) { + chain.push(q); + } + let flags = bytes.u32_at(rec + 4).unwrap_or(0); + if flags & 0x4 != 0 { + break; // the bone keeps the model root's orientation + } + idx = bytes.u16_at(rec + 8).map(|p| p as i16).unwrap_or(-1); + } + let mut q = [0.0, 0.0, 0.0, 1.0]; + for local in chain.iter().rev() { + q = quat_mul(q, *local); // root-first: global = root ∘ … ∘ local + } + quat_rotate_z(q) +} + +/// One M2 light record at a bounds-checked `rec` (the loop below already proved `rec + 0xd4 <= +/// bytes.len()`), so these reads can only fail on a header/offset bug — treated as "stop", not panic. +/// Offsets byte-verified in wow-5875-re `models/scratch/m2-dynamic-lights.md §1`: `type@0`, `bone@2`, +/// `position@4`, then 7 × `0x1c` M2Tracks — ambient colour/intensity `@0x10`/`@0x2c`, diffuse +/// colour/intensity `@0x48`/`@0x64`, attenuation start/end `@0x80`/`@0x9c`, visibility `@0xb8`. We +/// take diffuse (colour+intensity) + attenuation + the visibility gate ([`M2Light::visibility_off`], +/// byte-valued); ambient is a later refinement. +fn read_m2_light(bytes: &[u8], rec: usize) -> Option { + let bone = bytes.u16_at(rec + 0x02)? as i16; + Some(M2Light { + light_type: bytes.u16_at(rec)?, + bone, + bone_z: bone_z_axis(bytes, bone), + position: [ + bytes.f32_at(rec + 0x04)?, + bytes.f32_at(rec + 0x08)?, + bytes.f32_at(rec + 0x0c)?, + ], + ambient_color: track_first_vec3(bytes, rec + 0x10).unwrap_or([1.0; 3]), + ambient_intensity: track_first_f32(bytes, rec + 0x2c).unwrap_or(0.0), + diffuse_color: track_first_vec3(bytes, rec + 0x48).unwrap_or([1.0; 3]), + diffuse_intensity: track_first_f32(bytes, rec + 0x64).unwrap_or(1.0), + attenuation_start: track_first_f32(bytes, rec + 0x80).unwrap_or(0.0), + attenuation_end: track_first_f32(bytes, rec + 0x9c).unwrap_or(0.0), + visibility_off: track_first_u8(bytes, rec + 0xb8) == Some(0), + }) +} + +/// Parse the M2 **lights** array straight from the raw bytes (MD20 `count@0x11c`, `ofs@0x120`, record +/// stride `0xd4`, see [`read_m2_light`]). `benilla-m2` deliberately parses only the render path and +/// skips the cosmetic chunks (lights/particles/tex-anims), so we read the vanilla light record here +/// (decision 0016). +pub fn parse_m2_lights(bytes: &[u8]) -> Vec { + let (Some(count), Some(ofs)) = (bytes.u32_at(0x11c), bytes.u32_at(0x120)) else { + return Vec::new(); + }; + let (count, ofs) = (count as usize, ofs as usize); + let mut out = Vec::with_capacity(count.min(256)); + for i in 0..count { + let rec = match ofs.checked_add(i * 0xd4) { + Some(r) if r + 0xd4 <= bytes.len() => r, + _ => break, + }; + match read_m2_light(bytes, rec) { + Some(light) => out.push(light), + None => break, // unreachable given the guard above; defensive, not a panic + } + } + out +} + +/// The model's authored **portrait camera** — the MD20 camera the real 1.12 client renders every +/// unit-frame portrait through (VERIFIED, wow-re `system/ui/scratch/portrait-render.md` §4, +/// corrected verdict `aa186e79`): the bake at `0x524f60` selects camera `cameraLookup[0]` and +/// builds `lookAt(position, target, up-from-roll)` + the gxumath *diagonal-FOV* perspective +/// (`0x5c3cc0`, half-angle `(fov/2)/√(aspect²+1)`) at the portrait path's fixed `aspect = 4/3` — +/// net vertical half-angle `0.3·fov` with a 3:4 anamorphic squeeze. The framing is per-model +/// *authored data* — each artist calibrated camera 0 to their own geometry, which is why every +/// ref portrait (human, wolf, rabbit) crops identically tight. No engine-side yaw or +/// normalization exists on top of it. All fields raw WoW model space / radians. +#[derive(Debug, Clone, Copy)] +pub struct M2PortraitCamera { + /// FOV in radians (record `+0x04`), copied verbatim into the projection build (`0x70ebd0` → + /// `0x5c3cc0`) — a **diagonal** opening angle in the client's convention, NOT fovy (the + /// vertical half-angle at the portrait's 4/3 aspect is `0.3·fov`). + pub fov: f32, + /// Far clip plane (`+0x08`). + pub far_clip: f32, + /// Near clip plane (`+0x0c`). + pub near_clip: f32, + /// Camera eye: `position_base (+0x2c)` plus the position track's first key (`+0x10`) — portrait + /// cameras are static in practice (one key or none), so key 0 *is* the evaluated value. + pub position: [f32; 3], + /// Look target: `target_base (+0x54)` plus the target track's first key (`+0x38`). + pub target: [f32; 3], + /// Roll about the view axis (radians), the roll track's first key (`+0x60`); `0.0` when unkeyed. + pub roll: f32, +} + +/// Parse the M2's **portrait camera** straight from the raw bytes: cameras `count@0x124`/`ofs@0x128` +/// (stride `0x7c`), selected via `cameraLookup[0]` (`count@0x12c`/`ofs@0x130`, u16 entries) — exactly +/// the real client's selection (see [`M2PortraitCamera`]). `None` when the model carries no camera +/// table / no lookup slot 0 (some props and a few creatures — the caller falls back to heuristic +/// framing). Track key 0 reads are layout-safe for both linear and hermite tracks (a spline key's +/// value triple *starts* with the value). +pub fn parse_m2_portrait_camera(bytes: &[u8]) -> Option { + let cam_count = bytes.u32_at(0x124)? as usize; + let look_count = bytes.u32_at(0x12c)? as usize; + let look_ofs = bytes.u32_at(0x130)? as usize; + if look_count == 0 { + return None; + } + let idx = bytes.u16_at(look_ofs)? as usize; + if idx >= cam_count { + return None; // 0xffff "none" sentinel or a malformed lookup + } + parse_m2_camera(bytes, idx) +} + +/// One camera record by **table index** — the glue screens' `Model:SetCamera(idx)` path (the +/// create/select background scenes carry exactly one camera whose *lookup* slot holds the 0xffff +/// none sentinel, so the portrait selection above sees nothing; the client indexes the table +/// directly there). Same record read as the portrait camera. +pub fn parse_m2_camera(bytes: &[u8], index: usize) -> Option { + let cam_count = bytes.u32_at(0x124)? as usize; + let cam_ofs = bytes.u32_at(0x128)? as usize; + if index >= cam_count { + return None; + } + let rec = cam_ofs.checked_add(index.checked_mul(0x7c)?)?; + if rec.checked_add(0x7c)? > bytes.len() { + return None; + } + let base_plus_key = |base_ofs: usize, track_ofs: usize| -> Option<[f32; 3]> { + let b = [ + bytes.f32_at(base_ofs)?, + bytes.f32_at(base_ofs + 4)?, + bytes.f32_at(base_ofs + 8)?, + ]; + let k = track_first_vec3(bytes, track_ofs).unwrap_or([0.0; 3]); + Some([b[0] + k[0], b[1] + k[1], b[2] + k[2]]) + }; + Some(M2PortraitCamera { + fov: bytes.f32_at(rec + 0x04)?, + far_clip: bytes.f32_at(rec + 0x08)?, + near_clip: bytes.f32_at(rec + 0x0c)?, + position: base_plus_key(rec + 0x2c, rec + 0x10)?, + target: base_plus_key(rec + 0x54, rec + 0x38)?, + roll: track_first_f32(bytes, rec + 0x60).unwrap_or(0.0), + }) +} + +/// One SMOLight record at a bounds-checked `r` (the loop below already proved `r + 0x30 <= b.len()`): +/// type@0, useAtten@1, colour(BGRA)@4, position@8, intensity@0x14, **attenStart@0x28, attenEnd@0x2c**. +/// The runtime-consumed disk attenuation fields sit at the record's TAIL, not right after the +/// intensity — `+0x18..+0x28` hold four other floats (≈0, −0, −1, −0.5 on every vanilla light +/// audited). Verified two ways: wow-re `trace-forensics-abbey-interior-d3d` §4 fitted the reference's +/// fold window at exactly the `+0x28/+0x2c` values (NSabbey MOLT[10] 3.3333/4.7222; MOLT[40]/[41] +/// end 5.5556), and the abbey file carries those numbers at `+0x28/+0x2c` while `+0x18/+0x1c` read +/// ≈0/−0 (an earlier read of `+0x18/+0x1c` shipped zeros — dormant while nothing consumed them). +fn read_wmo_light(b: &[u8], r: usize) -> Option { + Some(WmoLight { + light_type: b.u8_at(r)?, + use_atten: b.u8_at(r + 1)? != 0, + // CImVector is BGRA in memory; recover RGB. + color: [ + f32::from(b.u8_at(r + 6)?) / 255.0, + f32::from(b.u8_at(r + 5)?) / 255.0, + f32::from(b.u8_at(r + 4)?) / 255.0, + ], + position: [b.f32_at(r + 8)?, b.f32_at(r + 12)?, b.f32_at(r + 16)?], + intensity: b.f32_at(r + 0x14)?, + attenuation_start: b.f32_at(r + 0x28)?, + attenuation_end: b.f32_at(r + 0x2c)?, + }) +} + +/// Parse a WMO root's **MOLT** lights straight from the chunk bytes (`benilla-wmo` doesn't expose +/// them). Scans the flat root chunk list for `MOLT` (magic stored reversed, `TLOM`) and reads each +/// 0x30-byte SMOLight (see [`read_wmo_light`]). NOTE: the chunk walk must NOT stop at a zero-size +/// chunk — `MOVV(0)`/`MOVB(0)` sit right before MOLT in vanilla roots (a bug there once hid MOLT +/// entirely). `root_bytes` is the raw WMO **root** file. +/// +/// Hand-rolled rather than `benilla_bytes::chunks()` (decision 0064): the two disagree on what a +/// record's bound is once a chunk's declared `size` is trusted past this walk. `chunks()` clamps a +/// chunk's *payload slice* to the buffer end and the record loop would then be bounds-checked against +/// that clamped slice; this scan instead advances `o` by the *unclamped* `data + size` (so a corrupt +/// size can push `o` past `b.len()`, which the `while` guard catches next iteration) and, for the +/// matched MOLT chunk, bounds each record against the **whole buffer** (`r + 0x30 <= b.len()`), not +/// against `size`. Both give identical records for well-formed input (the case this function is +/// proven against), but they are not the same algorithm, so this stays hand-rolled per the migration +/// guidance rather than being forced onto `chunks()`. +pub fn parse_wmo_lights(root_bytes: &[u8]) -> Vec { + let b = root_bytes; + let mut o = 0usize; + while o + 8 <= b.len() { + let Some(size) = b.u32_at(o + 4).map(|v| v as usize) else { + break; + }; + let data = o + 8; + if &b[o..o + 4] == b"TLOM" || &b[o..o + 4] == b"MOLT" { + let n = size / 0x30; + let mut out = Vec::with_capacity(n.min(256)); + for i in 0..n { + let Some(r) = data.checked_add(i * 0x30).filter(|&r| r + 0x30 <= b.len()) else { + break; + }; + match read_wmo_light(b, r) { + Some(light) => out.push(light), + None => break, // unreachable given the filter above; defensive, not a panic + } + } + return out; + } + o = match data.checked_add(size) { + Some(x) => x, + None => break, + }; + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a minimal buffer holding a 2-bone chain (root rotated 90° about +X, child 90° about + /// +Z locally) with the vanilla layout: bone table `count@0x34`/`ofs@0x38`, stride 0x6c, + /// rotation track @+0x28 (values `count@+0x14`/`ofs@+0x18`, keys `[x,y,z,w]` f32). + fn two_bone_chain() -> Vec { + let mut b = vec![0u8; 0x400]; + let put_u32 = + |b: &mut Vec, at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + let put_quat = |b: &mut Vec, at: usize, q: [f32; 4]| { + for (i, c) in q.iter().enumerate() { + b[at + 4 * i..at + 4 * i + 4].copy_from_slice(&c.to_le_bytes()); + } + }; + let bones = 0x100; + put_u32(&mut b, 0x34, 2); + put_u32(&mut b, 0x38, bones as u32); + let s = std::f32::consts::FRAC_1_SQRT_2; + // Bone 0 (root): parent −1, rotation 90° about +X. + put_u32(&mut b, bones + 8, 0xffff); // parent i16 −1 (submesh u16 slot unused) + put_u32(&mut b, bones + 0x28 + 0x14, 1); + put_u32(&mut b, bones + 0x28 + 0x18, 0x300); + put_quat(&mut b, 0x300, [s, 0.0, 0.0, s]); + // Bone 1: parent 0, local rotation 90° about +Z. + let r1 = bones + 0x6c; + put_u32(&mut b, r1 + 8, 0); // parent 0 + put_u32(&mut b, r1 + 0x28 + 0x14, 1); + put_u32(&mut b, r1 + 0x28 + 0x18, 0x340); + put_quat(&mut b, 0x340, [0.0, 0.0, s, s]); + b + } + + /// GOLDEN — the directional-direction law's rotation walk: `global = parent ∘ local`, applied + /// to +Z. Root alone: 90° about +X sends +Z → −Y. The child's local 90° about +Z does not move + /// its own +Z axis, so the chained result equals the root's — proving the composition order + /// (a local-first walk would differ for the mirrored case below). + #[test] + fn bone_z_axis_composes_parent_then_local() { + let b = two_bone_chain(); + let root = bone_z_axis(&b, 0); + assert!((root[0]).abs() < 1e-6 && (root[1] + 1.0).abs() < 1e-6 && root[2].abs() < 1e-6); + let child = bone_z_axis(&b, 1); + for (c, r) in child.iter().zip(root) { + assert!((c - r).abs() < 1e-6, "child {child:?} vs root {root:?}"); + } + // No rotation keys anywhere → identity +Z. + assert_eq!(bone_z_axis(&vec![0u8; 0x400], 0), [0.0, 0.0, 1.0]); + } +} diff --git a/crates/benilla-formats/src/models/tex_anim.rs b/crates/benilla-formats/src/models/tex_anim.rs new file mode 100644 index 00000000..16c6212c --- /dev/null +++ b/crates/benilla-formats/src/models/tex_anim.rs @@ -0,0 +1,143 @@ +//! The **texture-transform** (UV-animation) bake (decision 0130 phase 3): each M2 batch's texture +//! transform — batch `textureTransformComboIndex` (texUnit `+0x16`) → texAnimLookup (header `0xac`, +//! `0xffff`/out-of-range = none) → the record's **translation** track, baked to a loopable UV-offset +//! loop on the same clocks as the material-alpha bake (gseq wrap / seq-0 band). +//! +//! Translation only, deliberately: the full 7968-model 1.12 corpus sweep found 281 of 286 +//! transforms translation-keyed, and every rotation/scaling user is a UI/spell-effect/goober model +//! — **zero placed world doodads** — so the doodad lane (0130's scope) needs no rotation/scaling. +//! The baked offset is the track's raw x/y; how the real client maps it onto U/V (sign/axis, and +//! the application mechanism) is under RE in wow-re — the renderer applies the convention once, +//! when it consumes this. + +use benilla_m2::M2Model; + +use super::key_anim::{bake_track, KeyAnim}; + +/// One baked UV-offset loop, seconds — see [`KeyAnim`]. The texture-transform translation +/// channel's instantiation: values are the track's raw `(x, y)`. +pub type UvAnim = KeyAnim<[f32; 2]>; + +impl KeyAnim<[f32; 2]> { + /// The UV offset at `elapsed` seconds on the loop clock (`[0, 0]` — the additive identity — + /// for a defensively-empty loop the bake never emits). + pub fn sample(&self, elapsed: f32) -> [f32; 2] { + self.sample_or(elapsed, [0.0, 0.0]) + } +} + +/// `(x, y)` within a hair of zero — the offset that moves nothing. +fn is_zero(v: [f32; 2]) -> bool { + v[0].abs() < 1e-6 && v[1].abs() < 1e-6 +} + +/// Bake the batch's UV-offset loop, or `None` when the batch has no texture transform (the +/// overwhelmingly common case), the looked-up record is absent (`0xffff` sentinel falls out of the +/// bounds check), or its translation track never moves the UVs. +pub(super) fn bake_uv_anim( + model: &M2Model, + combo_index: u16, + seq0: Option<(u32, u32)>, +) -> Option { + let ti = *model.texture_transform_lookup.get(combo_index as usize)?; + let t = model.texture_transforms.get(ti as usize)?; + bake_track( + &t.translation, + &model.global_sequences, + seq0, + |v| [v[0], v[1]], + is_zero, + is_zero, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use benilla_m2::M2Vec3Track; + + fn track(gseq: u16, interp: u16, keys: &[(u32, [f32; 3])]) -> M2Vec3Track { + M2Vec3Track { + interp, + gseq, + keys: keys.to_vec(), + } + } + + fn bake(t: &M2Vec3Track, gseq: &[u32], seq0: Option<(u32, u32)>) -> Option { + super::super::key_anim::bake_track(t, gseq, seq0, |v| [v[0], v[1]], is_zero, is_zero) + } + + /// A keyless or never-moving track vanishes; a constant non-zero offset is kept period-0 (a + /// static UV shift the vertex data doesn't carry). + #[test] + fn bake_drops_the_identity_and_keeps_a_static_shift() { + assert_eq!(bake(&track(0xffff, 1, &[]), &[], None), None); + assert_eq!( + bake( + &track(0xffff, 1, &[(0, [0.0; 3]), (500, [0.0; 3])]), + &[], + None + ), + None + ); + let shift = bake(&track(0xffff, 1, &[(0, [0.25, 0.5, 9.0])]), &[], None).unwrap(); + assert_eq!(shift.period, 0.0); + assert_eq!(shift.sample(77.0), [0.25, 0.5]); // z discarded + } + + /// The fountain shape (gseq clock): a 2-key linear ramp wrapping the global-sequence duration — + /// the offset accumulates to a full texture period each loop. + #[test] + fn gseq_scroll_wraps_and_lerps() { + let a = bake( + &track(3, 1, &[(0, [0.0; 3]), (1333, [0.0, -1.0, 0.0])]), + &[1, 1, 1, 1333], + None, + ) + .unwrap(); + assert!((a.period - 1.333).abs() < 1e-6); + let mid = a.sample(1.333 / 2.0); + assert!((mid[1] + 0.5).abs() < 1e-3, "half-loop offset ≈ -0.5 V"); + assert!((a.sample(1.333 + 0.1)[1] - a.sample(0.1)[1]).abs() < 1e-4); + } + + /// The real Elwynn waterfall, straight off the client data: both batches carry seq-0-band + /// translation loops (V-scroll, −1.0 and −2.0 per loop — the 2× foam layer), wired batch → + /// texAnimLookup → transform. Guards the whole chain: the vec3-track parse (benilla-m2), the + /// 0xac lookup slot, the combo two-hop, and the band-clock bake. Skips without client data. + #[test] + fn elwynn_waterfall_bakes_two_v_scroll_loops() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file( + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Waterfall\\ElwynnTallWaterfall01.m2", + ) + .expect("read ElwynnTallWaterfall01.m2"); + let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + let anims: Vec<&UvAnim> = subs.iter().filter_map(|s| s.uv_anim.as_ref()).collect(); + assert_eq!(anims.len(), 2, "both waterfall batches carry UV loops"); + for a in &anims { + assert!(a.period > 1.0, "a real loop, not a constant"); + let (v0, v1) = (a.sample(0.0), a.sample(a.period * 0.5)); + assert!( + (v1[1] - v0[1]).abs() > 0.01, + "the V offset moves over the loop ({v0:?} vs {v1:?})" + ); + } + // The two layers scroll at different rates (the −1.0 vs −2.0 authoring). + let (e0, e1) = ( + anims[0].keys.last().unwrap().1[1], + anims[1].keys.last().unwrap().1[1], + ); + assert!( + (e0 - e1).abs() > 0.5, + "distinct layer rates survive the bake ({e0} vs {e1})" + ); + } +} diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs new file mode 100644 index 00000000..609fa544 --- /dev/null +++ b/crates/benilla-formats/src/models/types.rs @@ -0,0 +1,412 @@ +//! The shared render types every model source (M2 batches, WMO groups) produces: [`RenderSubmesh`] +//! (one per render batch) and the material/animation bits it carries — [`ModelBlend`], +//! [`CharSkinSlot`], [`BillboardKind`]/[`Billboard`], and the billboard glow-card pulse +//! ([`BoneScaleAnim`]). No parsing lives here — just the shapes [`m2_batches`](super::m2_batches) and +//! [`wmo`](super::wmo) both build. + +use super::mat_anim::{AlphaAnim, RgbAnim}; +use super::tex_anim::UvAnim; + +/// How a submesh blends — maps the model's blend mode to the renderer's alpha handling. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModelBlend { + /// Fully opaque (trunks, walls, floors). + Opaque, + /// Alpha-tested cutout (leaf canopies, fences, windows). + AlphaTest, + /// Alpha-blended / additive. + Blend, + /// Multiplicative — `out = src·dst` (GL `DST_COLOR/ZERO`; byte-verified wow-re + /// `m2-depth-blend-state`: M2 mode 5 → EGxBlend 4; WMO MOMT mode 4, direct index). Darkens/tints + /// what's already drawn; the blend equation reads NO alpha, so these batches cannot alpha-fade + /// (decision 0528). + Mod, + /// 2× multiplicative — `out = 2·src·dst` (GL `DST_COLOR/SRC_COLOR`; M2 mode 6 → EGxBlend 5; WMO + /// mode 5, direct index). Neutral at mid-grey, brightens above it: the weapon/armor ARMORREFLECT + /// sheen layer. Same no-alpha law as [`Self::Mod`] (decision 0528). + Mod2x, +} + +/// A character body texture the client supplies at runtime from the player's appearance — the M2 +/// texture record carries the *type* but no embedded filename, so the spawn site swaps a per-player +/// material onto the batches that carry the slot. +/// - `Body` (M2 texture type 1) — the composited body atlas: base skin + face/facial-hair/pelvis +/// overlays (decisions 0041 / 0044). +/// - `Hair` (M2 texture type 6) — the hair-mesh texture, a single CharSections `sectionType 3` +/// `TextureName[0]` BLP keyed by hairStyle + hairColor (decision 0045). +/// - `Object` (M2 texture type 2) — the runtime-bound **object skin**: on a weapon/shield M2 this is +/// the item's own texture (`ItemDisplayInfo`'s model-texture column, decision 0072); on a +/// character body model this same type is the cape slot. Which meaning applies depends on the +/// model wearing the batch, not on the type alone — the spawn site resolves it per-consumer. +/// - `SkinExtra` (M2 texture type 8) — the standalone **extra skin** BLP: CharSections `sectionType 0` +/// `TextureName[1]` keyed by skinColor (`…Skin00_NN_Extra.blp`), loaded plain (never composited — +/// the client's dedicated extra-texture loader is a bare TextureCreate). Only fur races author it: +/// the tauren body binds its head/leg fur batches to this slot instead of the body atlas. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CharSkinSlot { + Body, + Hair, + Object, + SkinExtra, +} + +/// How a billboarded bone tracks the camera (M2 bone billboard flags). The cylindrical arms keep +/// one authored axis and rebuild the in-plane pair from the camera — WHICH axis is kept is part +/// of the authored look (the frost-armor sheets ride a lock-Z bone so they stay upright while +/// spinning to face the viewer), so the three lock bits stay distinct here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BillboardKind { + /// Spherical (`0x08`) — faces the camera fully (glow cards, coronae). + Spherical, + /// Cylindrical lock-X (`0x10`) — keeps the bone's X axis (chains, ropes). + LockX, + /// Cylindrical lock-Y (`0x20`). + LockY, + /// Cylindrical lock-Z (`0x40`) — keeps model up (the questgiver `?` marker, the frost-armor + /// sheets): spins about the vertical to face the viewer. + LockZ, +} + +impl BillboardKind { + /// The kind an M2 bone's flag word authors, `None` for an ordinary bone — THE mapping, shared + /// by the batch splitter and the skeleton bake. `0x08` wins over a (never-seen) combined lock + /// bit, matching the palette replacement's own dispatch order. + pub fn from_bone_flags(bits: u32) -> Option { + if bits & 0x08 != 0 { + Some(Self::Spherical) + } else if bits & 0x10 != 0 { + Some(Self::LockX) + } else if bits & 0x20 != 0 { + Some(Self::LockY) + } else if bits & 0x40 != 0 { + Some(Self::LockZ) + } else { + None + } + } +} + +/// A bone **scale track driven by a global sequence** — the looping "breathe" pulse a glow card rides. +/// VERIFIED mechanism: the Lamppost glow card sits on a spherical-billboard bone whose scale track +/// (`interp=1`, `gseq=0`) oscillates `0.86 … 1.04` over the model's 1333 ms global sequence, so the +/// card grows/shrinks — read as a brightness pulse. The clock is the `m2_animate` Phase-B rule +/// (wow-5875-re): `gseq_time = elapsed_ms % duration`, then a linear lerp between adjacent keys. Only +/// **global-sequence** tracks are captured here (they loop with zero arming, the static-doodad case); +/// a `gseq == 0xffff` track needs the armed-animation machinery we don't run for static props. +#[derive(Debug, Clone)] +pub struct BoneScaleAnim { + /// Global-sequence loop length (ms). Sampling wraps at this period. + pub duration_ms: u32, + /// Linear interpolation between keys (`interp != 0`); `false` = step. + pub interp: bool, + /// `(timestamp_ms, scale_xyz)` keyframes, in file order (ascending time). + pub keys: Vec<(u32, [f32; 3])>, +} + +impl BoneScaleAnim { + /// Sample the scale at `time_ms` (wrapped into `[0, duration_ms)`), linearly interpolating between + /// the bracketing keys (or stepping when `interp == false`). Mirrors `m2_track_search` + the linear + /// sampler leg for the static-doodad / global-sequence case. + pub fn sample(&self, time_ms: u32) -> [f32; 3] { + let n = self.keys.len(); + if n == 0 { + return [1.0; 3]; + } + let t = time_ms % self.duration_ms.max(1); + if t <= self.keys[0].0 { + return self.keys[0].1; + } + // Last key whose timestamp is ≤ t (keys are time-ascending). + let mut k = 0; + while k + 1 < n && self.keys[k + 1].0 <= t { + k += 1; + } + if !self.interp || k + 1 >= n { + return self.keys[k].1; // step, or clamp past the final key + } + let (t0, v0) = self.keys[k]; + let (t1, v1) = self.keys[k + 1]; + let frac = if t1 > t0 { + (t - t0) as f32 / (t1 - t0) as f32 + } else { + 0.0 + }; + [ + v0[0] + (v1[0] - v0[0]) * frac, + v0[1] + (v1[1] - v0[1]) * frac, + v0[2] + (v1[2] - v0[2]) * frac, + ] + } +} + +/// A batch attached to an M2 **billboard bone**: the real client re-orients the bone to the camera +/// every frame (so the card is visible from all sides and tracks the viewer). `pivot` is the bone's +/// pivot in model space (WoW axes) — the point the card rotates about. +#[derive(Debug, Clone)] +pub struct Billboard { + pub pivot: [f32; 3], + /// The billboard bone's index in the model's bone table — the joint the card rides on an + /// animated host (the swinging lamp, the mount's lights). The pivot above is this bone's. + pub bone: u16, + pub kind: BillboardKind, + /// The billboard bone's global-sequence scale animation (the looping glow-card pulse), if any. + /// `None` for a static billboard bone. + pub scale_anim: Option, + /// The billboard bone's **translation** loops, one per sequence that keys it: `(anim id, the + /// keys inside that sequence's band, rebased to it)`. Same keyed-Vec3-loop shape as + /// [`BoneScaleAnim`] (values are offsets, WoW axes, not scales). The client's one-time load arm + /// plays anim **0** (the questgiver `?` marker's bob); the marker re-arms anim **190** — the + /// authored *raised* bob — while the unit shows an overhead name (wow-re `questgiver-marker.md` + /// Q4). A sequence whose band holds ≤1 key gets no entry; a global-sequence track gets none at + /// all. Only the **marker spawn site arms these** today; placed doodads' cards stay static-pivot + /// (the billboard half of the 0130 phase-4 bone-ride work owns that arming). + pub seq_translations: Vec<(u16, BoneScaleAnim)>, +} + +/// A WMO group render batch's **MOBA section** — the first `transBatchCount` batches of a group are +/// TRANS, then `intBatchCount` INT, then EXT (MOGP header counts at `+0x28/+0x2a/+0x2c`, right after +/// the byte-verified portal-ref span; cross-checked against NSabbey groups 1/3, whose per-class batch +/// index counts match the reference client's observed draws batch-for-batch). The class picks an +/// interior group's lighting law (wow-re `trace-forensics-abbey-interior-d3d` §2, observed on the +/// abbey at close range): **INT draws once, UNLIT — pure `tex × MOCV`, no exterior light, no +/// points**; **TRANS draws as a per-vertex MOCV-**alpha** lerp between the day/night-lit surface and +/// that unlit bake** (the reference does it as two passes; one pass computes the same product); +/// **EXT takes the ordinary exterior day/night law**. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WmoBatchClass { + Trans, + Int, + Ext, +} + +/// The batch's fog COLOUR policy — the per-blend fog table of the M2 batch state setter +/// (`0x70baf0`, table `DAT_811fc4` dispatched at `0x70bddf`; wow-re rf-weather-emission-timeline +/// ROUND 4): opaque/alpha-key/alpha fog toward the scene day-night colour; Add/AddAlpha toward +/// BLACK (an additive batch FADES with distance/veil instead of adding grey — the storm-veil +/// level-up fix); Mod toward WHITE; Mod2x toward GREY-128. Render flag 0x02 (`0x70bb24`) — or a +/// dead scene fog — disables fog outright. Discriminants are the shader encoding (Scene = 0 so +/// every non-M2 material defaults to the ordinary scene fog). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum FogPolicy { + #[default] + Scene = 0, + Black = 1, + White = 2, + Grey = 3, + Off = 4, +} + +/// One render batch: self-contained geometry + its material. +#[derive(Debug, Clone)] +pub struct RenderSubmesh { + pub positions: Vec<[f32; 3]>, + /// Authored per-vertex normals (model space, parallel to `positions`). WoW lights foliage with + /// these soft outward normals; recomputing flat per-face normals makes crossed canopy planes + /// catch the sun harshly. Empty only if the source had none (then the renderer recomputes). + pub normals: Vec<[f32; 3]>, + pub uvs: Vec<[f32; 2]>, + pub indices: Vec, + /// Texture (`.blp`) for this batch, if any. `None` for an unfilled creature skin slot — see + /// [`Self::skin_slot`], which the spawn site uses to fill it from the display's variation. + pub texture: Option, + /// The creature skin variation this batch draws from (`Some(0/1/2)` ⇒ `Monster1/2/3`), so a caller + /// that loaded the model without skins (the doodad/asset-loader path) can fill the texture at spawn + /// from `CreatureDisplayInfo`. `None` for a hardcoded/embedded texture (most M2s; all WMOs). + pub skin_slot: Option, + /// The batch's `skinSectionId` (geoset / mesh-part ID) — `group*100 + variant`. The character + /// compositor selects which geosets are visible by this (hide all but the chosen hair/facial/body + /// variants); `0` for a model with a single unnamed geoset (most creatures, all doodads/WMO). + pub geoset_id: u16, + /// A character body texture the client fills at runtime from the appearance ([`CharSkinSlot`]) — the + /// M2 record carries the texture *type* but no embedded path, so the spawn site swaps a per-player + /// material onto this batch. `None` for everything that carries its own texture (most M2s, all WMOs). + pub char_slot: Option, + pub blend: ModelBlend, + /// Render both faces (no backface culling)? From the M2 material's `0x04` flag (WMO: kept `true` + /// for now). When `false` the batch is single-sided like the real client — visible from one side. + pub two_sided: bool, + /// Per-vertex **skeletal skin binding** (decision 0019), parallel to `positions`: the 4 bone + /// indices each vertex is weighted to. They are **global M2 bone-array indices**, used directly as + /// joint indices (the skinned-entity path builds one joint entity per bone, in order). **Empty** + /// for WMO and for the static doodad/GameObject mesh (which never skins); populated by the M2 path. + pub joints: Vec<[u16; 4]>, + /// Per-vertex skin **weights** (normalised to sum 1.0), parallel to [`Self::joints`]. Empty when + /// `joints` is. + pub weights: Vec<[f32; 4]>, + /// Per-vertex **MOCV** colour (RGBA, alpha forced 1.0), parallel to `positions`. WMO groups carry + /// it — the baked shade. On EXTERIOR groups it modulates the lit factor (`tex × MOCV × (ambient + + /// sun·N·L)`); on INTERIOR groups (see [`Self::interior`]) it *is* the lighting — the reference runs + /// the FFP with the directional sun off, so the baked MOCV carries the room. **Empty** for M2 (no + /// MOCV) and for WMO groups lacking it → the renderer treats absent colour as white (no tint). + pub vertex_colors: Vec<[f32; 4]>, + /// This batch belongs to a WMO **interior** group. The real 1.12.1 client branches the per-group + /// render path on `groupFlags & 0x48` (binary-verified at `0x6b3f90`): the EXTERIOR/"exterior-lit" + /// bits `0x8 | 0x40`. With neither set (`== 0`) the group is a true interior, lit by its baked MOCV + /// with the directional sun **off**; with either set it takes the exterior (sun N·L) path. The exact + /// gx-layer combine is not yet byte-pinned (the gx FFP functions aren't decompiled), so the interior + /// combine + this branch direction are confirmed *visually*, not from the binary, for now. Always + /// `false` for M2. + pub interior: bool, + /// This batch is **unlit** — rendered without scene lighting so it shows at full texture + /// brightness. For **M2** it's the material `UNLIT (0x01)` flag (lamp glass, window panes, glow + /// cards). For **WMO** it's MOMT `UNLIT (0x01)` on an **exterior-group** batch only — the + /// exterior drawer keys lighting per material (`rs 0xE = !(flags & 1)`, unlit ⇒ `tex × white`), + /// while the interior drawer IGNORES the flag: lit/unlit there is dictated by the batch section + /// alone ([`Self::wmo_batch`]). Byte law: wow-re `wmo-lit-selector` §1.2/§1.3. + pub emissive: bool, + /// The MOMT **SIDN** (`0x10` — self-illum day/night) authored colour, RGB gamma bytes: the + /// windows-glow-at-night mechanism. The real client scales it per frame by the night fraction + /// (1 overnight, 0 all day, ramping 20:30→21:30 and 06:00→07:00) and binds it as the GL material + /// EMISSION, which the FFP adds **inside** the lit sum before the texture modulate — + /// `tex × (lit + sidn·night)`. A GL_LIGHTING term, so it contributes on LIT lanes only (dead on + /// an unlit INT batch, and under `UNLIT` where lighting is off). `None` for M2 and non-SIDN WMO + /// materials. Byte law: wow-re `wmo-interior-night-light` §3/§4, updater `0x6b4090`. + pub sidn: Option<[u8; 3]>, + /// MOMT **WINDOW** (`0x20`): in the **interior** drawer this batch swaps GL_LIGHT0 to the + /// brighter interior pair — ambient AND diffuse = the midpoint of the Direct and Ambient + /// day/night bands (ambient +16/255, saturating) — install → draw → restore, per batch. The + /// exterior drawer has no WINDOW machinery, so the flag only acts on interior-group batches. + /// This is the bright warm pane seen from inside a building (wow-re `wmo-interior-night-light` + /// §2, derivation `0x6d37e0`). `false` for M2. + pub window: bool, + /// This batch blends **additively** — its colour is *added* to the framebuffer rather than mixed + /// with it (M2 blend mode `3` NoAlphaAdd / `4` Add). Glow cards, coronae, magic effects. Rendering + /// these as ordinary alpha-blend lets the (cool, at night) background bleed *through* and mute the + /// warm glow; additive preserves the authored hue. `false` for everything else (incl. all WMO for now). + pub additive: bool, + /// M2 render-flag **0x10 — disable depth write** for this batch. The real 1.12 client writes depth + /// for *every* batch (opaque or transparent) **unless** this bit is set (VERIFIED, wow-re + /// `m2-depth-blend-state`); benilla otherwise blanket-disables depth-write for the whole transparent + /// pass, so a model's own transparent cards bleed through / flicker from some angles. `false` for WMO + /// (its depth state is the standard opaque/transparent pass). + pub no_depth_write: bool, + /// M2 render-flag **0x08 — disable depth test** for this batch (it draws over everything, ignoring + /// occlusion). Rare; `false` for WMO. + pub no_depth_test: bool, + /// The batch's fog COLOUR policy ([`FogPolicy`]) — `Scene` for WMO and every non-M2 batch. + pub fog_policy: FogPolicy, + /// Set when this batch sits on an M2 **billboard bone** — the renderer faces it to the camera each + /// frame (around the bone pivot). `None` for ordinary geometry and all WMO batches. + pub billboard: Option, + /// The batch's **animated material alpha** (decision 0130 phase 2): its time-varying colour-alpha + /// and/or transparency-weight loops, baked by [`mat_anim`](super::mat_anim). `None` when both + /// factors are static (baked/culled at build) — the overwhelming majority. The runtime multiplies + /// the sampled value into the instance's render alpha, per the verified combine (wow-re + /// `m2-alpha-combine-cull`). + pub alpha_anim: Option, + /// The batch's **UV-animation** loop (decision 0130 phase 3): the texture transform's + /// translation track baked by [`tex_anim`](super::tex_anim) — the raw `(x, y)` offset over the loop + /// clock. `None` for the ~98.6% of models with no texture transform, and for all WMO batches. + pub uv_anim: Option, + /// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell + /// effect's white-hot flash cooling to red): baked by [`mat_anim`](super::mat_anim). When + /// `Some`, the static vertex-colour tint is **skipped** for this batch (the two would + /// double-apply); the renderer carries the tint on the material instead, seeded at the first + /// key and animated where the lane runs material animation. `None` for constant/keyless tints + /// (the static vertex bake, decision 0029) — the overwhelming majority. + pub rgb_anim: Option, + /// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the + /// per-class lighting law of an interior group). `None` for every M2 batch. + pub wmo_batch: Option, +} + +/// A render batch that is a single flat **ground-plane quad**: four vertices, all at authored +/// z ≈ 0 (model space, WoW axes — the plane the model's owner stands on), forming an axis-aligned +/// rectangle in model XY, every vertex fully weighted to one bone. This is the authoring pattern +/// of ground-ring spell effects — Battle Shout's six crescents, the paladin auras' rings: flat +/// quads at the caster's feet that bones slide, spin, and scale outward, and that sloped terrain +/// buries per-pixel under a normal depth test (the batches author depth-test ON). The `groundscan` +/// sweep (2026-07-14, full 5875 chain) measures the population: 128 of the 139 flat batches under +/// `Spells\` are exactly this shape. The entity fx lane re-renders such parts as projected surface +/// decals (`benilla::ground_fx`) so they drape the terrain like the selection ring does. +#[derive(Debug, Clone, Copy)] +pub struct GroundQuad { + /// The M2 bone (global bone-array index) all four vertices skin to — the quad's animated + /// slide/spin/scale rides this joint. The consumer must pose corners through the joint's + /// matrix × the bone's inverse bindpose, exactly the skinned-vertex path. + pub bone: u16, + /// The corners in **model space (WoW axes, z = 0)**, in bilinear rect order: + /// `(min_x, min_y)`, `(max_x, min_y)`, `(min_x, max_y)`, `(max_x, max_y)`. + pub corners: [[f32; 3]; 4], + /// The authored UV at each corner, parallel to [`Self::corners`]. + pub uvs: [[f32; 2]; 4], +} + +/// Vertices further than this from the model's XY plane disqualify a batch as ground-plane flat +/// (the real population authors exactly 0.0; the epsilon only absorbs float noise). +const GROUND_FLAT_EPS: f32 = 0.01; + +impl RenderSubmesh { + /// Detect the flat **ground-plane quad** shape ([`GroundQuad`]) — `None` for everything that + /// isn't exactly it: more than four vertices, any vertex off the z≈0 plane, a multi-bone or + /// partial-weight skin, or corners that don't form an axis-aligned XY rectangle. Billboard + /// batches never match (their geometry is camera-facing by construction, not ground-lying). + /// Deliberately strict: the 11 `OTHER-FLAT` spell batches the population sweep found (Fist of + /// Justice, IceNuke impact) stay on the ordinary render path rather than stretch this shape. + pub fn ground_quad(&self) -> Option { + if self.billboard.is_some() + || self.positions.len() != 4 + || self.joints.len() != 4 + || self.weights.len() != 4 + || self.uvs.len() != 4 + { + return None; + } + if self.positions.iter().any(|p| p[2].abs() > GROUND_FLAT_EPS) { + return None; + } + // One bone, full weight, across all four vertices. + let bone = self.joints[0][0]; + if self + .joints + .iter() + .zip(&self.weights) + .any(|(j, w)| j[0] != bone || w[0] < 0.999) + { + return None; + } + // Axis-aligned rectangle: every vertex sits on a corner of the XY bounding box. + let (mut min, mut max) = ([f32::MAX; 2], [f32::MIN; 2]); + for p in &self.positions { + for a in 0..2 { + min[a] = min[a].min(p[a]); + max[a] = max[a].max(p[a]); + } + } + let eps = [ + (max[0] - min[0]) * 1e-3 + 1e-6, + (max[1] - min[1]) * 1e-3 + 1e-6, + ]; + if max[0] - min[0] <= eps[0] || max[1] - min[1] <= eps[1] { + return None; // degenerate (zero-area) quad + } + // Slot each vertex into its bilinear rect position; every slot must fill exactly once. + let mut corners = [[0.0_f32; 3]; 4]; + let mut uvs = [[0.0_f32; 2]; 4]; + let mut filled = [false; 4]; + for (p, uv) in self.positions.iter().zip(&self.uvs) { + let sx = if (p[0] - min[0]).abs() <= eps[0] { + 0 + } else if (p[0] - max[0]).abs() <= eps[0] { + 1 + } else { + return None; // an x between the extremes — not a rectangle corner + }; + let sy = if (p[1] - min[1]).abs() <= eps[1] { + 0 + } else if (p[1] - max[1]).abs() <= eps[1] { + 1 + } else { + return None; + }; + let slot = sy * 2 + sx; + if filled[slot] { + return None; // two vertices on one corner — degenerate + } + filled[slot] = true; + corners[slot] = *p; + uvs[slot] = *uv; + } + Some(GroundQuad { bone, corners, uvs }) + } +} diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs new file mode 100644 index 00000000..b6c46da8 --- /dev/null +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -0,0 +1,580 @@ +//! WMO **group**-file parsing: the MOGP header + sub-chunks (doodad/light refs, footprint faces) +//! and the render-batch → [`RenderSubmesh`] build against a parsed root. + +use std::io::Cursor; + +use anyhow::Result; +use benilla_wmo::{parse_wmo, ParsedWmo, WmoLiquid}; + +use super::{find_wmo_chunk, WmoRoot}; +use crate::liquid::{LiquidKind, LiquidMesh}; +use crate::models::{remap_submesh, ModelBlend, RenderSubmesh, WmoBatchClass}; + +/// The MLIQ grid vertex spacing (world yards between adjacent grid vertices) — the reference's +/// `[0x810cc0]` = `4.166666507720947` (`0x40855555`), VERIFIED wow-re. The liquid render dispatch +/// `0x6b62e0` branches on the type nibble: **water** → `0x6b6420`/`0x6b6630`, magma/slime → `0x6b68f0` +/// (wow-re `rf-mliq-liquid-uv-texgen.md`; the earlier `0x6b68f0` "water kernel" label was the magma +/// path). Either kernel places grid vertex `(i, j)` at `base + (i·STEP, j·STEP)`. +const MLIQ_CELL_STEP: f32 = f32::from_bits(0x4085_5555); + +/// Yards per water-texture repeat: the per-cell UV step is ¼, so one repeat spans `4·MLIQ_CELL_STEP` +/// ≈ 16.67 yd. We anchor each vertex's UV to its **model-space position** over this period so the +/// texture is one continuous field across all of a WMO's MLIQ surfaces — no per-surface seam. +/// +/// VERIFIED mechanism (wow-re `rf-mliq-liquid-uv-texgen.md`): the reference's per-vertex `st` DOES +/// reset per surface (raw `(i, j)`) — seamlessness comes from a shared **world-anchored** `GL_TEXTURE` +/// matrix that translates by the liquid batch's common bbox-min origin. Anchoring off a shared origin +/// is the fix; model space is our equivalent realization (identical within a single WMO placement, +/// which Stormwind is). DEFERRED to the exact form (a world-space texture matrix): the matrix's scale +/// — the true yards-per-repeat — is still INFERRED upstream (`[owner+0x94]`, unlocated), so `4·STEP` +/// stands as the size that preserves the prior ripple; fold the verified scale in when it lands. +const MLIQ_UV_PERIOD: f32 = 4.0 * MLIQ_CELL_STEP; + +/// The swatch-coord `V` fed to every WMO **water/ocean** vertex. WMO liquid verts carry flow bytes, +/// not a depth byte (unlike MCLQ), so there is no per-vertex bathymetry to ramp; the reference's WMO +/// water alpha comes from a separate shore LUT (`0xc7fbc0`, wow-re — not yet RE'd for this path). We +/// render the flat surface at the DEEP endpoint: opaque, deep-tinted canal water (the vanilla look), +/// which the zone's `Light.dbc` rows still colour per-zone. Tunable; the exact shore LUT is deferred. +const WMO_WATER_DEPTH_V: f32 = 1.0; + +/// The per-group fields the visibility pass reads from a **group file**'s MOGP header: the group +/// `flags` (offset `0x08`) and the group's slice into [`super::WmoPortals::refs`] +/// (`portal_ref_start` @ `0x24`, `portal_ref_count` @ `0x26`). VERIFIED layout (`grp+0x10` flags, +/// `+0x2c`/`+0x30` ref span seeded from MOGP `+0x24`/`+0x26`). `None` if the bytes aren't a +/// parseable group. +#[derive(Debug, Clone, Copy)] +pub struct WmoGroupHeader { + /// MOGP group flags. `& 0x8` = EXTERIOR (the outdoor bit the flood defers on); `& 0x48 == 0` = + /// a true interior group (see [`super::WmoGroupInfo`]). + pub flags: u32, + /// First index into [`super::WmoPortals::refs`] for this group's portals. + pub portal_ref_start: u16, + /// Number of portal refs for this group. + pub portal_ref_count: u16, + /// MOGP `uniqueID` @ `0x38` — this group's `WMOAreaTable.WMOGroupID` key (byte-verified + /// 2026-07-03: Chapel_000 interior → 478, matching its WMOAreaTable CAVE/ambience row). + /// `0` when the header is too short to carry it. + pub area_table_id: u32, + /// MOGP fog indices @ `0x30` — four indices into the root's [`super::WmoFog`] table, walked by + /// the client's interior-fog selector (`0x69de20`, wow-re `rf-weather-emission-timeline` + /// ROUND 5). Offset pinned empirically against the Goldshire inn (its groups carry `[1,0,0,0]` / + /// `[0,0,0,0]` here — valid indices into its 2-record MFOG — while `uniqueID @0x38` and the + /// no-liquid sentinel `@0x34` self-validate the layout; see `tests/wmo_fogs.rs`). + /// `[0; 4]` when the header is too short to carry them. + pub fog_indices: [u8; 4], +} + +/// Read one WMO **group** file's MOGP header fields used by the visibility pass (see +/// [`WmoGroupHeader`]). The MOGP super-chunk's payload begins with the 68-byte group header. +pub fn wmo_group_header(group_bytes: &[u8]) -> Option { + let mogp = find_wmo_chunk(group_bytes, b"PGOM")?; // MOGP + if mogp.len() < 0x28 { + return None; + } + let u32_at = |i: usize| { + (mogp.len() >= i + 4) + .then(|| u32::from_le_bytes([mogp[i], mogp[i + 1], mogp[i + 2], mogp[i + 3]])) + }; + Some(WmoGroupHeader { + flags: u32::from_le_bytes([mogp[8], mogp[9], mogp[10], mogp[11]]), + portal_ref_start: u16::from_le_bytes([mogp[0x24], mogp[0x25]]), + portal_ref_count: u16::from_le_bytes([mogp[0x26], mogp[0x27]]), + area_table_id: u32_at(0x38).unwrap_or(0), + fog_indices: if mogp.len() >= 0x34 { + [mogp[0x30], mogp[0x31], mogp[0x32], mogp[0x33]] + } else { + [0; 4] + }, + }) +} + +/// One interior WMO group's raw render triangles + per-vertex baked MOCV + per-triangle MOPY flags — +/// the face set the interior FOOTPRINT sample walks. Its live consumer is the **GameObject M2**: +/// the entity light node's env-update attach (`0x6717d0` → `0x69e4c0`, the entity twin of the +/// ADT-MDDF `0x6a8410`) down-rays the group RENDER mesh (`MOVT`/`MOVI`/`MOPY`) under the object and +/// bakes the hit's barycentric MOCV — floor-168/cap-96 — as its committed light (wow-re +/// `wmo-lit-selector.md`; trace-decisive on the abbey INNBENCH draws, diffuse max exactly 168). +/// `None` for a non-group, an exterior group, or a group without baked colours (never a footprint +/// source). (First built for decision 0290's MODD reading, deleted by 0306 when MODD props turned +/// out to use their own baked colour — resurrected now that the chain's true consumer is pinned.) +#[derive(Clone)] +pub struct FootprintTris { + /// Vertex positions, WMO model space (WoW axes). + pub positions: Vec<[f32; 3]>, + /// Triangle list into [`Self::positions`] (MOVI order — MOPY is parallel per triangle). + pub indices: Vec, + /// Per-vertex baked MOCV as RGB bytes, parallel to positions (bytes, not 0–1: the floor/cap + /// arithmetic is exact u8 fixed-point). + pub mocv: Vec<[u8; 3]>, + /// Per-TRIANGLE MOPY flags (`indices.len() / 3` entries). Bit `0x1` on the hit face selects the + /// day/night exterior colours as the object's base instead of the MOCV sample (§11's binary + /// selector). + pub mopy_flags: Vec, +} + +/// The footprint ray's per-face MOPY reject mask (wow-re `unit-light-combine-storm.md`, the BSP +/// face walk `0x6b92b0`/`0x6bc370`): a face with COLLISION `0x08` or VISITED `0x80` set is never a +/// footprint candidate. This is what keeps a floor authored as coplanar layers (a RENDER|DETAIL +/// sheet over a COLLISION sheet, observed on the Goldshire forge + inn) from being a per-position +/// coin toss: only the render layer survives, so the sample is deterministic and regional. +const FOOTPRINT_REJECT: u8 = 0x88; + +/// Parse one WMO **group**'s bytes into its footprint-sample face set ([`FootprintTris`]). +/// `None` unless the bytes are a group, the group is INTERIOR (`flags & 0x48 == 0`), and it carries +/// MOCV parallel to its positions. Faces matching [`FOOTPRINT_REJECT`] are dropped here (the +/// client rejects them per-ray inside the BSP walk; the set is static, so filtering at build is +/// equivalent and free). +pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return None; + }; + if group.flags & 0x48 != 0 || group.vertex_colors.len() != group.vertex_positions.len() { + return None; + } + let mut indices = Vec::new(); + let mut mopy_flags = Vec::new(); + for (ti, tri) in group.vertex_indices.chunks_exact(3).enumerate() { + let flags = group.material_info.get(ti).map_or(0, |m| m.flags); + if flags & FOOTPRINT_REJECT != 0 { + continue; + } + indices.extend_from_slice(tri); + mopy_flags.push(flags); + } + Some(FootprintTris { + positions: group + .vertex_positions + .iter() + .map(|v| [v.x, v.y, v.z]) + .collect(), + indices, + mocv: group + .vertex_colors + .iter() + .map(|c| [c.r, c.g, c.b]) + .collect(), + mopy_flags, + }) +} + +/// Find a sub-chunk of a group file's **MOGP** super-chunk by on-disk (reversed) magic. The MOGP +/// payload is the 0x44-byte group header followed by ordinary `[magic][size][data]` sub-chunks +/// (MOPY/MOVT/…/MODR/MOLR); this walks them. +fn find_mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mogp = find_wmo_chunk(group_bytes, b"PGOM")?; + let mut off = 0x44usize; // past the fixed group header + while off + 8 <= mogp.len() { + let size = u32::from_le_bytes([mogp[off + 4], mogp[off + 5], mogp[off + 6], mogp[off + 7]]) + as usize; + let data_start = off + 8; + let data_end = data_start.checked_add(size)?; + if data_end > mogp.len() { + break; + } + if &mogp[off..off + 4] == magic { + return Some(&mogp[data_start..data_end]); + } + off = data_end; + } + None +} + +/// A group's **MODR** doodad references: the MODD indices this group instantiates. This is the +/// faithful doodad→group OWNERSHIP relation — the real client creates a WMO doodad per (MODD index, +/// referencing group) from exactly this list (`0x695aa0` loops `group+0xe8`/count `+0x144`), and the +/// owning group's interior class + MOLR light list decide the doodad's lighting (wow-re +/// `trace-forensics-abbey-interior-d3d` §1.1/§4). Empty when the group has no MODR (no doodads). +pub fn wmo_group_doodad_refs(group_bytes: &[u8]) -> Vec { + find_mogp_subchunk(group_bytes, b"RDOM") + .map(|c| { + c.chunks_exact(2) + .map(|r| u16::from_le_bytes([r[0], r[1]])) + .collect() + }) + .unwrap_or_default() +} + +/// A group's **MOLR** light references: the MOLT indices whose lights this group folds into its own +/// doodads' committed light (`0x695c00` builds the per-group active-light list from exactly this; a +/// group WITHOUT an MOLR chunk provably lights its doodads with nothing — the abbey's group 3 has +/// none, and the director's stand receives zero point light, its own flame 3.7 yd away included). +/// Empty when the group has no MOLR. +pub fn wmo_group_light_refs(group_bytes: &[u8]) -> Vec { + find_mogp_subchunk(group_bytes, b"RLOM") + .map(|c| { + c.chunks_exact(2) + .map(|r| u16::from_le_bytes([r[0], r[1]])) + .collect() + }) + .unwrap_or_default() +} + +/// Build one WMO **group**'s MLIQ liquid surface as a [`LiquidMesh`] in **WMO model space** (WoW +/// axes, yards) — the flat animated water/lava/slime the group embeds (Stormwind's canals, the +/// Ironforge lava, dungeon pools). `None` when the group carries no `MLIQ`, or the grid has no wet +/// tiles / an unmapped type. The placement transform lifts the mesh into the world at spawn. +/// +/// Faithful model (VERIFIED wow-re `rf-water-liquid-type-texture-material.md` + `rf-mliq-liquid-uv-texgen.md`; +/// the render dispatch is `0x6b62e0`, water kernels `0x6b6420`/`0x6b6630`): grid vertex `(i, j)` sits +/// at `base + (i·STEP, j·STEP, heights[j·xverts + i])`; a +/// tile renders iff its flag low nibble `!= 0xf` (`0xf` = hole); the type is the group's +/// `groupLiquid` override when set, else the per-tile nibble, mapped to a texture via +/// [`LiquidKind::from_nibble`]. The whole surface takes ONE resolved kind (the reference resolves a +/// single type per surface — `0x6ba970`), with per-tile hole membership. +pub fn wmo_group_liquid_mesh(group_bytes: &[u8]) -> Option { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return None; + }; + let lq = group.liquid?; + build_wmo_liquid_mesh(&lq, group.group_liquid) +} + +/// Build the [`LiquidMesh`] for a parsed [`WmoLiquid`] grid + its group's `groupLiquid` override. +/// Split out so a unit test can exercise it without synthesising a full group file. +fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option { + let (xv, yv, xt, yt) = ( + lq.xverts as usize, + lq.yverts as usize, + lq.xtiles as usize, + lq.ytiles as usize, + ); + // The grid must be tile-consistent (`xtiles = xverts − 1`) and carry its full vertex/tile arrays. + if xv < 2 || yv < 2 || xt + 1 != xv || yt + 1 != yv { + return None; + } + if lq.heights.len() < xv * yv || lq.tile_flags.len() < xt * yt { + return None; + } + + // Resolve ONE kind for the whole surface: the `groupLiquid` override when set (`!= 0xf`), else + // the first wet tile's nibble (`0x6ba970` returns the first non-hole nibble). Unmapped ⇒ None. + let type_nibble = if group_liquid != 0xf { + (group_liquid & 0xf) as u8 + } else { + let first_wet = lq.tile_flags.iter().map(|&f| f & 0xf).find(|&n| n != 0xf)?; + first_wet + }; + let kind = LiquidKind::from_nibble(type_nibble)?; + let depth_v = if kind.is_fullbright() { + 0.0 + } else { + WMO_WATER_DEPTH_V + }; + + // Grid vertices (row-major `j·xverts + i`), WMO model space. Every vertex is emitted (dry-tile + // corners included, unreferenced) so the tile indices line up; the AABB stays tight because WMO + // liquid heights are real, flat values (no MCLQ FLT_MAX sentinel). + let mut positions = Vec::with_capacity(xv * yv); + let mut uvs = Vec::with_capacity(xv * yv); + let mut depths = Vec::with_capacity(xv * yv); + for j in 0..yv { + for i in 0..xv { + let mx = lq.base[0] + i as f32 * MLIQ_CELL_STEP; + let my = lq.base[1] + j as f32 * MLIQ_CELL_STEP; + let h = lq.heights[j * xv + i]; + positions.push([mx, my, if h.is_finite() { h } else { lq.base[2] }]); + // UV from the vertex's MODEL-SPACE position, NOT its per-surface tile index. A per-surface + // `(i·¼, j·¼)` resets to 0 at each surface's origin; a surface's tile count is arbitrary + // (Stormwind g099 is 11 tiles = 2.75 repeats), so that reset lands mid-texture and SEAMS at + // every surface boundary. Dividing the shared model-space position by the repeat period + // ties all of a WMO's MLIQ surfaces to ONE continuous UV field, so grid-aligned neighbours + // tile seamlessly. (Same per-cell ¼ step as before — `i·STEP/period = i·¼` — plus the + // `base/period` offset that couples the surfaces.) + uvs.push([mx / MLIQ_UV_PERIOD, my / MLIQ_UV_PERIOD]); + depths.push(depth_v); + } + } + + // 2 tris per wet tile (low nibble `!= 0xf`). Winding is irrelevant (drawn two-sided). + let mut indices = Vec::with_capacity(xt * yt * 6); + for ty in 0..yt { + for tx in 0..xt { + if lq.tile_flags[ty * xt + tx] & 0xf == 0xf { + continue; // hole — no liquid on this tile + } + let tl = (ty * xv + tx) as u32; + let tr = tl + 1; + let bl = ((ty + 1) * xv + tx) as u32; + let br = bl + 1; + indices.extend_from_slice(&[tl, bl, br, tl, br, tr]); + } + } + if indices.is_empty() { + return None; // grid present but every tile a hole + } + Some(LiquidMesh { + positions, + uvs, + depths, + indices, + // The `0x6ba970`-resolved nibble (groupLiquid override / first wet tile) IS the WMO + // surface's sound class — the ambient-loop key (see `LiquidMesh::sound_nibble`). + sound_nibble: type_nibble, + kind, + }) +} + +/// Build the render submeshes for one WMO **group** (its bytes), resolving textures/materials against +/// `root`. Empty if the bytes aren't a parseable group. The bytes-in entry point for the Bevy +/// `AssetLoader`; [`super::load_wmo`] is the chain-reading wrapper. +pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result> { + let ParsedWmo::Root(root) = &root.parsed else { + anyhow::bail!("WmoRoot is not a root"); + }; + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return Ok(Vec::new()); + }; + // The MOGP batch-section counts (`+0x28/+0x2a/+0x2c`, after the byte-verified portal-ref span): + // MOBA batches are laid out TRANS, then INT, then EXT — the class decides an interior group's + // per-batch lighting law (see [`WmoBatchClass`]). Absent/short header ⇒ everything reads EXT + // (the conservative exterior law). + let (trans_n, int_n) = find_wmo_chunk(group_bytes, b"PGOM") + .filter(|m| m.len() >= 0x2c) + .map_or((0usize, 0usize), |m| { + ( + u16::from_le_bytes([m[0x28], m[0x29]]) as usize, + u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize, + ) + }); + let mut out = Vec::new(); + { + // WMO groups carry MONR normals + MOCV colours (parallel to positions); fall back when absent. + let has_normals = group.vertex_normals.len() == group.vertex_positions.len(); + let has_colors = group.vertex_colors.len() == group.vertex_positions.len(); + // Interior iff neither the EXTERIOR (0x8) nor exterior-lit (0x40) bit is set — the same + // `groupFlags & 0x48 == 0` test the real client branches on (RenderSubmesh::interior). + let interior = (group.flags & 0x48) == 0; + let vertex = |g: u32| { + let p = &group.vertex_positions[g as usize]; + let n = group + .vertex_normals + .get(g as usize) + .map_or([0.0, 1.0, 0.0], |n| [n.x, n.y, n.z]); + let uv = group + .texture_coords + .get(g as usize) + .map_or([0.0, 0.0], |t| [t.u, t.v]); + // MOCV is BGRA; RGB is the per-vertex shade multiplier, and the REAL alpha rides along — + // on an interior TRANS batch it is the lit↔bake lerp factor (the FixColorVertexAlpha + // product); every other batch class forces it opaque below (alpha encodes blend/unused + // there — the exterior alpha→0xFF fixup). Absent → white. + let c = group + .vertex_colors + .get(g as usize) + .map_or([1.0, 1.0, 1.0, 1.0], |c| { + [ + c.r as f32 / 255.0, + c.g as f32 / 255.0, + c.b as f32 / 255.0, + c.a as f32 / 255.0, + ] + }); + ([p.x, p.y, p.z], n, uv, c) + }; + for (bi, batch) in group.render_batches.iter().enumerate() { + let class = if bi < trans_n { + WmoBatchClass::Trans + } else if bi < trans_n + int_n { + WmoBatchClass::Int + } else { + WmoBatchClass::Ext + }; + let start = batch.start_index as usize; + let global_indices: Vec = group + .vertex_indices + .get(start..start + batch.count as usize) + .unwrap_or(&[]) + .iter() + .map(|&i| u32::from(i)) + .filter(|&g| (g as usize) < group.vertex_positions.len()) + .collect(); + if global_indices.is_empty() { + continue; + } + let material = root.materials.get(batch.material_id as usize); + let texture = material + .map(|m| m.get_texture1_index(&root.texture_offset_index_map)) + .and_then(|i| root.textures.get(i as usize)) + .cloned() + .filter(|s| !s.is_empty()); + let blend = match material.map(|m| m.blend_mode) { + Some(0) | None => ModelBlend::Opaque, + Some(1) => ModelBlend::AlphaTest, + // MOMT.blendMode is a DIRECT EGxBlend index (no M2-style remap — wow-re + // `wmo-batch-blend-depth-state`): 4 = Mod (DST_COLOR/ZERO), 5 = Mod2x + // (DST_COLOR/SRC_COLOR). Decision 0528. + Some(4) => ModelBlend::Mod, + Some(5) => ModelBlend::Mod2x, + Some(_) => ModelBlend::Blend, + }; + // The MOMT window/glass flags, each its own mechanism (wow-re `wmo-lit-selector` §1, + // `wmo-interior-night-light` §2/§4 — byte-verified): + // UNLIT (0x01) — the EXTERIOR drawer turns lighting off per material → `tex × white` + // fullbright (the inn's always-lit outside panes, MM_ELWYNN_WND_EXT). + // The INTERIOR drawer ignores it — lit/unlit there is dictated by the + // batch section alone, so `emissive` stays false on interior groups. + // SIDN (0x10) — the authored night-glow emissive colour, ramped by the night + // fraction and added inside the lit sum (the abbey's stained glass + // glowing after dusk). Lit lanes only; carried with its colour. + // WINDOW (0x20) — interior-drawer batches swap to the brighter midpoint light (the + // warm pane seen from inside — MM_ELWYNN_WND_INT, MM_STAINED_01). + let flags = material.map_or(0, |m| m.flags); + let emissive = !interior && flags & 0x01 != 0; + // WMO: keep two-sided for now (the MOMT two-sided bit is ambiguous between sources, and + // single-siding a wall wrong makes it vanish). Buildings aren't the canopy issue. + // WMO has no skeleton, so the per-vertex skin (`_globals`) is unused. + let (mut submesh, _globals) = remap_submesh( + global_indices.into_iter(), + vertex, + texture, + blend, + true, + interior, + emissive, + ); + submesh.wmo_batch = Some(class); + submesh.sidn = (flags & 0x10 != 0) + .then(|| material.map(|m| m.sidn_rgb)) + .flatten(); + submesh.window = flags & 0x20 != 0; + if !has_normals { + submesh.normals.clear(); // no MONR → let the renderer recompute flat normals + } + if !has_colors { + submesh.vertex_colors.clear(); // no MOCV → empty (white, no tint) + } + // The MOCV **alpha** is per-vertex LIGHTING data on interior batches: a TRANS batch + // reads it as the lit↔bake lerp factor, an INT batch as the ×4 self-illumination mask + // (the reference's interior pixel shader `tex·MOCV·(1 + 4·a)` — the inn's fireplace + // surround bakes α≈100, the portal-seam whitening 255). Exterior batches encode + // blend/unused state there — force those opaque so bevy's vertex-colour fold can't + // perturb their alpha semantics (the shader un-folds coverage for the interior lanes). + if !(interior && matches!(class, WmoBatchClass::Trans | WmoBatchClass::Int)) { + for c in &mut submesh.vertex_colors { + c[3] = 1.0; + } + } + out.push(submesh); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::super::test_bytes::chunk; + use super::*; + + #[test] + fn builds_wmo_water_mesh_skipping_holes() { + // A 3×2-vertex grid (2×1 tiles): left tile wet (lake_a, nibble 4), right tile a hole (0xf). + let lq = WmoLiquid { + xverts: 3, + yverts: 2, + xtiles: 2, + ytiles: 1, + base: [100.0, 200.0, -3.0], + material_id: 0, + heights: vec![-3.0; 6], + tile_flags: vec![0x44, 0x0f], // wet (type 4 + fishable), hole + }; + let m = build_wmo_liquid_mesh(&lq, 0xf).expect("a wet mesh"); + assert_eq!(m.kind, LiquidKind::Still); + assert_eq!(m.positions.len(), 6); // full 3×2 grid emitted + assert_eq!(m.indices.len(), 6); // one wet tile → 2 tris → 6 indices + // Grid vertex (i=1, j=0) sits at base + (1·STEP, 0, height). + assert!((m.positions[1][0] - (100.0 + MLIQ_CELL_STEP)).abs() < 1e-3); + assert_eq!(m.positions[0], [100.0, 200.0, -3.0]); + // Water is rendered at the deep (opaque) V. + assert!(m.depths.iter().all(|&v| v == WMO_WATER_DEPTH_V)); + // The wet tile is the LEFT one (indices reference the left cell's 4 corners: 0,1,3,4). + assert!(m.indices.iter().all(|&i| [0u32, 1, 3, 4].contains(&i))); + } + + /// Two grid-aligned WMO liquid surfaces (a canal split across groups) must share ONE continuous + /// UV field, so the animated water texture doesn't seam at the boundary. Surface B begins exactly + /// where A's last column sits (base offset by 2·STEP in X), so the vertex they share in model space + /// must get the SAME UV in both — the property the old per-surface `(i·¼, j·¼)` reset broke (each + /// surface restarted at UV 0, and an arbitrary tile count put that restart mid-texture → the seam). + #[test] + fn adjacent_surfaces_share_a_continuous_uv_field() { + let base = [100.0, 200.0, -3.0]; + let a = WmoLiquid { + xverts: 3, + yverts: 2, + xtiles: 2, + ytiles: 1, + base, + material_id: 0, + heights: vec![-3.0; 6], + tile_flags: vec![0x44, 0x44], // both tiles wet + }; + // B's origin column coincides with A's rightmost column (one 2-tile span east). + let b = WmoLiquid { + xverts: 3, + yverts: 2, + xtiles: 2, + ytiles: 1, + base: [base[0] + 2.0 * MLIQ_CELL_STEP, base[1], base[2]], + material_id: 0, + heights: vec![-3.0; 6], + tile_flags: vec![0x44, 0x44], + }; + let ma = build_wmo_liquid_mesh(&a, 0xf).unwrap(); + let mb = build_wmo_liquid_mesh(&b, 0xf).unwrap(); + // A's rightmost-bottom vertex (i=2, j=0 → index 2) and B's leftmost-bottom (i=0, j=0 → index 0) + // are the same model-space point; their UVs must match (no seam). + let (ua, ub) = (ma.uvs[2], mb.uvs[0]); + assert!( + (ua[0] - ub[0]).abs() < 1e-4 && (ua[1] - ub[1]).abs() < 1e-4, + "seam: shared-vertex UVs differ A={ua:?} B={ub:?}" + ); + // The field is position-derived: UV.x still steps exactly ¼ per cell (same ripple size as before). + assert!((ma.uvs[1][0] - ma.uvs[0][0] - 0.25).abs() < 1e-4); + } + + #[test] + fn wmo_liquid_type_override_and_all_holes() { + // groupLiquid override = 2 (magma) forces the kind regardless of the wet tile's nibble. + let lq = WmoLiquid { + xverts: 2, + yverts: 2, + xtiles: 1, + ytiles: 1, + base: [0.0, 0.0, 0.0], + material_id: 0, + heights: vec![0.0; 4], + tile_flags: vec![0x40], // nibble 0 (would be Still), but the override wins + }; + let m = build_wmo_liquid_mesh(&lq, 2).expect("magma mesh"); + assert_eq!(m.kind, LiquidKind::Magma); + assert!(m.depths.iter().all(|&v| v == 0.0)); // fullbright ⇒ depth V unused + + // A grid where every tile is a hole → no mesh. + let all_holes = WmoLiquid { + tile_flags: vec![0x0f], + ..lq + }; + assert!(build_wmo_liquid_mesh(&all_holes, 0xf).is_none()); + } + + #[test] + fn reads_group_header_flags_and_portal_ref_span() { + // A 68-byte MOGP header: flags @0x08, portal_ref_start @0x24, portal_ref_count @0x26. + let mut hdr = vec![0u8; 68]; + hdr[8..12].copy_from_slice(&0x8u32.to_le_bytes()); // EXTERIOR + hdr[0x24..0x26].copy_from_slice(&5u16.to_le_bytes()); + hdr[0x26..0x28].copy_from_slice(&3u16.to_le_bytes()); + hdr[0x30..0x34].copy_from_slice(&[2, 0, 0, 0]); // fog indices + // Group file: MVER then MOGP(super-chunk = header + (no) sub-chunks). + let mut group = Vec::new(); + group.extend(chunk(b"REVM", &17u32.to_le_bytes())); + group.extend(chunk(b"PGOM", &hdr)); + + let h = wmo_group_header(&group).expect("group header"); + assert_eq!(h.flags, 0x8); + assert_eq!(h.portal_ref_start, 5); + assert_eq!(h.portal_ref_count, 3); + assert_eq!(h.fog_indices, [2, 0, 0, 0]); + } +} diff --git a/crates/benilla-formats/src/models/wmo/mod.rs b/crates/benilla-formats/src/models/wmo/mod.rs new file mode 100644 index 00000000..3d4c8e07 --- /dev/null +++ b/crates/benilla-formats/src/models/wmo/mod.rs @@ -0,0 +1,76 @@ +//! WMO (building) parsing, split along the file boundary the format itself draws: [`root`] parses +//! the root file's shared tables (materials, doodads, group infos, portals, fogs), [`group`] parses +//! a group file (header, refs, render batches) against a parsed root. This module keeps the shared +//! chunk-finder and the chain-reading entry point. + +mod group; +mod root; + +pub use group::*; +pub use root::*; + +use anyhow::{Context, Result}; + +use crate::Chain; + +/// Load a WMO (building) from the chain: its root + every group file, flattened into submeshes +/// (one per group render batch). `raw_path` is the MWMO root path (`.wmo`). +pub fn load_wmo(chain: &mut Chain, raw_path: &str) -> Result> { + let root_path = raw_path.to_ascii_lowercase(); + let bytes = chain + .read_file(&root_path) + .with_context(|| format!("reading WMO {root_path}"))?; + let root = parse_wmo_root(&bytes).with_context(|| format!("parsing WMO {root_path}"))?; + let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); + let mut out = Vec::new(); + for gi in 0..root.group_count() { + let group_path = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&group_path) else { + continue; + }; + out.extend(wmo_group_submeshes(&gbytes, &root)?); + } + Ok(out) +} + +/// Find a top-level WMO chunk's data slice by its **on-disk (reversed) magic** — WMO/ADT store the +/// FourCC reversed (`MODN` → `NDOM`). Top-level root chunks are laid out flat from byte 0 +/// (`[magic:4][size:u32 LE][data:size]`), so a linear walk locates any of them. +pub(crate) fn find_wmo_chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mut off = 0usize; + while off + 8 <= bytes.len() { + let size = u32::from_le_bytes([ + bytes[off + 4], + bytes[off + 5], + bytes[off + 6], + bytes[off + 7], + ]) as usize; + let data_start = off + 8; + let data_end = data_start.checked_add(size)?; + if data_end > bytes.len() { + break; // truncated chunk — give up + } + if &bytes[off..off + 4] == magic { + return Some(&bytes[data_start..data_end]); + } + off = data_end; + } + None +} + +/// Synthetic-chunk builders shared by the [`root`]/[`group`] test modules. +#[cfg(test)] +mod test_bytes { + /// Build one top-level WMO chunk: the FourCC stored **reversed**, a `u32 LE` size, then `data`. + pub(super) fn chunk(reversed_magic: &[u8; 4], data: &[u8]) -> Vec { + let mut out = Vec::with_capacity(8 + data.len()); + out.extend_from_slice(reversed_magic); + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(data); + out + } + + pub(super) fn f3(v: [f32; 3]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() + } +} diff --git a/crates/benilla-formats/src/models/wmo/root.rs b/crates/benilla-formats/src/models/wmo/root.rs new file mode 100644 index 00000000..6f6bb419 --- /dev/null +++ b/crates/benilla-formats/src/models/wmo/root.rs @@ -0,0 +1,445 @@ +//! WMO **root**-file parsing: the shared tables a group resolves against — doodads (MODS/MODN/MODD), +//! group infos (MOGI), the portal graph (MOPV/MOPT/MOPR), fog records (MFOG) — plus the root id. + +use std::io::Cursor; + +use anyhow::Result; +use benilla_wmo::{parse_wmo, ParsedWmo}; + +use super::find_wmo_chunk; + +/// A placed WMO **doodad** (an MODD entry): an M2 prop the WMO root positions *inside* itself — +/// candle stands, banners, braziers, chandeliers — as opposed to geometry baked into the WMO mesh +/// (pews, walls). Resolved + in WMO model space (WoW axes, Z up); compose with the WMO instance's +/// world transform at spawn (`benilla_assets::coords::wmo_doodad_local`). +#[derive(Debug, Clone)] +pub struct WmoDoodad { + /// M2 path (MMDX-style, `.mdx`/`.mdl`); `m2_url`/`load_m2_mesh` normalize it to `.m2`. Empty ⇒ + /// the MODN name offset didn't resolve (skipped at spawn). + pub model: String, + /// Position in WMO model space (WoW axes). + pub position: [f32; 3], + /// Orientation quaternion `(x, y, z, w)` in WMO model space. + pub orientation: [f32; 4], + /// Uniform scale. + pub scale: f32, + /// The MODD entry's **baked colour** (`+0x24`, BGRA on disk → stored `[r, g, b, a]`): the WMO + /// baker's authored local-lighting product for this placement. For an INTERIOR-group doodad this + /// IS the slot-0 base light — the real client copies it at doodad create into the diffuse word + /// (floor-raised to HSV value 112) and the ambient word (capped to max 96), never running any + /// footprint/attach sample (byte-verified `0x694e90` create → `6950de call 0x6a77e0(&MODD.colour, + /// …, 0x70, …, 0x60)`; wow-re `trace-forensics-abbey-interior-d3d` §1.1 — the abbey stands' + /// decoded diffuse words equal these bytes verbatim). + pub color: [u8; 4], +} + +/// A WMO **doodad set** (an MODS entry): a contiguous range `[start, start+count)` into the +/// [`WmoRoot::doodads`] list. Set 0 is the global set (always shown); a placement's MODF +/// `doodad_set` field selects one *additional* set to show by index. +#[derive(Debug, Clone, Copy)] +pub struct WmoDoodadSet { + pub start: u32, + pub count: u32, +} + +/// Per-WMO-**group** info from the root's MOGI chunk: the group's interior/exterior class and its +/// axis-aligned bounding box (WMO model space, WoW axes). A placed MODD doodad's class comes from +/// its OWNING group (the MODR reference that instantiates it — see [`super::wmo_group_doodad_refs`]); +/// moving ENTITIES classify by the position-cast face down-ray instead (the client's own indoor +/// bit — never these boxes, whose authored bounds can float above their own floor polys). The box +/// is a loose AABB approximation of the group's true BSP/portal volume. +#[derive(Debug, Clone, Copy)] +pub struct WmoGroupInfo { + /// `groupFlags & 0x48 == 0` — neither EXTERIOR (`0x8`) nor exterior-lit (`0x40`): a true interior + /// group. The same test the client branches on at `0x6b3f90` and that [`crate::models::RenderSubmesh::interior`] uses. + pub interior: bool, + /// Group bounding-box min, WMO model space (WoW axes). + pub bbox_min: [f32; 3], + /// Group bounding-box max, WMO model space (WoW axes). + pub bbox_max: [f32; 3], +} + +/// A parsed WMO **root**: the shared material/texture tables the group submeshes resolve against, +/// plus the resolved doodad tables (sets + placed props). Build with [`parse_wmo_root`], then feed +/// each group file's bytes to [`super::wmo_group_submeshes`]. The Bevy WMO `AssetLoader` +/// orchestrates the root + group reads; the chain-reading equivalent is [`super::load_wmo`]. +pub struct WmoRoot { + /// The raw parse — the group-side batch build reads the material/texture tables through it. + pub(super) parsed: ParsedWmo, + doodads: Vec, + doodad_sets: Vec, + group_infos: Vec, + portals: WmoPortals, + fogs: Vec, +} + +impl WmoRoot { + /// Number of group files (`_NNN.wmo`) this root references. + pub fn group_count(&self) -> u32 { + match &self.parsed { + ParsedWmo::Root(r) => r.n_groups, + _ => 0, + } + } + + /// The root's placed doodads (MODD), names resolved, indexable by the ranges in [`Self::doodad_sets`]. + pub fn doodads(&self) -> &[WmoDoodad] { + &self.doodads + } + + /// The root's doodad sets (MODS) — ranges into [`Self::doodads`]. + pub fn doodad_sets(&self) -> &[WmoDoodadSet] { + &self.doodad_sets + } + + /// The root's per-group info (MOGI) — interior flag + bounding box, for classifying which placed + /// doodads are interior (see [`WmoGroupInfo`]). + pub fn group_infos(&self) -> &[WmoGroupInfo] { + &self.group_infos + } + + /// The root's portal graph (MOPV/MOPT/MOPR) — the data that drives per-group visibility culling + /// (see [`WmoPortals`]). Empty for WMOs with no portals (most single-group props). + pub fn portals(&self) -> &WmoPortals { + &self.portals + } + + /// The root's fog records (MFOG) — indexed by a group header's [`super::WmoGroupHeader::fog_indices`] + /// (see [`WmoFog`] for what's pinned vs pending about their consumption). + pub fn fogs(&self) -> &[WmoFog] { + &self.fogs + } +} + +/// Parse a WMO **root** file from bytes. `Err` if the bytes aren't a WMO root. +pub fn parse_wmo_root(bytes: &[u8]) -> Result { + let parsed = + parse_wmo(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing WMO root: {e}"))?; + if !matches!(parsed, ParsedWmo::Root(_)) { + anyhow::bail!("not a WMO root file"); + } + let (doodads, doodad_sets) = parse_wmo_doodads(bytes); + let group_infos = parse_wmo_group_infos(bytes); + let portals = parse_wmo_portals(bytes); + let fogs = parse_wmo_fogs(bytes); + Ok(WmoRoot { + parsed, + doodads, + doodad_sets, + group_infos, + portals, + fogs, + }) +} + +/// Parse the root's **MOGI** group table (`[flags:u32][bbox:6×f32][nameOff:i32]`, stride 32) into the +/// per-group interior flag + bounding box used to classify placed doodads (see [`WmoGroupInfo`]). The +/// flags here mirror the per-group MOGP header flags ([`crate::models::RenderSubmesh::interior`] reads the latter). +fn parse_wmo_group_infos(bytes: &[u8]) -> Vec { + let Some(mogi) = find_wmo_chunk(bytes, b"IGOM") else { + return Vec::new(); + }; + mogi.chunks_exact(32) + .map(|rec| { + let f = |i: usize| f32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]); + let flags = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]); + WmoGroupInfo { + interior: (flags & 0x48) == 0, + bbox_min: [f(4), f(8), f(12)], + bbox_max: [f(16), f(20), f(24)], + } + }) + .collect() +} + +/// The WMO root's **portal graph** — the three root chunks that drive per-group visibility culling +/// (`wow-5875-re` `system/models/models.md` → "WMO portal-based visibility culling", VERIFIED against +/// `WoW.exe` 5875). All coordinates are WMO model space (WoW axes, Z up), the same space as +/// [`WmoGroupInfo`] bounds. A group references its portals through a slice of [`Self::refs`] given by +/// the group header's `portal_ref_start`/`portal_ref_count` ([`super::wmo_group_header`]). +#[derive(Debug, Clone, Default)] +pub struct WmoPortals { + /// **MOPV** — the portal polygon vertices, pooled; a portal indexes a contiguous run via + /// [`WmoPortalInfo::start_vertex`]/[`WmoPortalInfo::count`]. + pub vertices: Vec<[f32; 3]>, + /// **MOPT** — one entry per portal: its vertex run + separating plane. + pub infos: Vec, + /// **MOPR** — portal references: per-group edges of the portal graph (which portal joins which + /// neighbouring group, and on which side of the plane that neighbour lies). + pub refs: Vec, +} + +/// One **MOPT** portal (20 bytes on disk): a polygon (a run of [`WmoPortals::vertices`]) plus the +/// plane it lies in. `plane` is `[nx, ny, nz, dist]` — a unit normal and the signed plane offset, so a +/// point `p` is at signed distance `normal·p + dist` from the portal. +#[derive(Debug, Clone, Copy)] +pub struct WmoPortalInfo { + pub start_vertex: u16, + pub count: u16, + pub plane: [f32; 4], +} + +/// One **MOPR** portal reference (8 bytes on disk): an edge in a group's portal slice. `portal` +/// indexes [`WmoPortals::infos`]; `group` is the neighbouring group reached through it; `side` +/// (`±1`) orients the plane test — the camera must be on the `side`-facing half-space to enter. +#[derive(Debug, Clone, Copy)] +pub struct WmoPortalRef { + pub portal: u16, + pub group: u16, + pub side: i16, +} + +/// Parse the root's portal chunks (**MOPV**/**MOPT**/**MOPR**) into the portal graph. Absent chunks +/// yield empty vecs (a WMO with no portals — most single-group props). Strides/field offsets are +/// VERIFIED from the client's consumer in `WoW.exe` 5875 (parser `0x6c3a60`): MOPV stride `0xc` +/// (`C3Vector`), MOPT stride `0x14` (`u16 start, u16 count, C4Plane`), MOPR stride `0x8` +/// (`u16 portal, u16 group, i16 side, u16 filler`). +pub fn parse_wmo_portals(bytes: &[u8]) -> WmoPortals { + let f = |b: &[u8], i: usize| f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]); + let u16 = |b: &[u8], i: usize| u16::from_le_bytes([b[i], b[i + 1]]); + let i16 = |b: &[u8], i: usize| i16::from_le_bytes([b[i], b[i + 1]]); + + let vertices = find_wmo_chunk(bytes, b"VPOM") // MOPV + .map(|c| { + c.chunks_exact(12) + .map(|r| [f(r, 0), f(r, 4), f(r, 8)]) + .collect() + }) + .unwrap_or_default(); + let infos = find_wmo_chunk(bytes, b"TPOM") // MOPT + .map(|c| { + c.chunks_exact(20) + .map(|r| WmoPortalInfo { + start_vertex: u16(r, 0), + count: u16(r, 2), + plane: [f(r, 4), f(r, 8), f(r, 12), f(r, 16)], + }) + .collect() + }) + .unwrap_or_default(); + let refs = find_wmo_chunk(bytes, b"RPOM") // MOPR + .map(|c| { + c.chunks_exact(8) + .map(|r| WmoPortalRef { + portal: u16(r, 0), + group: u16(r, 2), + side: i16(r, 4), + }) + .collect() + }) + .unwrap_or_default(); + WmoPortals { + vertices, + infos, + refs, + } +} + +/// One **MFOG** fog record (48 bytes on disk — stride `0x30` VERIFIED as the client's own +/// (`wow-5875-re` ledger: `CMapObj+0x158` mfog_ptr, stride `0x30`)). Fields are the verbatim +/// v17 on-disk layout; *which* record an interior group selects and how the client consumes +/// `start_scalar` are the round-5 Q-G carve (`rf-weather-emission-timeline`) — parse now, +/// apply only once pinned. Position/radii are WMO model space, same as [`WmoGroupInfo`] bounds. +#[derive(Debug, Clone, Copy)] +pub struct WmoFog { + pub flags: u32, + /// Fog sphere centre, WMO model space (WoW axes). + pub pos: [f32; 3], + /// Inner radius — full fog inside this. + pub radius_inner: f32, + /// Outer radius — no fog influence beyond this. + pub radius_outer: f32, + /// Land fog end distance (world units from the eye). + pub fog_end: f32, + /// Land fog start, as a scalar of `fog_end` per the v17 layout (client transform pending Q-G). + pub fog_start_scalar: f32, + /// Land fog colour — raw on-disk dword (`u32 LE`; byte-order decode deferred to the fold). + pub color: u32, + /// Underwater fog end distance. + pub uw_fog_end: f32, + /// Underwater fog start scalar. + pub uw_fog_start_scalar: f32, + /// Underwater fog colour — raw on-disk dword. + pub uw_color: u32, +} + +/// Parse the root's **MFOG** fog records (stride 48). Absent chunk yields an empty vec — but note +/// every shipped 5875 root carries at least one MFOG record (the "null fog", huge `fog_end`). +pub fn parse_wmo_fogs(bytes: &[u8]) -> Vec { + let Some(mfog) = find_wmo_chunk(bytes, b"GOFM") else { + return Vec::new(); + }; + let f = |b: &[u8], i: usize| f32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]); + let u = |b: &[u8], i: usize| u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]); + mfog.chunks_exact(48) + .map(|r| WmoFog { + flags: u(r, 0), + pos: [f(r, 4), f(r, 8), f(r, 12)], + radius_inner: f(r, 0x10), + radius_outer: f(r, 0x14), + fog_end: f(r, 0x18), + fog_start_scalar: f(r, 0x1c), + color: u(r, 0x20), + uw_fog_end: f(r, 0x24), + uw_fog_start_scalar: f(r, 0x28), + uw_color: u(r, 0x2c), + }) + .collect() +} + +/// The root's `MOHD.wmoID` @ `0x20` — the `WMOAreaTable.WMOID` key for the whole building +/// (byte-verified 2026-07-03: NSabbey.wmo → 59, the abbey rows; Chapel.wmo → 656). `0` when +/// the header is too short (no 5875 root is). +pub fn wmo_root_id(root_bytes: &[u8]) -> u32 { + find_wmo_chunk(root_bytes, b"DHOM") + .filter(|mohd| mohd.len() >= 0x24) + .map(|mohd| u32::from_le_bytes([mohd[0x20], mohd[0x21], mohd[0x22], mohd[0x23]])) + .unwrap_or(0) +} + +/// Parse the root's doodad tables straight from the raw bytes: MODS (sets), MODN (name blob), MODD +/// (defs). We parse these three small fixed-layout chunks ourselves rather than via `benilla-wmo` +/// because an MODD's name is a **byte offset into the MODN blob**, which a split-on-NUL `Vec` +/// discards — so we read the blob + offsets directly. Records: MODS = 32 B +/// (`name[20]`, `start:u32`, `count:u32`, pad), MODD = 40 B (`nameOff+flags:u32`, `pos:3×f32`, +/// `quat:4×f32`, `scale:f32`, `color:4×u8`). +fn parse_wmo_doodads(bytes: &[u8]) -> (Vec, Vec) { + let modn = find_wmo_chunk(bytes, b"NDOM").unwrap_or(&[]); + // Resolve a MODN byte offset → the NUL-terminated M2 path at that offset. + let resolve = |off: usize| -> String { + if off >= modn.len() { + return String::new(); + } + let end = modn[off..] + .iter() + .position(|&b| b == 0) + .map_or(modn.len(), |p| off + p); + String::from_utf8_lossy(&modn[off..end]).into_owned() + }; + + let mut doodads = Vec::new(); + if let Some(modd) = find_wmo_chunk(bytes, b"DDOM") { + for rec in modd.chunks_exact(40) { + let f = |i: usize| f32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]); + let name_and_flags = u32::from_le_bytes([rec[0], rec[1], rec[2], rec[3]]); + doodads.push(WmoDoodad { + model: resolve((name_and_flags & 0x00FF_FFFF) as usize), + position: [f(4), f(8), f(12)], + orientation: [f(16), f(20), f(24), f(28)], + scale: f(32), + // The baked colour at +0x24 — CImVector is BGRA in memory; store as RGBA. + color: [rec[38], rec[37], rec[36], rec[39]], + }); + } + } + + let mut doodad_sets = Vec::new(); + if let Some(mods) = find_wmo_chunk(bytes, b"SDOM") { + for rec in mods.chunks_exact(32) { + let u = |i: usize| u32::from_le_bytes([rec[i], rec[i + 1], rec[i + 2], rec[i + 3]]); + doodad_sets.push(WmoDoodadSet { + start: u(20), + count: u(24), + }); + } + } + (doodads, doodad_sets) +} + +#[cfg(test)] +mod tests { + use super::super::test_bytes::{chunk, f3}; + use super::*; + + #[test] + fn parses_portal_vertices_infos_and_refs() { + // MOPV: 3 vertices. + let mut mopv = Vec::new(); + for v in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] { + mopv.extend(f3(v)); + } + // MOPT: one portal — startVertex 0, count 3, plane (0,0,1, -2.5). + let mut mopt = Vec::new(); + mopt.extend_from_slice(&0u16.to_le_bytes()); + mopt.extend_from_slice(&3u16.to_le_bytes()); + mopt.extend(f3([0.0, 0.0, 1.0])); + mopt.extend_from_slice(&(-2.5f32).to_le_bytes()); + // MOPR: two refs — (portal 0, group 1, side +1), (portal 0, group 7, side -1). + let mut mopr = Vec::new(); + for (p, g, s) in [(0u16, 1u16, 1i16), (0, 7, -1)] { + mopr.extend_from_slice(&p.to_le_bytes()); + mopr.extend_from_slice(&g.to_le_bytes()); + mopr.extend_from_slice(&s.to_le_bytes()); + mopr.extend_from_slice(&0u16.to_le_bytes()); // filler + } + let mut bytes = Vec::new(); + bytes.extend(chunk(b"VPOM", &mopv)); + bytes.extend(chunk(b"TPOM", &mopt)); + bytes.extend(chunk(b"RPOM", &mopr)); + + let p = parse_wmo_portals(&bytes); + assert_eq!(p.vertices.len(), 3); + assert_eq!(p.vertices[2], [0.0, 0.0, 1.0]); + assert_eq!(p.infos.len(), 1); + assert_eq!(p.infos[0].start_vertex, 0); + assert_eq!(p.infos[0].count, 3); + assert_eq!(p.infos[0].plane, [0.0, 0.0, 1.0, -2.5]); + assert_eq!(p.refs.len(), 2); + assert_eq!( + (p.refs[0].portal, p.refs[0].group, p.refs[0].side), + (0, 1, 1) + ); + assert_eq!( + (p.refs[1].portal, p.refs[1].group, p.refs[1].side), + (0, 7, -1) + ); + } + + #[test] + fn absent_portal_chunks_yield_empty_graph() { + let p = parse_wmo_portals(&[]); + assert!(p.vertices.is_empty() && p.infos.is_empty() && p.refs.is_empty()); + } + + #[test] + fn parses_mfog_records() { + // Two 48-byte MFOG records: a "null fog" (huge end) and a room fog. + let mut mfog = Vec::new(); + for (pos, r_in, r_out, end, start_scalar, color) in [ + ( + [0.0f32, 0.0, 0.0], + 0.0f32, + 0.0f32, + 444.4445f32, + 0.25f32, + 0xff000000u32, + ), + ([10.0, -4.0, 2.5], 5.0, 20.0, 80.0, 0.1, 0xff102030), + ] { + mfog.extend_from_slice(&0u32.to_le_bytes()); // flags + mfog.extend(f3(pos)); + for v in [r_in, r_out, end, start_scalar] { + mfog.extend_from_slice(&v.to_le_bytes()); + } + mfog.extend_from_slice(&color.to_le_bytes()); + // Underwater triple: end, start scalar, colour. + for v in [222.2f32, 0.5] { + mfog.extend_from_slice(&v.to_le_bytes()); + } + mfog.extend_from_slice(&0xff445566u32.to_le_bytes()); + } + let bytes = chunk(b"GOFM", &mfog); + + let fogs = parse_wmo_fogs(&bytes); + assert_eq!(fogs.len(), 2); + assert_eq!(fogs[0].fog_end, 444.4445); + assert_eq!(fogs[0].color, 0xff000000); + assert_eq!(fogs[1].pos, [10.0, -4.0, 2.5]); + assert_eq!(fogs[1].radius_inner, 5.0); + assert_eq!(fogs[1].radius_outer, 20.0); + assert_eq!(fogs[1].fog_start_scalar, 0.1); + assert_eq!(fogs[1].uw_fog_end, 222.2); + assert_eq!(fogs[1].uw_color, 0xff445566); + assert!(parse_wmo_fogs(&[]).is_empty()); + } +} diff --git a/crates/benilla-formats/src/npc_greeting.rs b/crates/benilla-formats/src/npc_greeting.rs new file mode 100644 index 00000000..6e7aaef0 --- /dev/null +++ b/crates/benilla-formats/src/npc_greeting.rs @@ -0,0 +1,168 @@ +//! NPC greeting voice — the vocal an NPC plays when the player interacts with it ("Greetings, +//! traveler", race/sex/personality-flavored). A per-**display** property, distinct from the +//! creature-body vocals of [`crate::creature_sound`]: +//! +//! Chain — VERIFIED against build 5875, twice over: this session's raw DBC decode (2026-07-04) +//! AND the wow-re §5 byte proof (`wow-5875-re/system/sound/scratch/npc-greeting.md`, `68cccdd1` +//! — the loaders check exactly these layouts: NPCSounds `0x54afa0` cols==5/recsize==0x14, +//! CreatureDisplayInfo `0x542e90` cols==12/recsize==0x30; the play path reads row`+0x2c`): +//! `UNIT_FIELD_DISPLAYID` → `CreatureDisplayInfo.dbc` **field[11]** (the last field, `NPCSoundID`) +//! → `NPCSounds.dbc` row → **field[1]** (`hello`) / **field[2]** (`goodbye`) / **field[3]** +//! (`pissed`, the variation-overflow line) — each a `SoundEntries` kit id, played through the +//! shared kit player. NOT the `CreatureSoundData.NPCSoundID` the older wowdev notes suggest: that +//! column (field 22) is **0/406 in 1.12** — entirely unused, and the wow-re proof confirms +//! CreatureSoundData is not on the greeting path at all. +//! +//! Byte-census of the real tables: +//! - `CreatureDisplayInfo.dbc`: 10534 × 12 fields. field[11] is nonzero on **4509** displays, and +//! **every** nonzero value (4509/4509) is a valid `NPCSounds` id (distinct 147/156). A beast +//! display (e.g. id 26) carries field[11]=0 → no greeting; only humanoid/character displays greet. +//! - `NPCSounds.dbc`: 156 × 5 fields (recsize 20), layout `{id(0), hello(1), goodbye(2), pissed(3), +//! ack(4)}`. `hello` is set on all 156 rows; `goodbye` on 118; `pissed` on 120; `ack` is **0 on +//! every row** in 1.12 (and the binary never reads it — wow-re). The kits are all `SoundEntries` +//! type 17 (NPC greeting), flags `0x20` (no-duplicates), distance-cutoff 45 yd — a 3D world +//! emitter at the NPC, not a 2D sound (wow-re §4: `FSOUND_3D_SetAttributes` with the unit's +//! position). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +/// The greeting kit set for one NPC (each a `SoundEntries` kit id; `0` = none). +#[derive(Clone, Copy, Debug)] +pub struct NpcGreeting { + /// `NPCSounds.hello` — the greeting played on interact / target enter. + pub hello: u32, + /// `NPCSounds.goodbye` — the farewell, played when the tracked interaction unit clears + /// (wow-re: `0x60c3b0(0)` on mouseover/target leave — no discrete gossip-close callback). + pub goodbye: u32, + /// `NPCSounds.pissed` — the annoyed line the variation-overflow branch plays once repeat + /// interacts have cycled past the hello kit's variations (wow-re: `0x623910`'s overflow reads + /// row`+0xc`). + pub pissed: u32, +} + +/// display id → greeting pair, joined `CreatureDisplayInfo.field[11]` → `NPCSounds`. +pub struct NpcGreetingCatalog { + display_to_sound: HashMap, + rows: HashMap, +} + +impl NpcGreetingCatalog { + /// The greeting for a creature **display id** (the [`crate`] wire `display_id`), or `None` when + /// the display carries no `NPCSoundID` (beasts and most non-character models) or the referenced + /// `NPCSounds` row is missing. + pub fn for_display(&self, display_id: u32) -> Option<&NpcGreeting> { + self.rows.get(self.display_to_sound.get(&display_id)?) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// `NPCSounds.dbc` — 5 uint32 fields. +fn npcsounds_schema() -> Schema { + let mut s = Schema::new("NPCSounds"); + for i in 0..5 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +/// `CreatureDisplayInfo.dbc` — 12 fields (field[11] = `NPCSoundID`). +fn cdi_schema() -> Schema { + let mut s = Schema::new("CreatureDisplayInfo"); + for i in 0..12 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +/// Read `NPCSounds.dbc` + `CreatureDisplayInfo.dbc` off the patch chain into the joined catalog. +pub fn load_npc_greeting_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\NPCSounds.dbc") + .context("reading NPCSounds.dbc")?; + let rs = parse(&bytes, npcsounds_schema(), "NPCSounds")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + rows.insert( + id, + NpcGreeting { + hello: u32_at(r, 1).unwrap_or(0), + goodbye: u32_at(r, 2).unwrap_or(0), + pissed: u32_at(r, 3).unwrap_or(0), + }, + ); + } + + let bytes = chain + .read_file("DBFilesClient\\CreatureDisplayInfo.dbc") + .context("reading CreatureDisplayInfo.dbc")?; + let rs = parse(&bytes, cdi_schema(), "CreatureDisplayInfo")?; + let mut display_to_sound = HashMap::new(); + for r in rs.records() { + let (Some(id), Some(npc_sound)) = (u32_at(r, 0), u32_at(r, 11)) else { + continue; + }; + if npc_sound != 0 { + display_to_sound.insert(id, npc_sound); + } + } + Ok(NpcGreetingCatalog { + display_to_sound, + rows, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The join works on the real 5875 tables: all 156 `NPCSounds` rows load, thousands of + /// displays resolve a greeting, a known character display resolves to a real greeting kit, and + /// a beast display (26, `NPCSoundID` 0) has no greeting. + #[test] + fn real_npc_greeting_resolves() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_npc_greeting_catalog(&mut chain).expect("load npc greetings"); + assert_eq!(cat.len(), 156, "all NPCSounds rows load"); + + // Display 793 → NPCSoundID 50 → {hello 5977, goodbye 5978, pissed 5979} (byte-decoded). + let g = cat.for_display(793).expect("display 793 greets"); + assert_eq!(g.hello, 5977); + assert_eq!(g.goodbye, 5978); + assert_eq!(g.pissed, 5979); + + // Display 89 → NPCSoundID 161 → {hello 7094, no goodbye, pissed 7095}. + let g = cat.for_display(89).expect("display 89 greets"); + assert_eq!(g.hello, 7094); + assert_eq!(g.goodbye, 0); + assert_eq!(g.pissed, 7095); + + // A beast model display carries NPCSoundID 0 → no greeting. + assert!( + cat.for_display(26).is_none(), + "beast display does not greet" + ); + } +} diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs new file mode 100644 index 00000000..a5fd5650 --- /dev/null +++ b/crates/benilla-formats/src/particles.rs @@ -0,0 +1,858 @@ +//! Vanilla (build 5875 / MD20 v256) M2 **particle emitter** parsing — read straight from raw bytes. +//! +//! `benilla-m2` parses only the render path and deliberately skips the cosmetic chunks, so the +//! particle emitters are read here. The post-vanilla `M2ParticleEmitter` (with `texture_file_data_ids`, +//! `enable_encryption`, WoD flag bits) does not match 1.12; the vanilla layout is a fixed +//! **0x1f8-byte** record. The byte layout is from `wow-5875-re` (`FUN_0070ebd0` @ `0x70ebd0`, +//! `m2-decomp.c`) and **difftested** here against `ElwynnCampfire.m2` (see `tests/`): +//! +//! ```text +//! header array : count @ MD20+0x13c, ptr @ MD20+0x140 record stride 0x1f8 +//! +0x04 flags(u32) +0x08 position(C3Vector) +0x14 bone(u16) +0x16 texture(u16, index→M2 textures) +//! +0x28 blendingType(u8) +0x2a emitterType(u16:1=Plane,2=Sphere,3=Spline) +//! +0x2c particleType(u16:0=head,1=tail,2=both) +0x30 tileRows(u16) +0x32 tileCols(u16) +//! +0x34.. ten emission M2Tracks (vanilla 28-byte track; the runtime uses value[0] of each): +//! +0x34 emissionSpeed +0x50 speedVariation +0x6c verticalRange +0x88 horizontalRange +//! +0xa4 gravity +0xc0 lifespan +0xdc emissionRate +0xf8 areaLength +0x114 areaWidth +//! +0x130 zSource +//! +0x14c.. baked over-life color/opacity/scale/cell ramps (see `OverLife`). +//! +0x17c tailTime(f32, SECONDS — the tail streak's length is velocity·tailTime) +//! +0x180 twinkleSpeed +0x184 twinklePercent +0x188/+0x18c twinkleScale{min,max} +//! +0x190 inheritScale(f32) +0x194 drag(f32, plain scalar — the velocity decay) +//! +0x198 spin(f32, rad/s quad rotation) +//! +0x1c4..+0x1d0 followSpeed1/followScale1/followSpeed2/followScale2 (the follow-delta line) +//! +0x1dc enabled(M2Track, step) — the emission ON/OFF gate, closing the record at 0x1f8. +//! ``` +//! +//! The keyed tracks here (emission rate, enabled) parse through the shared [`crate::value_track`] +//! kernel: **rebased to the first sequence's band** at parse (see its module doc for the +//! global-timeline law), so the runtime emitter clock (seconds since the effect spawned) samples +//! them directly. +//! +//! The record tail (+0x180..) is the wow-re `part-simspace-fields.md` §5-verified map (their +//! `ac915a7d`): the loader `0x70ebd0` remaps file+0x188/+0x18c → runtime twinkleScale min/max +//! (delta = max − min at rt+0x1c0), NOT a size multiplier — see [`ParticleEmitterDef::twinkle`]. + +use std::io::Cursor; + +use anyhow::Result; +use benilla_m2::parse_m2; + +use crate::value_track::{seq0_band, track_enabled, track_keys_with, OnOffTrack, ValueTrack}; + +/// Emitter spawn shape (file `emitterType` @ +0x2a). Only `Plane` is needed for campfires/torches; +/// `Sphere` (e.g. dust clouds) and `Spline` follow. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParticleShape { + Plane, + Sphere, + Spline, +} + +/// How a particle batch blends (file `blendingType` @ +0x28 = the M2/EGxBlend enum). Additive is the +/// flame/glow case (the campfire is `4`); the rest fold to alpha for now (mod/mod2x are rare for the +/// props we render and will get their own path when a model needs them). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParticleBlend { + /// `3` NoAlphaAdd / `4` Add — `(SRC_ALPHA, ONE)`. Flames, glows, embers. No depth write/sort. + Add, + /// `2` Alpha — `(SRC_ALPHA, ONE_MINUS_SRC_ALPHA)`. Smoke. + Alpha, + /// `0` Opaque / `1` AlphaKey — rare for particles. + Opaque, +} + +/// The baked **over-life** ramps (record tail, +0x14c..) sampled by a particle's normalized age `u = +/// age/lifespan`. Each of color, size and cell is a **3-key ramp** evaluated as two linear segments +/// split at [`Self::mid`]: key0→key1 over `u∈[0,mid]`, then key1→key2 over `u∈[mid,1]`. Byte layout + +/// math verified in `wow-5875-re` (`FUN_0070ebd0` builder, `FUN_007b9b10` evaluator) and difftested +/// against `ElwynnCampfire.m2`. These drive the believable flame fade / grow / flicker. +#[derive(Debug, Clone, Copy)] +pub struct OverLife { + /// Normalized age (`+0x14c`) splitting the two ramp segments. + pub mid: f32, + /// RGBA keys k0/k1/k2 (`+0x150/+0x154/+0x158`, packed BGRA u32 → linear 0..1). The **A** channel + /// is the particle's additive weight (its over-life opacity); there is no separate alpha array. + pub color: [[f32; 4]; 3], + /// Uniform particle size keys k0/k1/k2 (`+0x15c/+0x160/+0x164`, yards). + pub scale: [f32; 3], + /// Head texture-cell index range `[begin, end]` for segment A (`u≤mid`, `+0x168/+0x16a`) and + /// segment B (`u>mid`, `+0x16e/+0x170`) — the cell animated across the `rows×cols` atlas. + pub cell_a: [u16; 2], + pub cell_b: [u16; 2], +} + +impl OverLife { + /// Sample the ramps at normalized age `u` (0..1): `(rgba, size_yards, cell_index)`. + pub fn sample(&self, u: f32) -> ([f32; 4], f32, u16) { + let u = u.clamp(0.0, 1.0); + let mid = self.mid.clamp(1e-3, 1.0); + let (a, b, t, cell) = if u <= mid { + (0, 1, u / mid, self.cell_a) + } else { + (1, 2, (u - mid) / (1.0 - mid).max(1e-3), self.cell_b) + }; + let t = t.clamp(0.0, 1.0); + let mut color = [0.0; 4]; + for (c, slot) in color.iter_mut().enumerate() { + *slot = self.color[a][c] + (self.color[b][c] - self.color[a][c]) * t; + } + let size = self.scale[a] + (self.scale[b] - self.scale[a]) * t; + // cell = floor(begin + (end-begin+1)·t), clamped to [begin, end]. + let span = (i32::from(cell[1]) - i32::from(cell[0]) + 1).max(1) as f32; + let idx = (f32::from(cell[0]) + span * t).floor() as i32; + let cell_index = idx.clamp(i32::from(cell[0]), i32::from(cell[1])) as u16; + (color, size, cell_index) + } +} + +/// A SPLINE (type-3) emitter's authored curve: a flattened **cubic Bézier chain** (K segments, +/// `3K+1` control points: `P, out-tangent, in-tangent, P, …`), **arc-length parameterized** — the +/// reference computes per-segment arc lengths into a knot array at load (`0x7b9a80` → +/// `0x4532e0`; the knots are NOT on disk) and the spawn kernel's `t ∈ [0,1]` walks the chain by +/// normalized arc length (wow-re `part-spline-file-layout.md` + `part-shape-kernels.md` §3). Our +/// per-segment length is a 16-chord subdivision (the reference's `0x453e50` method is untraced — +/// any smooth-curve length approximation lands within a fraction of a percent). +#[derive(Debug, Clone)] +pub struct SplineData { + /// Control points, model-local WoW axes (`3K+1`, from file+0x1d4/+0x1d8). + pub points: Vec<[f32; 3]>, + /// Cumulative normalized arc length at each segment boundary (`K+1` entries, 0 → 1). + knots: Vec, +} + +impl SplineData { + /// Build from the raw control points (must be `3K+1`; returns `None` below one segment). + pub fn new(points: Vec<[f32; 3]>) -> Option { + let k = points.len().checked_sub(1)? / 3; + if k == 0 || points.len() != 3 * k + 1 { + return None; + } + let mut knots = vec![0.0f32]; + for seg in 0..k { + let mut len = 0.0; + let mut prev = Self::bezier(&points[3 * seg..3 * seg + 4], 0.0); + for i in 1..=16 { + let p = Self::bezier(&points[3 * seg..3 * seg + 4], i as f32 / 16.0); + len += ((p[0] - prev[0]).powi(2) + + (p[1] - prev[1]).powi(2) + + (p[2] - prev[2]).powi(2)) + .sqrt(); + prev = p; + } + knots.push(knots[seg] + len); + } + let total = *knots.last().unwrap(); + if total > 0.0 { + for kn in &mut knots { + *kn /= total; + } + } + Some(Self { points, knots }) + } + + fn bezier(p: &[[f32; 3]], u: f32) -> [f32; 3] { + let w = [ + (1.0 - u).powi(3), + 3.0 * u * (1.0 - u).powi(2), + 3.0 * u * u * (1.0 - u), + u.powi(3), + ]; + std::array::from_fn(|c| (0..4).map(|i| w[i] * p[i][c]).sum()) + } + + fn bezier_deriv(p: &[[f32; 3]], u: f32) -> [f32; 3] { + let w = [ + -3.0 * (1.0 - u).powi(2), + 3.0 * (1.0 - u) * (1.0 - 3.0 * u), + 3.0 * u * (2.0 - 3.0 * u), + 3.0 * u * u, + ]; + std::array::from_fn(|c| (0..4).map(|i| w[i] * p[i][c]).sum()) + } + + /// Locate `(segment, local u)` for a normalized arc fraction `t`. + fn locate(&self, t: f32) -> (usize, f32) { + let k = self.knots.len() - 1; + let seg = self.knots[1..k] + .iter() + .position(|&kn| t < kn) + .unwrap_or(k - 1); + let (a, b) = (self.knots[seg], self.knots[seg + 1]); + (seg, ((t - a) / (b - a).max(1e-6)).clamp(0.0, 1.0)) + } + + /// The curve point at arc fraction `t` — first/last control point outside `[0,1]` (the + /// reference's `0x453390` clamp legs). + pub fn eval(&self, t: f32) -> [f32; 3] { + if t <= 0.0 { + return self.points[0]; + } + if t >= 1.0 { + return *self.points.last().unwrap(); + } + let (seg, u) = self.locate(t); + Self::bezier(&self.points[3 * seg..3 * seg + 4], u) + } + + /// The (unnormalized) curve tangent at arc fraction `t` (the reference's `0x453420` leg — + /// its caller renormalizes). + pub fn tangent(&self, t: f32) -> [f32; 3] { + let (seg, u) = self.locate(t.clamp(0.0, 1.0)); + Self::bezier_deriv(&self.points[3 * seg..3 * seg + 4], u) + } +} + +/// One parsed vanilla M2 particle emitter, reduced to what the renderer needs. Positions/vectors are in +/// **model-local** space (WoW axes, Z up) — compose with the instance world transform at spawn. +#[derive(Debug, Clone)] +pub struct ParticleEmitterDef { + pub flags: u32, + /// Emitter origin, model-local. (Bone-follow @ +0x14 is deferred — static props sit on the root.) + pub position: [f32; 3], + pub bone: u16, + pub shape: ParticleShape, + pub blend: ParticleBlend, + /// **Geometry model** (file+0x18 count / +0x1c offset, an `M2Array` path — wow-re + /// `part-model-particles.md`): when it names a resolvable `.m2`, this emitter spawns tiny + /// 3-D MODEL instances instead of billboard quads (Whirlwind's blades, Cone of Cold's + /// shards, the cyclones). `None` when unauthored. + pub geometry_model: Option, + /// **Recursion model** (file+0x20 / +0x24 — wow-re `part-child-recursion.md`): the named + /// model's OWN particle emitters (capped at 4) become CHILD emitters driven once per live + /// parent particle per frame at the particle's position (Fire Blast's impact sparks, bomb + /// explosions). `None` when unauthored. + pub recursion_model: Option, + /// Particle texture (`.blp` path, as embedded in the M2 textures table). `None` if unresolved. + pub texture: Option, + /// Texture atlas size for cell animation (`1×1` = a single static cell). + pub tile_rows: u16, + pub tile_cols: u16, + /// 0 = head (camera quad), 1 = tail (speed-stretched), 2 = both. We render head first. + pub head_tail: u8, + // --- emission params: value[0] of each emission M2Track (the runtime's own load-time bake) --- + /// Initial particle speed (yards/sec). + pub emission_speed: f32, + /// Fractional random speed spread (`speed·(1 ± var·noise)`). + pub speed_variation: f32, + /// Half-angle of the emission cone, radians (small = a tight upward jet). + pub vertical_range: f32, + /// Azimuthal spread, radians (`2π` = full circle around the up axis). + pub horizontal_range: f32, + /// Downward acceleration (yards/sec²); small/zero for a buoyant flame. + pub gravity: f32, + /// Particle lifetime (seconds) — killed at this age. + pub lifespan: f32, + /// Particles spawned per second — the one emission parameter parsed as its **full keyed track** + /// ([`ValueTrack`]), because the one-shot combat effects author their bursts on it (see + /// [`ValueTrack`]'s doc). The other nine stay `value[0]`-baked: constant in every model this + /// renderer draws (verified across the spurt family + the ambient props). + pub emission_rate: ValueTrack, + /// The emission ON/OFF gate ([`OnOffTrack`], file `+0x1dc`) — the one-shot effects' + /// choreography track. Sampled on the same clip clock as `emission_rate`. + pub enabled: OnOffTrack, + /// The clip clock's WRAP: `Some(first sequence's span ms)` when that sequence LOOPS — the + /// client samples the two tracks above on `m2_animate`'s sequence time, which wraps there, + /// re-firing windowed tracks every pass (a precast hold's pulsing hand flash). A CLAMPED + /// sequence holds its end instead — and a discrete kit instance is reaped at exactly one + /// pass (`spell_fx`), so its boundary never renders; `None` keeps the end-clamp hold. + /// (wow-re `part-emission-rate-animated.md`: the per-frame `m2_animate` sampler.) + pub clip_wrap_ms: Option, + /// Plane emitter full extents (yards); spawn points are drawn from the `±½·extent` rectangle. + pub area_length: f32, + pub area_width: f32, + /// zSource / gravity2 — a pull toward a point above the emitter (0 = unused). + pub z_source: f32, + /// Velocity **drag** (file +0x194; a plain scalar, not a track — the builder copies it straight to + /// the runtime emitter's `+0x1e0` at `m2-decomp.c:8950`, *outside* the ten track setters). Each + /// frame the runtime applies `vel −= min(dt·drag, 1)·vel` (verified `particle_integrate` @ + /// `0x7b2680`, step 4) — exponential + /// velocity decay that caps a particle's total travel near `speed/drag`. Load-bearing for props + /// that author a *fast, long-lived, zero-gravity* jet and rely on drag to contain it: e.g. + /// `CandelabraTallWall01` (speed 0.56, life 6, gravity 0, **drag 10** → a ~0.06 yd flicker; with + /// no drag the same particle coasts 3.3 yd to the ceiling). 0 = no drag. + pub drag: f32, + /// **Tail time** (file +0x17c, seconds — wow-re `part-quad-tail-twinkle.md`, their `65b8305b`): + /// a tail-mode particle (`head_tail` 1/2) renders a velocity-projected streak of world length + /// `|velocity| · tail_time`, trailing behind the motion; file flag `0x400` additionally clamps + /// the time to the particle's age (the streak grows from zero at birth). + pub tail_time: f32, + /// **Tumble** — the model particles' angular-velocity range (file +0x19c min / +0x1a8 max, + /// C3Vectors, rad/s — wow-re `part-model-particles.md` §b): each birth rolls a body-frame + /// spin vector. The reference's roll carries a VERIFIED original-client asymmetry — only X + /// honors `min + u·range`; Y and Z multiply a raw `[1,2)` mantissa by their range alone + /// (their min is loaded but never read) — which a fidelity consumer must replicate. + pub angular_velocity_min: [f32; 3], + pub angular_velocity_max: [f32; 3], + /// **Emitter-motion inherit scale** (file +0x190 → runtime +0x1c4 — wow-re + /// `part-emitter-motion.md` §1, byte-verified `0x70ff71/0x70ff77`; closes the field the old + /// tail map left "?"): scales the inherited velocity a flag-0x40 emitter feeds its births + /// (see [`Self::inherits_emitter_motion`]). + pub inherit_scale: f32, + /// **Follow pairs** (file +0x1c4..+0x1d0 — wow-re `part-emitter-motion.md` §2, the + /// `0x7b5d30` setter): two authored (emitter speed → follow fraction) samples defining the + /// follow-delta response line; see [`Self::follow_line`]. + pub follow_speed1: f32, + pub follow_scale1: f32, + pub follow_speed2: f32, + pub follow_scale2: f32, + /// **Twinkle** — the per-frame flicker modulation (file +0x180/+0x184/+0x188/+0x18c; wow-re + /// `part-simspace-fields.md`, byte-verified in the quad writer `0x7b2a50`). The rendered half- + /// size is the over-life scale ramp × a **gated** twinkle multiplier: + /// `min ≠ max ⇒ noise(speed·age)·(max − min) + min`, **skipped entirely when `min == max`** + /// (a degenerate range — `{0,0}` and `{1,1}` alike burn steady at ramp size; the kobold candle's + /// `{0,0}` is NOT size zero). This corrects the old `scale_base + rand·scale_variation` reading, + /// which inflated every `{1,1}` flame 1–2× and collapsed `{0,0}` to invisible. + pub twinkle_speed: f32, + /// Fraction of frames the particle draws at all while twinkling (the reference's separate + /// draw-gate; exact byte condition thin — consumers may treat `≥ 1` / `0` as always-draw). + pub twinkle_percent: f32, + pub twinkle_min: f32, + pub twinkle_max: f32, + /// SPLINE (type-3) curve data — `Some` only when the record authors a valid Bézier chain + /// (file+0x1d4 count / +0x1d8 offset, `N = 3·⌊count/3⌋+1` C3Vectors — wow-re + /// `part-spline-file-layout.md`, VERIFIED). For splines the generic emission fields are + /// **repurposed** (each setter reads its track's `values[0]`): `area_length`/`area_width` + /// = **tMin/tMax** (the spawned arc-fraction window, clamped [0,1]), `vertical_range` = + /// the **tangent-spin range ψ** (birth velocity = +Z rotated about the local tangent by + /// S11·ψ), `horizontal_range` = the **scatter** (a `U01·scatter` position jitter along the + /// velocity), and the rate track's `values[0]` doubles as the load-time arcScale (a + /// one-frame transient — the per-frame rate sample overwrites it; we always sample the + /// track, so it needs no separate field). + pub spline: Option, + /// Quad **spin** (file +0x198 → runtime +0x18c): the billboard rotates in-plane by + /// `angle = spin · age` (rad; fcos/fsin @ `0x7b2ddc` in the quad writer). 0 = no rotation. + /// A **negative** spin is the author's alternating-direction switch: the writer negates the + /// (negative) angle on half the particles (pointer-bit-5 hash, `0x7b2dda`), counter-rotating + /// the cloud — vanilla's only rotation randomizer (the record has no phase/variance field). + pub spin: f32, + /// Baked over-life ramps (color/opacity/size/cell) — see [`OverLife`]. + pub over_life: OverLife, +} + +impl ParticleEmitterDef { + /// File flag `0x10` (→ runtime `0x100` — the loader's flag word is a **non-identity remap**, + /// wow-re `part-simspace-fields.md` corrections `1f40db0b`, byte block `0x70faf8–0x70fc44`): + /// the whole cloud renders through the emitter's **live bone matrix** each frame (rotation and + /// all — the chandelier's candle flames rigidly ride the swing). Clear ⇒ bone/model rotation is + /// baked at birth instead. Either way the cloud is **re-anchored to the emitter's current + /// position every frame** (the draw matrix rebuilds with `translate(−emitterPos)` per frame) — + /// a moving model carries its flame; there is NO world-frozen trail mode. + pub fn model_space(&self) -> bool { + self.flags & 0x10 != 0 + } + /// File flag `0x4000` (→ runtime `0x40000` — wow-re `part-emitter-motion.md` §2/§2b, + /// §5-resolved): live particles keep exactly **[`Self::follow_line`]'s fraction (≤ 1) of + /// the emitter's per-frame world motion** — at saturation the trail rides the emitter + /// rigidly, below it lags toward a world-frozen trail; it never leads. (The reference's + /// baseline for this content class is world-frozen — its emitter motion folds into the + /// emitter matrix — and its `+fraction·Δ` add recovers the ride; over an anchor-riding + /// store the same observable is a `(fraction−1)·Δ` move.) The hunter missiles author it + /// (19 emitters / 15 spell models). + pub fn follow_emitter(&self) -> bool { + self.flags & 0x4000 != 0 + } + /// The follow-delta response line (the reference's load-time `0x7b5d30`): `(slope, + /// intercept)` of the line through the two authored `(speed, fraction)` samples, so the + /// per-frame fraction is `clamp(slope·|Δpos|/dt + intercept, 0, 1)`. `None` when the two + /// speeds coincide (the reference zeroes both — no follow response). + pub fn follow_line(&self) -> Option<(f32, f32)> { + ((self.follow_speed2 - self.follow_speed1).abs() >= 1e-6).then(|| { + let slope = (self.follow_scale2 - self.follow_scale1) + / (self.follow_speed2 - self.follow_speed1); + (slope, self.follow_scale1 - slope * self.follow_speed1) + }) + } + /// File flag `0x40` (→ runtime `0x400` — wow-re `part-emitter-motion.md` §1, VERIFIED): + /// births inherit the emitter's recent motion. The emitter keeps a ~30 Hz inherit-velocity + /// vector — at each trigger (accumulated dt > 1/30 s), `oneFrameΔ · ((1/30)/accum) · + /// inherit_scale`, zeroed while no particles are live — and each birth adds + /// `(1 + S11·speed_variation) · inherit` to its velocity (the shape kernels' closing + /// block). The enchant hands / Bloodlust / Death Wish family authors it (70 emitters / 33 + /// spell models). + pub fn inherits_emitter_motion(&self) -> bool { + self.flags & 0x40 != 0 + } + /// File flag `0x20` (→ runtime `0x200`): the rendered particle size is multiplied by the + /// emitter transform's scale magnitude. Torches/campfires author it; without it an + /// instance-scaled prop scales its particle *positions* only. + pub fn scale_size_by_instance(&self) -> bool { + self.flags & 0x20 != 0 + } + /// File flag `0x100` on a **sphere** emitter (→ runtime `0x4000`, mapped only when the type + /// word is 2): birth velocity is straight `+Z` instead of radial through the shell point. + pub fn sphere_up(&self) -> bool { + self.flags & 0x100 != 0 + } + /// File flag `0x80` on a **sphere** emitter (→ runtime `0x800` — like [`Self::sphere_up`] + /// the loader maps it only when the type word is 2, so a plane's authored `0x80` is dead + /// data): kill the particle the frame its motion turns AWAY from the emitter origin — the + /// integrator's tail tests `dot(stepVelocity, updatedPos) > 0` and returns dead (`0x7b2680`, + /// the `rt 0x800` branch). The suction containment: every corpus author pairs it with a + /// NEGATIVE emission speed (Blink/Conjure/Detect Magic converge inward), and the kill is + /// what stops the stream at the centre instead of letting it spray out the far side. + pub fn kill_outbound(&self) -> bool { + self.shape == ParticleShape::Sphere && self.flags & 0x80 != 0 + } + /// File flag `0x400` (→ runtime `0x10000`): a tail streak's `tail_time` is clamped to the + /// particle's age — the streak grows from zero at birth instead of popping in full-length. + pub fn tail_clamps_to_age(&self) -> bool { + self.flags & 0x400 != 0 + } + /// File flag `0x200` (→ runtime `0x8000` — wow-re `part-model-particles.md` §b): each + /// tumble axis's rolled angular velocity is independently sign-flipped with probability ½ + /// (three extra draws) — the model particles' spin-direction randomizer. + pub fn tumble_random_sign(&self) -> bool { + self.flags & 0x200 != 0 + } + /// File flag `0x2000` (→ runtime `0x20000` — wow-re `part-groundsnap-zhook.md`, VERIFIED): + /// the **at-spawn ground snap**. Once, at each birth (anchored mode only — the shape + /// kernels call the hook `0x7b2140` inside their bit-0x100-CLEAR branch), the client + /// probes **20 yd straight down** for terrain OR WMO/doodad geometry (`0x672b60` → + /// `0x6aa160`, flags 0x100111); on a hit the particle's up-coordinate becomes `surfaceZ + + /// its birth over-life SIZE` (the sampler's float leg is the scale ramp — `0x7b9e20` + /// writes bank+0x24/+0x28 from file+0x15c/0x160/0x164 — so the quad stands ON the surface + /// by its half-extent). No surface within 20 yd leaves the spawn position untouched. NOT a + /// per-frame clamp, NOT terrain-only. Fire/Frost Nova, the ground fogs (37 emitters / 17 + /// spell models). + pub fn ground_snap(&self) -> bool { + self.flags & 0x2000 != 0 + } + /// File flag `0x8000` (bit 15): the emission model is a **one-shot BURST**, not continuous. + /// The reference's per-frame emitter pass branches on this bit (`0x718ec8`): a burst emitter + /// gets a rising-edge trigger — the frame `(enabled && rate > 0)` first turns true it requests + /// ONE spawn of `ftol(rate · density · LOD)` particles (`0x7b5c50` → the spawn driver's + /// self-clearing burst branch `0x7b55ae`/`0x7b563d`) — and the continuous-enable bit is + /// skipped, so the `rate·dt` pour can never run for it. Re-arms only when the gate falls + /// (a looping clip's next pass). This flag is why the Feint/Eviscerate impact's plume and + /// crescents are over ~0.5 s while the same-shaped `Eviscerate_Cast_Hands` flame (bit clear) + /// pours for its whole 1.6 s clip (wow-re `part-emission-burst-flag.md`, byte-arbitrated). + pub fn burst(&self) -> bool { + self.flags & 0x8000 != 0 + } + /// File flag `0x1000` (→ runtime `0x2000`): the head quad does NOT billboard — it lies flat + /// in the emitter's model-space XY plane, carried by the live model→world matrix (the + /// community "XYQuad"; the impact crescents, state rings, fish-school splash rings). + /// Byte-pinned end to end in wow-re: corners = the ±1 XY unit square (z = 0) × the draw + /// matrix — camera-independent, unit half-extent exactly like the billboard corner table — + /// and quad spin is Rodrigues about the quad-plane normal (consumption + /// `part-quad-tail-twinkle.md` §3, builder semantics `part-tiled-corner-builder.md`, both + /// VERIFIED). + pub fn xy_quad(&self) -> bool { + self.flags & 0x1000 != 0 + } + /// The gated twinkle size multiplier, given a noise sample `noise ∈ [0,1)` from the caller's + /// LUT (indexed off `twinkle_speed · age`): identity when the range is degenerate. + pub fn twinkle(&self, noise: f32) -> f32 { + if (self.twinkle_max - self.twinkle_min).abs() < 1e-6 { + 1.0 + } else { + noise * (self.twinkle_max - self.twinkle_min) + self.twinkle_min + } + } +} + +const STRIDE: usize = 0x1f8; +const HDR_COUNT: usize = 0x13c; +const HDR_PTR: usize = 0x140; + +fn le_u16(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn le_u32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn le_f32(b: &[u8], o: usize) -> f32 { + f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn le_vec3(b: &[u8], o: usize) -> [f32; 3] { + [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)] +} + +/// Read value[0] of a vanilla M2Track (28 bytes) whose record begins at `track`. Track tail: +/// `values{count @ +0x14, offset @ +0x18}`, values are `f32`. Returns `default` if the track is empty +/// or points out of range (the runtime's load-time bake reads exactly this first value). +fn track_first(b: &[u8], track: usize, default: f32) -> f32 { + if track + 0x1c > b.len() { + return default; + } + let n = le_u32(b, track + 0x14); + let ofs = le_u32(b, track + 0x18) as usize; + if n == 0 || ofs + 4 > b.len() { + return default; + } + le_f32(b, ofs) +} + +/// Read a vanilla M2Track's full `(timestamp ms, f32)` key list, band-rebased — the shared +/// kernel's reader with the scalar decode ([`track_keys_with`]). +fn track_keys(b: &[u8], track: usize, default: f32, band: (u32, u32)) -> ValueTrack { + track_keys_with(b, track, default, band, 4, le_f32) +} + +fn blend_of(v: u8) -> ParticleBlend { + match v { + 3 | 4 => ParticleBlend::Add, + 2 | 5 | 6 => ParticleBlend::Alpha, // 5/6 (mod/mod2x) fold to alpha until a model needs them + _ => ParticleBlend::Opaque, + } +} + +fn shape_of(v: u16) -> ParticleShape { + match v { + 2 => ParticleShape::Sphere, + 3 => ParticleShape::Spline, + _ => ParticleShape::Plane, + } +} + +/// Parse the vanilla M2's particle emitters from its file bytes. Texture indices are resolved against +/// the M2 textures table (parsed by `benilla-m2`; the particle *records* are read here). Returns an +/// empty vec if the model has no emitters or isn't a parseable M2. +pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result> { + // Texture names come from `benilla-m2`'s textures table. + let textures: Vec> = match parse_m2(&mut Cursor::new(bytes)) { + Ok(fmt) => fmt + .model() + .textures + .iter() + .map(|t| { + let f = t + .filename + .string + .to_string_lossy() + .trim_end_matches('\0') + .to_string(); + (!f.is_empty()).then_some(f) + }) + .collect(), + Err(_) => Vec::new(), + }; + + if bytes.len() < HDR_PTR + 4 || &bytes[..4] != b"MD20" { + return Ok(Vec::new()); + } + let count = le_u32(bytes, HDR_COUNT) as usize; + let base = le_u32(bytes, HDR_PTR) as usize; + if count == 0 || count > 256 || base + count * STRIDE > bytes.len() { + return Ok(Vec::new()); + } + + // The first sequence's absolute time band — the window the keyed tracks below are rebased + // onto (`value_track::seq0_band`). + let band = seq0_band(bytes); + let clip_wrap_ms = crate::value_track::seq0_wrap_ms(bytes); + + // Decode a packed BGRA `CImVector` color key → linear RGBA 0..1 (A = over-life opacity). + let color_key = |o: usize| -> [f32; 4] { + let v = le_u32(bytes, o); + [ + ((v >> 16) & 0xff) as f32 / 255.0, // R + ((v >> 8) & 0xff) as f32 / 255.0, // G + (v & 0xff) as f32 / 255.0, // B + ((v >> 24) & 0xff) as f32 / 255.0, // A + ] + }; + + let mut out = Vec::with_capacity(count); + for i in 0..count { + let e = base + i * STRIDE; + let tex_index = le_u16(bytes, e + 0x16) as usize; + let texture = textures.get(tex_index).cloned().flatten(); + let shape = shape_of(le_u16(bytes, e + 0x2a)); + // The two per-emitter model paths (`M2Array`, offset pre-fixup = file-relative): + // geometry (model particles) and recursion (child emitters). + let model_path = |cnt_off: usize| -> Option { + let n = le_u32(bytes, e + cnt_off) as usize; + let ofs = le_u32(bytes, e + cnt_off + 4) as usize; + if n < 2 || ofs + n > bytes.len() { + return None; + } + let s = String::from_utf8_lossy(&bytes[ofs..ofs + n]) + .trim_end_matches('\0') + .to_string(); + (!s.is_empty()).then_some(s) + }; + let geometry_model = model_path(0x18); + let recursion_model = model_path(0x20); + // SPLINE control points (file+0x1d4/+0x1d8, raw M2Array dwords): the reference copies + // N = 3·⌊count/3⌋+1 C3Vectors — the flattened cubic Bézier chain. + let spline = (shape == ParticleShape::Spline) + .then(|| { + let q = le_u32(bytes, e + 0x1d4) as usize; + let ofs = le_u32(bytes, e + 0x1d8) as usize; + let n = 3 * (q / 3) + 1; + if q < 3 || ofs + n * 12 > bytes.len() { + return None; + } + SplineData::new((0..n).map(|p| le_vec3(bytes, ofs + p * 12)).collect()) + }) + .flatten(); + let over_life = OverLife { + mid: le_f32(bytes, e + 0x14c), + color: [ + color_key(e + 0x150), + color_key(e + 0x154), + color_key(e + 0x158), + ], + scale: [ + le_f32(bytes, e + 0x15c), + le_f32(bytes, e + 0x160), + le_f32(bytes, e + 0x164), + ], + cell_a: [le_u16(bytes, e + 0x168), le_u16(bytes, e + 0x16a)], + cell_b: [le_u16(bytes, e + 0x16e), le_u16(bytes, e + 0x170)], + }; + out.push(ParticleEmitterDef { + flags: le_u32(bytes, e + 0x04), + position: le_vec3(bytes, e + 0x08), + bone: le_u16(bytes, e + 0x14), + shape, + spline, + geometry_model, + recursion_model, + clip_wrap_ms, + blend: blend_of(bytes[e + 0x28]), + texture, + tile_rows: le_u16(bytes, e + 0x30).max(1), + tile_cols: le_u16(bytes, e + 0x32).max(1), + head_tail: bytes[e + 0x2c], + emission_speed: track_first(bytes, e + 0x34, 0.0), + speed_variation: track_first(bytes, e + 0x50, 0.0), + vertical_range: track_first(bytes, e + 0x6c, 0.0), + horizontal_range: track_first(bytes, e + 0x88, 0.0), + gravity: track_first(bytes, e + 0xa4, 0.0), + lifespan: track_first(bytes, e + 0xc0, 1.0), + emission_rate: track_keys(bytes, e + 0xdc, 0.0, band), + enabled: track_enabled(bytes, e + 0x1dc, band), + area_length: track_first(bytes, e + 0xf8, 0.0), + area_width: track_first(bytes, e + 0x114, 0.0), + z_source: track_first(bytes, e + 0x130, 0.0), + drag: le_f32(bytes, e + 0x194), + tail_time: le_f32(bytes, e + 0x17c), + angular_velocity_min: le_vec3(bytes, e + 0x19c), + angular_velocity_max: le_vec3(bytes, e + 0x1a8), + inherit_scale: le_f32(bytes, e + 0x190), + follow_speed1: le_f32(bytes, e + 0x1c4), + follow_scale1: le_f32(bytes, e + 0x1c8), + follow_speed2: le_f32(bytes, e + 0x1cc), + follow_scale2: le_f32(bytes, e + 0x1d0), + twinkle_speed: le_f32(bytes, e + 0x180), + twinkle_percent: le_f32(bytes, e + 0x184), + twinkle_min: le_f32(bytes, e + 0x188), + twinkle_max: le_f32(bytes, e + 0x18c), + spin: le_f32(bytes, e + 0x198), + over_life, + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The spline chain is **arc-length** parameterized: two straight segments of length 1 and + /// 3 (control points at exact thirds, so the cubic degenerates to linear) put the segment + /// boundary at t = 0.25, not 0.5 — plus the reference's outside-[0,1] clamp legs. + #[test] + fn spline_chain_is_arc_length_parameterized() { + let x = |v: f32| [v, 0.0, 0.0]; + let s = SplineData::new(vec![ + x(0.0), + x(1.0 / 3.0), + x(2.0 / 3.0), + x(1.0), // segment 0: 1 yd + x(2.0), + x(3.0), + x(4.0), // segment 1: 3 yd + ]) + .expect("3K+1 chain"); + assert!((s.eval(0.25)[0] - 1.0).abs() < 1e-4, "boundary at arc 1/4"); + assert!((s.eval(0.625)[0] - 2.5).abs() < 1e-4, "mid of segment 1"); + assert_eq!(s.eval(-0.5), [0.0, 0.0, 0.0], "clamp to first point"); + assert_eq!(s.eval(1.5), [4.0, 0.0, 0.0], "clamp to last point"); + let tan = s.tangent(0.1); + assert!(tan[0] > 0.0 && tan[1] == 0.0 && tan[2] == 0.0, "+X tangent"); + assert!(SplineData::new(vec![x(0.0)]).is_none(), "below one segment"); + } + + /// The real `BloodSpurt.m2` (decision 0140 fold-back — the melee impact flash): 4 emitters, + /// and the three the old `value[0]` bake silently killed are **keyed bursts** — the starflash + /// (STARFLASH_GREY, additive) rates 0→20→0 over the first 133 ms. Pins the burst keys and + /// that the spray (emitter 0) still reads its constant-equivalent first key of 100/s. + #[test] + fn real_blood_spurt_emitters_are_keyed_bursts() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Particles\\BloodSpurts\\BloodSpurt.m2") + .expect("read BloodSpurt.m2"); + let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters"); + assert_eq!(defs.len(), 4); + // Emitter 0 — the red spray: starts hot (100/s at t=0), the old bake's one working case. + assert_eq!(defs[0].emission_rate.first(), 100.0); + // Emitter 3 — the starflash: additive, STARFLASH texture, burst-keyed (first key 0, + // peak 20/s inside the 67–100 ms window, dead again by 133 ms). + let flash = &defs[3]; + assert!(flash + .texture + .as_deref() + .is_some_and(|t| t.to_ascii_uppercase().contains("STARFLASH"))); + assert_eq!(flash.blend, ParticleBlend::Add); + assert_eq!(flash.emission_rate.first(), 0.0, "value[0] bake = silence"); + assert_eq!(flash.emission_rate.peak(), 20.0); + assert_eq!(flash.emission_rate.step_ms(80.0), 20.0); + assert_eq!(flash.emission_rate.step_ms(200.0), 0.0); + // Emitters 1/2 — the glowball droplets, same burst shape, peaks 200. + assert_eq!(defs[1].emission_rate.peak(), 200.0); + assert_eq!(defs[2].emission_rate.peak(), 200.0); + // The spray's enabled track cuts emission at 500 ms — exactly where its rate track goes + // negative (the authored tail the old always-on read let the floor-at-0 hide). + assert!(defs[0].enabled.on_at(400.0)); + assert!(!defs[0].enabled.on_at(600.0)); + } + + /// The real `Feint_Impact_Chest.m2` (the Eviscerate/Feint impact) — the BURST-flag split + /// that bounds its visible burst at ~0.5 s on the reference: plume (e0) and crescents (e3) + /// author file flag 0x8000 (one-shot burst — a single `ftol(rate)` puff at their 67 ms key), + /// lava (e1) and dust (e2) are continuous, choreographed by their enabled windows. The + /// cast-side `Eviscerate_Cast_Hands.m2` is the counter-case: the same 1.6 s asset shape with + /// bit 15 clear — a continuous pour for the whole clip. + #[test] + fn real_feint_impact_authors_burst_emitters() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let impact = parse_m2_particle_emitters( + &chain + .read_file("Spells\\Feint_Impact_Chest.m2") + .expect("read Feint_Impact_Chest.m2"), + ) + .expect("parse emitters"); + assert_eq!(impact.len(), 4); + assert!(impact[0].burst(), "plume is a one-shot burst"); + assert!(!impact[1].burst(), "lava pours (enabled window 0–333 ms)"); + assert!(!impact[2].burst(), "dust pours (enabled window 67–200 ms)"); + assert!(impact[3].burst(), "crescents are a one-shot burst"); + // The STEP rate law that arms the burst count: silent before the 67 ms key, the full + // value at it, held past it (where the burst latch keeps it from ever mattering). + assert_eq!(impact[0].emission_rate.step_ms(50.0), 0.0); + assert_eq!(impact[0].emission_rate.step_ms(67.0), 30.0); + assert_eq!(impact[0].emission_rate.step_ms(1500.0), 30.0); + let cast = parse_m2_particle_emitters( + &chain + .read_file("Spells\\Eviscerate_Cast_Hands.m2") + .expect("read Eviscerate_Cast_Hands.m2"), + ) + .expect("parse emitters"); + assert!( + cast.iter().all(|e| !e.burst()), + "the cast-hands flame is continuous — same asset shape, opposite flag" + ); + } + + /// The real `FlameStrike_Area.m2` — the spline-record law on real content (the + /// `part-spline-file-layout.md` corpus caveat, resolved): e2..e5 are type-3 emitters whose + /// control-point counts land exactly on the `3K+1` flattened-Bézier law (16 and 19), with + /// the repurposed fields reading sanely (t window [0,1], no spin/scatter — the fire + /// columns sit ON their authored descent curves with zero birth velocity). + #[test] + fn real_flamestrike_authors_spline_chains() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let defs = parse_m2_particle_emitters( + &chain + .read_file("Spells\\FlameStrike_Area.m2") + .expect("read FlameStrike_Area.m2"), + ) + .expect("parse emitters"); + let splines: Vec<_> = defs + .iter() + .filter(|d| d.shape == ParticleShape::Spline) + .collect(); + assert_eq!(splines.len(), 4, "the four fire-column emitters"); + for d in &splines { + let s = d.spline.as_ref().expect("chain parses"); + assert_eq!(s.points.len() % 3, 1, "3K+1 control points"); + assert!(s.points.len() >= 16); + assert_eq!(d.area_length, 0.0, "tMin"); + assert_eq!(d.area_width, 1.0, "tMax"); + assert_eq!(d.vertical_range, 0.0, "no tangent spin"); + assert!(d.burst(), "one puff of standing flames"); + // The chain starts high (the authored descent) — a sane, in-model coordinate. + assert!( + (5.0..15.0).contains(&s.points[0][2]), + "z {}", + s.points[0][2] + ); + assert_eq!(s.eval(0.0), s.points[0], "t=0 is the first point"); + } + } + + /// The real `Fire_Cast_Hand.m2` (spell 133's "go" flash) and `MoltenBlast_Impact_Chest.m2` + /// (its impact): the authored choreography this parse exists to carry. Cast hand: constant + /// rates, enabled for exactly the first 200 ms of the clip. Impact emitter 0 (the plume + /// burst): rate ramps 0→60 across the first 133 ms — a full second late under the old raw + /// global-timeline read (seq 0 spans [1000, 2600]). + #[test] + fn real_fireball_effects_rebase_to_clip_time() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cast = parse_m2_particle_emitters( + &chain + .read_file("Spells\\Fire_Cast_Hand.m2") + .expect("read Fire_Cast_Hand.m2"), + ) + .expect("parse emitters"); + assert_eq!(cast.len(), 3); + for em in &cast { + assert!(em.enabled.on_at(100.0), "the flash is ON at its start"); + assert!( + !em.enabled.on_at(250.0), + "and OFF from 200 ms — the 1.0 s clip does not burn through" + ); + } + let impact = parse_m2_particle_emitters( + &chain + .read_file("Spells\\MoltenBlast_Impact_Chest.m2") + .expect("read MoltenBlast_Impact_Chest.m2"), + ) + .expect("parse emitters"); + assert_eq!(impact.len(), 6); + assert_eq!(impact[0].emission_rate.step_ms(0.0), 0.0); + assert_eq!( + impact[0].emission_rate.step_ms(133.0), + 60.0, + "the plume bursts at impact" + ); + assert!(impact[1].enabled.on_at(100.0)); + assert!( + !impact[1].enabled.on_at(400.0), + "shockwave window is 300 ms" + ); + assert!(!impact[4].enabled.on_at(50.0), "smoke starts staggered…"); + assert!(impact[4].enabled.on_at(200.0)); + assert!(!impact[4].enabled.on_at(700.0), "…and ends by 567 ms"); + } +} diff --git a/crates/benilla-formats/src/quest_headers.rs b/crates/benilla-formats/src/quest_headers.rs new file mode 100644 index 00000000..118ea536 --- /dev/null +++ b/crates/benilla-formats/src/quest_headers.rs @@ -0,0 +1,81 @@ +//! Quest-log **header names**: the quest template's `ZoneOrSort` → the section title the list +//! groups under ("Northshire Valley", "Warlock", …). +//! +//! The 1.12 client resolves the sign split the same way: **positive** = an `AreaTable.dbc` id +//! (the zone/subzone name), **negative** = a `QuestSort.dbc` id (class/profession/seasonal sort +//! names). Layouts: +//! - `AreaTable.dbc` 25 × u32 cols: `ID(0) … AreaName(11)` + loc block (the audio catalog's +//! `area_sound.rs` documents the head; only ID + name matter here). +//! - `QuestSort.dbc` 10 × u32 cols: `ID(0), SortName(1)` + the rest of the loc block. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{parse, str_at, u32_at}; + +/// The resolved `ZoneOrSort → name` lookup ([`load_quest_header_names`]). +#[derive(Debug, Default)] +pub struct QuestHeaderNames { + /// `AreaTable.ID → AreaName` (positive ZoneOrSort). + zones: HashMap, + /// `QuestSort.ID → SortName` (negative ZoneOrSort, negated). + sorts: HashMap, +} + +impl QuestHeaderNames { + /// The header title for a quest's `ZoneOrSort`, or `None` for an id neither table knows + /// (including 0 — the caller picks its own fallback bucket). + pub fn resolve(&self, zone_or_sort: i32) -> Option<&str> { + if zone_or_sort > 0 { + self.zones.get(&(zone_or_sort as u32)).map(String::as_str) + } else if zone_or_sort < 0 { + self.sorts + .get(&(zone_or_sort.unsigned_abs())) + .map(String::as_str) + } else { + None + } + } +} + +/// A minimal `ID(0) … name(name_col) …` schema of `cols` u32-wide columns (strings are one u32 +/// ref) — enough for the two id→name reads this module does. +fn id_name_schema(what: &str, name_col: usize, cols: usize) -> Schema { + let mut s = Schema::new(what); + for i in 0..cols { + if i == name_col { + s.add_field(SchemaField::new("Name", FieldType::String)); + } else { + s.add_field(SchemaField::new(format!("c{i}"), FieldType::UInt32)); + } + } + s +} + +/// Load both name tables through the patch chain. +pub fn load_quest_header_names(chain: &mut Chain) -> Result { + let mut read = + |file: &str, name_col: usize, cols: usize, what: &str| -> Result> { + let bytes = chain + .read_file(file) + .with_context(|| format!("reading {file}"))?; + let rs = parse(&bytes, id_name_schema(what, name_col, cols), what)?; + let mut out = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, name_col)) else { + continue; + }; + if !name.is_empty() { + out.insert(id, name); + } + } + Ok(out) + }; + Ok(QuestHeaderNames { + zones: read("DBFilesClient\\AreaTable.dbc", 11, 25, "AreaTable")?, + sorts: read("DBFilesClient\\QuestSort.dbc", 1, 10, "QuestSort")?, + }) +} diff --git a/crates/benilla-formats/src/ribbons.rs b/crates/benilla-formats/src/ribbons.rs new file mode 100644 index 00000000..ac468a3c --- /dev/null +++ b/crates/benilla-formats/src/ribbons.rs @@ -0,0 +1,347 @@ +//! Vanilla (build 5875 / MD20 v256) M2 **ribbon emitter** parsing — the weapon trails, wisp +//! streamers, and spell-missile trails. Read straight from raw bytes, like `particles`. +//! +//! Byte layout is wow-5875-re's transcription-ready spec (`system/models/scratch/ +//! ribbon-emitter-spec.md`, their `9c862186` — a §5 pair over the MD20 relocation fixup + the +//! ctor/render reads; closes the models item-12 remainder for the ribbon record): +//! +//! ```text +//! header array : count @ MD20+0x134, ptr @ MD20+0x138 record stride 0xdc +//! +0x04 boneIndex(u16) +0x08 position(C3Vector, bone-local) +//! +0x14 textureIndices(M2Array → M2 textures) +0x1c materialIndices(M2Array → the +//! M2 render-flags table @MD20+0x84, stride 4: flags u16 + blend u16 — the blend source) +//! +0x24 colorTrack(M2Track) +0x40 alphaTrack(M2Track) +//! +0x5c heightAboveTrack(M2Track) +0x78 heightBelowTrack(M2Track) +//! +0x94 edgesPerSecond(f32) +0x98 edgeLifetime(f32, clamped ≥ 0.25) +0x9c gravity(f32) +//! +0xa0 textureRows(u16) +0xa2 textureCols(u16) +//! +0xa4 texSlotTrack(M2Track) +0xc0 visibilityTrack(M2Track) +//! ``` +//! +//! The **look tracks** (color, alpha, heightAbove/Below) parse as full keyed tracks through the +//! shared [`crate::value_track`] kernel, band-rebased like the particle emission tracks — the +//! one-shot spell effects key them: `HolySmite_Low_Chest.m2` flares its slash ribbons' height +//! `0 → 0.167 → 0` over 267 ms and fades their alpha `1 → 0`, so the old value[0] bake read a +//! permanent zero height and Smite's impact slash never drew (the 0141 lesson, surfaced). +//! `texSlot` stays value[0]-baked (constant in the shipped corpus). The `visibility` gate track +//! (`+0xc0`) IS parsed — **per sequence** ([`RibbonEmitterDef::visible_in_anim`]): the thrown +//! weapon keys its flight trail OFF in Stand (worn in the hand) and Impact (landed), ON only in +//! InFlight (flying), so the spawn site lights it on the missile and never in the hand. This is +//! the multi-sequence gating that was a named seam — the thrown weapon is its driving model. Ramp +//! within a sequence isn't modeled (no shipped ribbon keys it): the band-start value stands. + +use std::io::Cursor; + +use anyhow::Result; +use benilla_m2::parse_m2; + +use crate::value_track::{seq0_band, track_keys_with, ValueTrack}; +use crate::ParticleBlend; + +const STRIDE: usize = 0xdc; +const HDR_COUNT: usize = 0x134; +const HDR_PTR: usize = 0x138; +/// The M2 render-flags (materials) table: `{flags u16, blend u16}` per entry. +const HDR_RENDER_FLAGS: usize = 0x84; + +fn le_u16(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn le_u32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +fn le_f32(b: &[u8], o: usize) -> f32 { + f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +/// value[0] of a vanilla 0x1c M2Track whose values have `elem_size` bytes, decoded by `read`. +/// Track tail: values `{count @ +0x14, offset @ +0x18}`. `default` when empty/out-of-range. +fn track_first( + b: &[u8], + track: usize, + elem_size: usize, + default: T, + read: impl Fn(&[u8], usize) -> T, +) -> T { + if track + 0x1c > b.len() { + return default; + } + let n = le_u32(b, track + 0x14); + let ofs = le_u32(b, track + 0x18) as usize; + if n == 0 || ofs + elem_size > b.len() { + return default; + } + read(b, ofs) +} + +/// One parsed vanilla M2 ribbon emitter, reduced to what the renderer needs. Positions are +/// **bone-local** (WoW axes) — the runtime transforms through the host bone's live matrix each +/// frame, exactly like a particle emitter's origin. +#[derive(Debug, Clone)] +pub struct RibbonEmitterDef { + pub bone: u16, + /// Emission origin in the host bone's frame (the reference multiplies it by the bone matrix + /// per frame — `0x718960`). + pub position: [f32; 3], + /// Trail texture (`.blp` path via `textureIndices[0]` → the M2 textures table). `None` if + /// unresolved (the consumer skips the ribbon). + pub texture: Option, + /// Blend mode from `materialIndices[0]` → the M2 render-flags entry's blend u16 (the same + /// EGxBlend folding as particle/batch blends). + pub blend: ParticleBlend, + /// Trail tint (colorTrack, RGB 0..1, keyed on the clip clock; constant white when unkeyed). + pub color: ValueTrack<[f32; 3]>, + /// Trail opacity (alphaTrack, fixed16/32767, keyed — the slash's fade-out; constant 1.0 when + /// unkeyed). + pub alpha: ValueTrack, + /// Cross-section half-widths (yards) above/below the bone path, keyed — the slash's + /// flare-and-collapse. Sampled at edge-commit time (each edge keeps the width it was born + /// with; the reference stores the vertex pair per edge). + pub height_above: ValueTrack, + pub height_below: ValueTrack, + /// Edge spawn rate (edges/second) — with `edge_lifetime`, sizes the ring. + pub edges_per_second: f32, + /// Edge age-out (seconds) — the reference clamps to ≥ 0.25 at load; so do we. + pub edge_lifetime: f32, + /// Downward sag applied to live edges (the reference's `2·g·dt` per-frame term). + pub gravity: f32, + /// Texture atlas tiling (1×1 = the whole texture) + the (baked) slot index. + pub tile_rows: u16, + pub tile_cols: u16, + pub tex_slot: u16, + /// Per-sequence **visibility** from the `+0xc0` gate track (`M2Track`): `anim_id → whether + /// the trail shows during that sequence`, sampled at the sequence's band start. `None` = no + /// gate — keyless, global-seq-clocked, or ON in every sequence (the always-on majority: enchant + /// trails, wisps). `Some(map)` = the trail is dark in some sequence: the thrown weapon keys it + /// OFF in Stand (worn in hand) and Impact (landed), ON only in InFlight — so the spawn site + /// gates on the sequence its owner runs ([`crate::RibbonEmitterDef`]). The shipped keyed ribbons + /// are constant-per-sequence; ramping mid-sequence isn't modeled (no shipped ribbon does it). + pub visible_in_anim: Option>, +} + +/// Per-sequence visibility from a ribbon's `+0xc0` gate track (`M2Track`, sequence-timeline). +/// Returns `anim_id → whether the trail shows during that sequence`, sampled nearest-previous at +/// each sequence's absolute band start (the shipped keyed ribbons are constant-per-sequence, so +/// the band-start value stands for the whole sequence). `None` when there is nothing to gate: +/// +/// - a keyless / out-of-range track (the always-on loader default), or +/// - a **global-sequence** clock (`gseq != 0xffff`) — a free-running loop, not per-sequence, or +/// - a track that resolves ON in *every* sequence (a keyed-but-always-on author). +/// +/// Sequences: MD20 count @ `0x1c`, offset @ `0x20`, stride `0x44` — `anim_id` @ +0, band `[start, +/// end]` @ +4/+8 (the same walk as [`crate::value_track::seq0_band`], one sequence wider). +fn visibility_by_anim( + bytes: &[u8], + vis_track: usize, +) -> Option> { + if vis_track + 0x1c > bytes.len() { + return None; + } + // gseq != 0xffff: a global-sequence free clock, not keyed per animation — leave it always-on. + if le_u16(bytes, vis_track + 0x02) != 0xffff { + return None; + } + let tn = le_u32(bytes, vis_track + 0x0c) as usize; + let tofs = le_u32(bytes, vis_track + 0x10) as usize; + let vn = le_u32(bytes, vis_track + 0x14) as usize; + let vofs = le_u32(bytes, vis_track + 0x18) as usize; + let n = tn.min(vn); + if n == 0 || tofs + n * 4 > bytes.len() || vofs + n > bytes.len() { + return None; // keyless → the always-on default + } + let keys: Vec<(u32, bool)> = (0..n) + .map(|i| (le_u32(bytes, tofs + i * 4), bytes[vofs + i] != 0)) + .collect(); + + let nseq = le_u32(bytes, 0x1c) as usize; + let oseq = le_u32(bytes, 0x20) as usize; + let mut map = std::collections::HashMap::new(); + let mut any_off = false; + for i in 0..nseq { + let s = oseq + i * 0x44; + if s + 0x0c > bytes.len() { + break; + } + let anim = le_u16(bytes, s); + let start = le_u32(bytes, s + 4); + // Nearest-previous at the band start (M2 step interpolation); before the first key, the + // first key's value; keyless-safe default ON. + let mut on = keys.first().map(|&(_, v)| v).unwrap_or(true); + for &(t, v) in &keys { + if t <= start { + on = v; + } else { + break; + } + } + any_off |= !on; + map.entry(anim).or_insert(on); // variations share an id — the head sequence wins + } + // Nothing dark anywhere ⇒ effectively always-on: no gate to carry. + any_off.then_some(map) +} + +/// Parse an M2's ribbon emitters (see the module doc). Empty when the model has none or isn't a +/// parseable M2. +pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result> { + if bytes.len() < HDR_PTR + 4 || &bytes[0..4] != b"MD20" { + return Ok(Vec::new()); + } + // Texture paths from `benilla-m2`'s textures table (same source as particle textures). + let textures: Vec> = match parse_m2(&mut Cursor::new(bytes)) { + Ok(fmt) => fmt + .model() + .textures + .iter() + .map(|t| { + let f = t + .filename + .string + .to_string_lossy() + .trim_end_matches('\0') + .to_string(); + (!f.is_empty()).then_some(f) + }) + .collect(), + Err(_) => Vec::new(), + }; + let count = le_u32(bytes, HDR_COUNT) as usize; + let base = le_u32(bytes, HDR_PTR) as usize; + let rf_count = le_u32(bytes, HDR_RENDER_FLAGS) as usize; + let rf_base = le_u32(bytes, HDR_RENDER_FLAGS + 4) as usize; + // The first sequence's absolute time band — the keyed look tracks rebase onto it + // (`value_track::seq0_band`; the same window the particle tracks use). + let band = seq0_band(bytes); + let mut out = Vec::with_capacity(count); + for i in 0..count { + let e = base + i * STRIDE; + if e + STRIDE > bytes.len() { + break; + } + // textureIndices[0] → the M2 textures table → path. + let texture = { + let n = le_u32(bytes, e + 0x14); + let ofs = le_u32(bytes, e + 0x18) as usize; + (n > 0 && ofs + 2 <= bytes.len()) + .then(|| le_u16(bytes, ofs) as usize) + .and_then(|ti| textures.get(ti).cloned().flatten()) + }; + // materialIndices[0] → render-flags entry → blend u16. + let blend = { + let n = le_u32(bytes, e + 0x1c); + let ofs = le_u32(bytes, e + 0x20) as usize; + let mat = (n > 0 && ofs + 2 <= bytes.len()) + .then(|| le_u16(bytes, ofs) as usize) + .filter(|&m| m < rf_count && rf_base + m * 4 + 4 <= bytes.len()); + match mat.map(|m| le_u16(bytes, rf_base + m * 4 + 2)) { + Some(3 | 4) => ParticleBlend::Add, + Some(2) => ParticleBlend::Alpha, + Some(_) => ParticleBlend::Opaque, + None => ParticleBlend::Add, // unresolved material: trails are near-always additive + } + }; + out.push(RibbonEmitterDef { + bone: le_u16(bytes, e + 0x04), + position: [ + le_f32(bytes, e + 0x08), + le_f32(bytes, e + 0x0c), + le_f32(bytes, e + 0x10), + ], + texture, + blend, + color: track_keys_with(bytes, e + 0x24, [1.0; 3], band, 12, |b, o| { + [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)] + }), + alpha: track_keys_with(bytes, e + 0x40, 1.0, band, 2, |b, o| { + f32::from(le_u16(b, o)) / 32767.0 + }), + height_above: track_keys_with(bytes, e + 0x5c, 0.0, band, 4, le_f32), + height_below: track_keys_with(bytes, e + 0x78, 0.0, band, 4, le_f32), + edges_per_second: le_f32(bytes, e + 0x94), + edge_lifetime: le_f32(bytes, e + 0x98).max(0.25), + gravity: le_f32(bytes, e + 0x9c), + tile_rows: le_u16(bytes, e + 0xa0).max(1), + tile_cols: le_u16(bytes, e + 0xa2).max(1), + tex_slot: track_first(bytes, e + 0xa4, 2, 0, le_u16), + visible_in_anim: visibility_by_anim(bytes, e + 0xc0), + }); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data test skips when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The real `HolySmite_Low_Chest.m2` (Smite's impact): its yellow slash IS its two ribbon + /// emitters, whose look tracks are KEYED inside the seq band [3300, 4433] — height flares + /// `0 → 0.167 → 0` over the first 267 ms and alpha fades `1 → 0` by 467 ms. The old + /// value[0] bake read a permanent zero height, so the slash never spawned (the director's + /// "we have the glitter but not the slash"). Pins the band rebase and the keyed samples. + #[test] + fn real_holy_smite_slash_ribbons_are_keyed_flares() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Spells\\HolySmite_Low_Chest.m2") + .expect("read HolySmite_Low_Chest.m2"); + let defs = parse_m2_ribbon_emitters(&bytes).expect("parse ribbons"); + assert_eq!(defs.len(), 2); + for r in &defs { + // Height: born at 0 (the value[0] trap), flaring to 0.167 yd at 200 ms clip time. + assert_eq!(r.height_above.first(), 0.0, "value[0] bake = no slash"); + assert!((r.height_above.peak() - 0.167).abs() < 1e-3); + assert!((r.height_above.sample_ms(200.0) - 0.167).abs() < 1e-3); + assert_eq!(r.height_above.sample_ms(400.0), 0.0, "collapsed by 267 ms"); + assert_eq!(r.height_below.keys, r.height_above.keys, "symmetric slash"); + // Alpha: full through the flare, gone by 467 ms. + assert_eq!(r.alpha.sample_ms(0.0), 1.0); + assert!(r.alpha.sample_ms(600.0) < 1e-6); + // Band rebase happened: every key sits on the 0-based clip clock, inside the + // 1133 ms clip. + assert!(r.height_above.keys.iter().all(|&(t, _)| t <= 1133)); + assert_eq!(r.color.first(), [1.0; 3]); + // Smite's slash is a one-shot flash, not visibility-gated per sequence — the trail is + // ON wherever it plays (its alpha, above, does the fade). So it must carry NO gate, + // or the spawn site would wrongly darken it against a Stand default. + assert_eq!( + r.visible_in_anim, None, + "an always-on slash carries no visibility gate" + ); + } + } + + /// The thrown dagger's flight trail is **visibility-gated per sequence** (`+0xc0`): its ribbon + /// is dark in Stand (worn in the hand) and Impact (landed), lit only in InFlight (flying). This + /// is the seam the multi-sequence gate closes — the worn item resting in Stand shows no trail; + /// the InFlight missile does. Pins the per-sequence resolve against the shipped asset. + #[test] + fn real_thrown_dagger_trail_is_lit_only_in_flight() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Item\\ObjectComponents\\Weapon\\Thrown_1H_Dagger_A_01.m2") + .expect("read Thrown_1H_Dagger_A_01.m2"); + let defs = parse_m2_ribbon_emitters(&bytes).expect("parse ribbons"); + assert_eq!(defs.len(), 1, "the dagger authors one trail ribbon"); + let vis = defs[0] + .visible_in_anim + .as_ref() + .expect("the thrown dagger's trail IS visibility-gated"); + assert_eq!(vis.get(&0), Some(&false), "Stand (worn in hand): dark"); + assert_eq!(vis.get(&144), Some(&true), "InFlight (flying): lit"); + assert_eq!(vis.get(&191), Some(&false), "Impact (landed): dark"); + } +} diff --git a/crates/benilla-formats/src/sheathe.rs b/crates/benilla-formats/src/sheathe.rs new file mode 100644 index 00000000..46f65090 --- /dev/null +++ b/crates/benilla-formats/src/sheathe.rs @@ -0,0 +1,123 @@ +//! `SheatheSoundLookups.dbc` — the draw/stow sound table (the client's runtime +//! `AUSHEATHSOUNDHASH`; wow-re sound node, wave2-CD). +//! +//! Layout — VERIFIED against build 5875 (header + full dump with SoundEntries name joins, +//! 2026-07-03): **33 × 7 × 28 B**: `ID(0), ItemClass(1), ItemSubclass(2), Material(3), +//! _col4(4), SheatheSound(5), UnsheatheSound(6)`. The data forms three families: +//! shields (class 4, subclasses 5/6, material 0 → kits 696/701 `SHEATHINGSHIELD*`), metal +//! weapons (class 2 × subclasses 0–14, material 1 → 698/700 `SHEATHINGMETALWEAPON*`) and wood +//! weapons (material 2 → 697/699 `SHEATHINGWOODWEAPON*`), plus one material-0 weapon default +//! row (→ wood). Column 4 is 0 on shield rows and 1 on weapon rows — meaning unrecorded, +//! unused here. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +/// One row's kit pair. +#[derive(Clone, Copy)] +pub struct SheatheSounds { + /// Stowing the item (SoundEntries kit). + pub sheathe: u32, + /// Drawing it. + pub unsheathe: u32, +} + +/// `(ItemClass, ItemSubclass, Material)` → the kit pair. +pub struct SheatheSoundCatalog { + rows: HashMap<(u32, u32, u32), SheatheSounds>, +} + +impl SheatheSoundCatalog { + /// The kit pair for an item, preferring the exact material row and falling back to any + /// material carried for that `(class, subclass)` (players' item material isn't on the wire — + /// the caller passes its best guess), then to the class's subclass-0 row: the 5875 table + /// only covers weapon subclasses 0–14, so daggers (15) and other stragglers ring like a + /// generic weapon of their material (INTERIM — the client's hash-miss behavior is + /// unrecorded). + pub fn get(&self, class: u32, subclass: u32, material: u32) -> Option { + [subclass, 0] + .iter() + .find_map(|&sc| { + [material, 1, 2, 0] + .iter() + .find_map(|&m| self.rows.get(&(class, sc, m))) + }) + .copied() + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("SheatheSoundLookups"); + for i in 0..7 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +/// Read `SheatheSoundLookups.dbc` off the patch chain. +pub fn load_sheathe_sound_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\SheatheSoundLookups.dbc") + .context("reading SheatheSoundLookups.dbc")?; + let rs = parse(&bytes, schema(), "SheatheSoundLookups")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let g = |i: usize| u32_at(r, i).unwrap_or(0); + rows.insert( + (g(1), g(2), g(3)), + SheatheSounds { + sheathe: g(5), + unsheathe: g(6), + }, + ); + } + Ok(SheatheSoundCatalog { rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: a metal 1H sword (2/7/1) draws with 700 and stows with 698; a + /// shield (4/6) hits the shield pair; the material fallback resolves an unknown material. + /// Skips without client data. + #[test] + fn real_sheathe_sounds_decode() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_sheathe_sound_catalog(&mut chain).expect("load sheathe sounds"); + assert_eq!(cat.len(), 33); + + let sword = cat.get(2, 7, 1).expect("metal 1H sword"); + assert_eq!((sword.sheathe, sword.unsheathe), (698, 700)); + + let staff_wood = cat.get(2, 10, 2).expect("wood staff"); + assert_eq!((staff_wood.sheathe, staff_wood.unsheathe), (697, 699)); + + let shield = cat.get(4, 6, 0).expect("shield"); + assert_eq!((shield.sheathe, shield.unsheathe), (696, 701)); + + // Daggers (15) have NO row in 5875 — they fall back to the class's subclass-0 metal + // pair (and an unlisted material to the material chain). + let dagger = cat.get(2, 15, 1).expect("dagger falls back to subclass 0"); + assert_eq!((dagger.sheathe, dagger.unsheathe), (698, 700)); + assert!(cat.get(2, 15, 7).is_some(), "weird material still resolves"); + } +} diff --git a/crates/benilla-formats/src/skill_lines.rs b/crates/benilla-formats/src/skill_lines.rs new file mode 100644 index 00000000..7e62494e --- /dev/null +++ b/crates/benilla-formats/src/skill_lines.rs @@ -0,0 +1,630 @@ +//! `SkillLine.dbc` + `SkillLineAbility.dbc` loader — spell id → skill line → {name, icon}, the +//! spellbook's tab source (decision 0216 §8: "tabs = the class skill lines of known spells"). +//! +//! Layout — VERIFIED against the **vmangos server source** +//! (`vmangos-src/src/game/Database/DBCStructure.h`'s `SkillLineEntry`/`SkillLineAbilityEntry` +//! structs + `DBCfmt.h`'s `SkillLinefmt`/`SkillLineAbilityfmt` format strings): vmangos parses +//! these two tables straight off the same build-5875 client data benilla reads, so its struct +//! layout — not an empirical guess, not even wow-re's own notes (none exist for these two tables, +//! grepped `system/dbc`) — is the strongest ground available, the same standing this codebase +//! already gives vmangos's wire handlers (decision 0216's own citations of `ItemHandler.cpp`/ +//! `Player.cpp`). +//! +//! `SkillLine.dbc` — `SkillLinefmt = "nixssssssssxxxxxxxxxxi"` (22 fields, 88 B/record): +//! `id`(0, indexed) · `categoryId`(1, int32) · `skillCostID`(2, unused) · the 8-locale +//! `displayName_lang` block (3..10, enUS first ⇒ **NameEnUs = column 3**) + its flags word (11) · +//! the 8-locale `description_lang` block (12..19; enUS **column 12** = the skills pane's +//! detail-pane body, `GetSkillLineInfo`'s 13th return) + its flags word (20) · +//! **`spellIcon` = column 21** (a `SpellIcon.dbc` id — [`crate::dbc::load_spell_icon_map`], the +//! same table `spells.rs`'s action-bar catalog joins against). +//! +//! `SkillLineAbility.dbc` — `SkillLineAbilityfmt = "niiiixxiiiiixxi"` (15 fields, 60 B/record): +//! `id`(0, indexed) · **`skillId` = column 1** · **`spellId` = column 2** · `racemask`(3) · +//! `classmask`(4) · `req_skill_value`(7; 5-6 unused: `racemaskNot`/`classmaskNot`, always 0 this +//! build) · `forward_spellid`(8) · `learnOnGetSkill`(9) · `max_value`(10) · `min_value`(11) · +//! `reqtrainpoints`(14; 12-13 unused). Read into one [`SlaInfo`] per spell (a spell can carry +//! more than one row across race/class variants; the FIRST row wins, deterministic by file +//! order — [`SkillLineCatalog::spell_to_line`]'s long-standing convention). `max_value`/ +//! `min_value` are the recipe-difficulty trivial ranks (TrivialSkillLineRankHigh/Low): pinned on +//! the raw 5875 file this session — Bolt of Linen Cloth 2963 → (line 197, req 1, low 25, +//! high 50), Minor Healing Potion 2330 → (171, 1, 55, 95), which reproduces its known classic +//! orange 1 / yellow 55 / green 75 / gray 95 progression under the color law TU-C confirmed at +//! the bytes (decision 0446). +//! +//! `SkillRaceClassInfo.dbc` — `SkillRaceClassInfofmt = "diiiiiix"` (8 fields, 32 B/record): +//! `id`(0) · **`skillId` = column 1** · **`raceMask` = column 2** · **`classMask` = column 3** · +//! **`flags` = column 4** · `reqLevel`(5) · `skillTierId`(6) · `skillCostID`(7, unused). This is +//! the table the client's spellbook tab classifier routes through (decision 0228): a spell's skill +//! line is looked up here for the player's race+class, and if the matching row's `flags` bit `0x80` +//! (`SKILL_FLAG_DISPLAY_SORTED`, cmangos `DBCEnums.h`) is set — or no row matches — the spell's tab +//! is **General** (key 0) instead of the line's own tab. Byte-verified: wow-re +//! `system/ui/scratch/spellbook-book-build.md` §3 (`0x6ddf90(skillLine, class, race) → variant`; +//! `(int8)[variant+0x10] < 0 → key 0`; `[variant+4]` = skillId, `[variant+0x10]` = flags — the +//! struct offsets confirm the column read). The `flags`/`raceMask`/`classMask` semantics follow +//! vmangos `DBCStructure.h`'s `SkillRaceClassInfoEntry`; the row-match (first row whose masks +//! admit the race/class) is the standard classic semantics, validated against the real build-5875 +//! data by [`SkillLineCatalog::spell_tab`]'s tests. +//! +//! `SkillLineCategory.dbc` — byte-checked on the raw 5875 file (a struct-unpack dump: 8 records × +//! 11 fields, 44 B/record): `id`(0) · the 8-locale `name` block (enUS ⇒ **column 1**) + flags(9) · +//! **`displayOrder` = column 10** — the skills pane's header vocabulary and group order (decision +//! 0437 phase 4): Class Skills(7, order 2) · Professions(11, 3) · Secondary(9, 4) · Weapon(6, 5) · +//! Armor(8, 6) · Languages(10, 7); `Attributes`(5, 1) never carries player rows and +//! `Not Displayed`(12, 8) is the hide bucket. A skill line's own `categoryId` is `SkillLine.dbc` +//! column 1 (the `SkillLinefmt` layout above). +//! +//! Skill line ids are stable, well-known constants across the whole classic tool ecosystem +//! (vmangos `SharedDefines.h`'s `SkillType` enum, itself commented "Data from SpellLine.dbc (1.12.1 +//! checked)") — Frost=6, Fire=8, … — cross-checked directly by this module's own real-data tests. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const SKILL_LINE: &str = "DBFilesClient\\SkillLine.dbc"; +const SKILL_LINE_ABILITY: &str = "DBFilesClient\\SkillLineAbility.dbc"; +const SKILL_RACE_CLASS_INFO: &str = "DBFilesClient\\SkillRaceClassInfo.dbc"; + +const SKILL_LINE_FIELDS: usize = 22; +const COL_SL_NAME_ENUS: usize = 3; +const COL_SL_DESC_ENUS: usize = 12; +const COL_SL_SPELL_ICON: usize = 21; + +const SKILL_LINE_ABILITY_FIELDS: usize = 15; +const COL_SLA_SKILL_ID: usize = 1; +const COL_SLA_SPELL_ID: usize = 2; +const COL_SLA_REQ_SKILL_VALUE: usize = 7; +const COL_SLA_TRIVIAL_HIGH: usize = 10; +const COL_SLA_TRIVIAL_LOW: usize = 11; + +const SKILL_LINE_CATEGORY: &str = "DBFilesClient\\SkillLineCategory.dbc"; +const SKILL_LINE_CATEGORY_FIELDS: usize = 11; +const COL_SLC_NAME_ENUS: usize = 1; +const COL_SLC_ORDER: usize = 10; +/// `SkillLineCategory` id 12 — "Not Displayed": the skills pane hides lines in this bucket. +pub const SKILL_CATEGORY_NOT_DISPLAYED: u32 = 12; + +const SKILL_RACE_CLASS_INFO_FIELDS: usize = 8; +const COL_SRCI_SKILL_ID: usize = 1; +const COL_SRCI_RACE_MASK: usize = 2; +const COL_SRCI_CLASS_MASK: usize = 3; +const COL_SRCI_FLAGS: usize = 4; + +/// `SkillRaceClassInfo.flags` bit `0x80` — cmangos `DBCEnums.h`'s `SKILL_FLAG_DISPLAY_SORTED`. The +/// spellbook tab classifier reads it as the low byte's sign (`(int8) < 0`): set ⇒ the skill line's +/// spells sort into the **General** tab rather than the line's own (decision 0228). Real +/// build-5875 data for a human warrior: set on `Racial - Human`, `GENERIC (DND)`, the proficiency/ +/// language/riding lines; clear on the class combat lines (`Arms`/`Fury`/`Protection`). +const SKILL_FLAG_DISPLAY_SORTED: u32 = 0x80; + +/// `SkillRaceClassInfo.flags` bit `0x20` — vmangos `DBCEnums.h`'s `SKILL_FLAG_UNLEARNABLE` +/// ("Skill can be unlearned"): the skills pane's unlearn-button gate, and the exact bit the +/// server's own `CMSG_UNLEARN_SKILL` handler enforces (vmangos `SkillHandler.cpp` — a request +/// for a line without it is dropped and anticheat-flagged, so the client must never offer it). +const SKILL_FLAG_UNLEARNABLE: u32 = 0x20; + +/// One skill line's display identity (`SkillLine.dbc`) — a spellbook tab's name + icon, and the +/// skills pane's grouping key. +#[derive(Clone, Debug, PartialEq)] +pub struct SkillLineInfo { + pub name: String, + /// `categoryId` (column 1) — the [`SkillLineCategory`](Self) bucket the skills pane groups + /// this line under ([`SkillLineCatalog::category`]); 0 when absent. + pub category_id: u32, + /// The tab icon's MPQ path (`Interface\Icons\…`, extensionless); `None` when the line's + /// `spellIcon` id is 0/unresolved (render the fallback question mark, the spell catalog's own + /// convention). + pub icon: Option, + /// `description_lang` enUS (column 12) — the skills pane's detail-pane body + /// (`GetSkillLineInfo`'s 13th return). Professions carry the trade's flavor sentence, weapon + /// lines the shared "Higher weapon skill increases your chance to hit."; empty when the row + /// has none. + pub description: String, +} + +/// One `SkillRaceClassInfo.dbc` row's tab-classification inputs: which race/class it admits and +/// whether its skill line's spells sort into General ([`SKILL_FLAG_DISPLAY_SORTED`]). +#[derive(Clone, Copy, Debug)] +struct SrciRow { + race_mask: u32, + class_mask: u32, + flags: u32, +} + +/// One spell's `SkillLineAbility.dbc` row (module doc columns; first row wins across race/class +/// variants): the skill line it belongs to, the rank required to learn it, and the trivial ranks +/// the crafting book's difficulty colors band against (the color law TU-C confirmed at the bytes, +/// decision 0446). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SlaInfo { + /// `skillId` (column 1) — the owning skill line. + pub skill_id: u32, + /// `req_skill_value` (column 7) — the line rank required to learn/use the ability. + pub req_skill_value: u32, + /// `min_value` (column 11) — TrivialSkillLineRankLow: yellow at-or-above, orange below. + pub trivial_low: u32, + /// `max_value` (column 10) — TrivialSkillLineRankHigh: gray at-or-above; green at-or-above + /// the low/high midpoint. Both 0 on non-recipe rows (class abilities, the openers). + pub trivial_high: u32, +} + +/// `SkillLine.dbc` × `SkillLineAbility.dbc` × `SkillRaceClassInfo.dbc`, joined: a spell's skill +/// line, a line's display, and the per-race/class tab routing (the General collapse). +pub struct SkillLineCatalog { + lines: HashMap, + abilities: HashMap, + /// `SkillLineCategory.dbc`: id → (enUS name, displayOrder) — the skills pane's headers. + categories: HashMap, + /// skill line id → its `SkillRaceClassInfo` rows (empty when the DBC failed to load — then + /// [`Self::spell_tab`] skips the General collapse and keeps each line its own tab). + race_class: HashMap>, +} + +impl SkillLineCatalog { + /// The skill line a spell belongs to, if `SkillLineAbility.dbc` names one. + pub fn spell_to_line(&self, spell_id: u32) -> Option { + self.abilities.get(&spell_id).map(|a| a.skill_id) + } + + /// A spell's full `SkillLineAbility` row ([`SlaInfo`]) — the crafting book's difficulty and + /// requirement source (0437). + pub fn ability(&self, spell_id: u32) -> Option<&SlaInfo> { + self.abilities.get(&spell_id) + } + + /// The spellbook **tab** a spell lands in for a character of `race`/`class` (1-based unit + /// bytes): the spell's skill line, unless that line routes to General (decision 0228). Returns + /// `0` (the General tab) when the spell has no skill line, no `SkillRaceClassInfo` row admits + /// this race/class, or the matching row carries [`SKILL_FLAG_DISPLAY_SORTED`]; the line's own + /// id otherwise. With `race`/`class` `0` or out of range (unknown character), or when no + /// `SkillRaceClassInfo` data loaded, the collapse is skipped — the raw skill line is returned. + pub fn spell_tab(&self, spell_id: u32, race: u8, class: u8) -> u32 { + let Some(line) = self.spell_to_line(spell_id) else { + return 0; // no skill line → General + }; + // No character context, or no routing data — keep the raw line (pre-collapse behavior). + if self.race_class.is_empty() || !(1..=32).contains(&race) || !(1..=32).contains(&class) { + return line; + } + match self.srci_row(line, race, class) { + // A matching row without the sort flag keeps the line's own tab. + Some(r) if r.flags & SKILL_FLAG_DISPLAY_SORTED == 0 => line, + // The sort flag, or no admitting row for this race/class → General. + _ => 0, + } + } + + /// The first `SkillRaceClassInfo` row of `line_id` admitting a 1-based `race`/`class` (mask + /// `0` admits all) — the standard classic row-match ([`Self::spell_tab`]'s own, factored out + /// for [`Self::abandonable`]). `None` for out-of-range race/class or no admitting row. + fn srci_row(&self, line_id: u32, race: u8, class: u8) -> Option<&SrciRow> { + if !(1..=32).contains(&race) || !(1..=32).contains(&class) { + return None; + } + let race_bit = 1u32 << (race - 1); + let class_bit = 1u32 << (class - 1); + self.race_class.get(&line_id).and_then(|rows| { + rows.iter().find(|r| { + (r.race_mask == 0 || r.race_mask & race_bit != 0) + && (r.class_mask == 0 || r.class_mask & class_bit != 0) + }) + }) + } + + /// Whether `line_id` can be unlearned by a character of `race`/`class` (1-based unit bytes): + /// the admitting `SkillRaceClassInfo` row carries [`SKILL_FLAG_UNLEARNABLE`] (`0x20`) — the + /// skills pane's unlearn-button predicate, and byte-for-byte the server's own gate (vmangos + /// `SkillHandler.cpp`). `false` with no routing data, unknown race/class, or no admitting + /// row — a missing button beats offering an unlearn the server would anticheat-flag. + pub fn abandonable(&self, line_id: u32, race: u8, class: u8) -> bool { + self.srci_row(line_id, race, class) + .is_some_and(|r| r.flags & SKILL_FLAG_UNLEARNABLE != 0) + } + + /// A skill line's display (name + tab icon), by id. + pub fn line(&self, line_id: u32) -> Option<&SkillLineInfo> { + self.lines.get(&line_id) + } + + /// A `SkillLineCategory.dbc` row's `(name, displayOrder)` — the skills pane's header for a + /// line's [`SkillLineInfo::category_id`]; `None` for 0/unknown. + pub fn category(&self, category_id: u32) -> Option<(&str, u32)> { + self.categories + .get(&category_id) + .map(|(n, o)| (n.as_str(), *o)) + } + + pub fn len(&self) -> usize { + self.lines.len() + } + + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +fn skill_line_schema() -> Schema { + let mut s = Schema::new("SkillLine"); + for i in 0..SKILL_LINE_FIELDS { + if i == COL_SL_NAME_ENUS { + s.add_field(SchemaField::new("NameEnUs", FieldType::String)); + } else if i == COL_SL_DESC_ENUS { + s.add_field(SchemaField::new("DescEnUs", FieldType::String)); + } else { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + } + s +} + +fn skill_line_ability_schema() -> Schema { + let mut s = Schema::new("SkillLineAbility"); + for i in 0..SKILL_LINE_ABILITY_FIELDS { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + s +} + +fn skill_line_category_schema() -> Schema { + let mut s = Schema::new("SkillLineCategory"); + for i in 0..SKILL_LINE_CATEGORY_FIELDS { + let ty = if i == COL_SLC_NAME_ENUS { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("F{i}"), ty)); + } + s +} + +/// Load `SkillLineCategory.dbc` — id → (name, displayOrder). Missing/unparseable degrades to an +/// empty map (the skills pane then renders one flat group). +fn load_categories(chain: &mut Chain) -> HashMap { + let mut map = HashMap::new(); + let Ok(bytes) = chain.read_file(SKILL_LINE_CATEGORY) else { + return map; + }; + let Ok(set) = parse( + &bytes, + skill_line_category_schema(), + "SkillLineCategory.dbc", + ) else { + return map; + }; + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(name) = str_at(&set, r, COL_SLC_NAME_ENUS) { + map.insert(id, (name, u32_at(r, COL_SLC_ORDER).unwrap_or(0))); + } + } + map +} + +fn skill_race_class_info_schema() -> Schema { + let mut s = Schema::new("SkillRaceClassInfo"); + for i in 0..SKILL_RACE_CLASS_INFO_FIELDS { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + s +} + +/// The `SkillRaceClassInfo.dbc` rows keyed by skill line — the General-collapse routing table. A +/// missing/unparseable file returns an empty map (the caller degrades to "each line its own tab"). +fn load_race_class_info(chain: &mut Chain) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + let bytes = match chain.read_file(SKILL_RACE_CLASS_INFO) { + Ok(b) => b, + Err(_) => return map, + }; + let set = match parse( + &bytes, + skill_race_class_info_schema(), + "SkillRaceClassInfo.dbc", + ) { + Ok(s) => s, + Err(_) => return map, + }; + for r in set.records() { + let Some(skill) = u32_at(r, COL_SRCI_SKILL_ID) else { + continue; + }; + map.entry(skill).or_default().push(SrciRow { + race_mask: u32_at(r, COL_SRCI_RACE_MASK).unwrap_or(0), + class_mask: u32_at(r, COL_SRCI_CLASS_MASK).unwrap_or(0), + flags: u32_at(r, COL_SRCI_FLAGS).unwrap_or(0), + }); + } + map +} + +/// Load the joined skill-line catalog off the patch chain. +pub fn load_skill_line_catalog(chain: &mut Chain) -> Result { + let icons = crate::dbc::load_spell_icon_map(chain)?; + + let sl_bytes = chain + .read_file(SKILL_LINE) + .context("reading SkillLine.dbc")?; + let sl_set = parse(&sl_bytes, skill_line_schema(), "SkillLine.dbc")?; + let mut lines: HashMap = HashMap::new(); + for r in sl_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let name = str_at(&sl_set, r, COL_SL_NAME_ENUS).unwrap_or_default(); + let icon = u32_at(r, COL_SL_SPELL_ICON) + .filter(|&i| i != 0) + .and_then(|i| icons.get(&i).cloned()); + let category_id = u32_at(r, 1).unwrap_or(0); + let description = str_at(&sl_set, r, COL_SL_DESC_ENUS).unwrap_or_default(); + lines.insert( + id, + SkillLineInfo { + name, + category_id, + icon, + description, + }, + ); + } + + let sla_bytes = chain + .read_file(SKILL_LINE_ABILITY) + .context("reading SkillLineAbility.dbc")?; + let sla_set = parse( + &sla_bytes, + skill_line_ability_schema(), + "SkillLineAbility.dbc", + )?; + let mut abilities: HashMap = HashMap::new(); + for r in sla_set.records() { + if let (Some(skill_id), Some(spell_id)) = + (u32_at(r, COL_SLA_SKILL_ID), u32_at(r, COL_SLA_SPELL_ID)) + { + // First row wins (module doc): deterministic, and every probed spell in the tests + // below has exactly one row anyway. + abilities.entry(spell_id).or_insert(SlaInfo { + skill_id, + req_skill_value: u32_at(r, COL_SLA_REQ_SKILL_VALUE).unwrap_or(0), + trivial_low: u32_at(r, COL_SLA_TRIVIAL_LOW).unwrap_or(0), + trivial_high: u32_at(r, COL_SLA_TRIVIAL_HIGH).unwrap_or(0), + }); + } + } + + let race_class = load_race_class_info(chain); + let categories = load_categories(chain); + + Ok(SkillLineCatalog { + lines, + abilities, + categories, + race_class, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolve real spells to their real skill lines on the build-5875 data, cross-checked + /// against vmangos `SharedDefines.h`'s `SkillType` enum (Frost=6, Fire=8 — the module doc's + /// own citation). Skips without client data. + #[test] + fn real_skill_line_catalog_resolves_known_spells() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load SkillLine/SkillLineAbility"); + assert!( + cat.len() > 50, + "a real skill-line table has hundreds of rows" + ); + + // Fireball (133) -> the Fire line (SKILL_FIRE = 8). + let fire_line = cat.spell_to_line(133).expect("Fireball has a skill line"); + assert_eq!(fire_line, 8); + let fire = cat.line(fire_line).expect("the Fire line resolves"); + assert_eq!(fire.name, "Fire"); + assert!(fire.icon.is_some(), "the Fire tab has an icon"); + + // Frost Armor (168) -> the Frost line (SKILL_FROST = 6). + let frost_line = cat + .spell_to_line(168) + .expect("Frost Armor has a skill line"); + assert_eq!(frost_line, 6); + let frost = cat.line(frost_line).expect("the Frost line resolves"); + assert_eq!(frost.name, "Frost"); + assert!(frost.icon.is_some(), "the Frost tab has an icon"); + + // An unknown spell id has no line. + assert_eq!(cat.spell_to_line(0), None); + + // The description column (12): a profession line carries the flavor sentence, a weapon + // line is blank — a column slip lands on another locale (empty for enUS data) or the + // name flags word (a parse error) and fails loudly. + let smithing = cat.line(164).expect("Blacksmithing resolves"); + assert!( + smithing.description.to_lowercase().contains("blacksmith"), + "Blacksmithing's description names the trade: {:?}", + smithing.description + ); + let swords = cat.line(43).expect("Swords resolves"); + assert_eq!( + swords.description, "Higher weapon skill increases your chance to hit.", + "the weapon lines' shared byte-exact sentence" + ); + } + + /// The General collapse on real build-5875 `SkillRaceClassInfo.dbc` (decision 0228), traced on + /// concrete spells for a human warrior (race 1, class 1) vs. a human mage (race 1, class 8): + /// class-native combat lines keep their own tab, racials collapse to General, and a cross-class + /// spell (a warrior's cheated Fireball) collapses to General while the SAME spell keeps its Fire + /// tab for a mage — the class/race dependence, the whole point of the routing. Skips without + /// client data. + #[test] + fn real_spell_tab_collapses_general_by_race_and_class() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + const HUMAN: u8 = 1; + const WARRIOR: u8 = 1; + const MAGE: u8 = 8; + + // Warrior class abilities keep their own class-line tabs (flag clear for a warrior): + // Charge/Heroic Strike/Rend on Arms (26), Battle Shout on Fury (256). + for (id, line) in [(100u32, 26u32), (78, 26), (772, 26), (6673, 256)] { + assert_eq!(cat.spell_to_line(id), Some(line)); + assert_eq!( + cat.spell_tab(id, HUMAN, WARRIOR), + line, + "warrior class ability {id} keeps its own tab {line}" + ); + } + + // A human racial (Perception, line 754 "Racial - Human") collapses to General (0). + assert_eq!(cat.spell_to_line(20600), Some(754)); + assert_eq!( + cat.spell_tab(20600, HUMAN, WARRIOR), + 0, + "a human racial routes to General" + ); + + // Fireball (Fire line 8): class-dependent. A warrior has no Fire race/class row → General; + // a mage has one, flag clear → its own Fire tab. Same spell, different tab by class. + assert_eq!(cat.spell_to_line(133), Some(8)); + assert_eq!( + cat.spell_tab(133, HUMAN, WARRIOR), + 0, + "a warrior's cross-class Fireball collapses to General" + ); + assert_eq!( + cat.spell_tab(133, HUMAN, MAGE), + 8, + "a mage's Fireball keeps its Fire tab" + ); + + // Unknown character (race/class 0): the collapse is skipped — the raw line stands. + assert_eq!(cat.spell_tab(133, 0, 0), 8); + } + + /// The `SlaInfo` columns on the real build-5875 `SkillLineAbility.dbc` (0437) — expected + /// values read straight off the raw file's rows this session (a struct-unpack dump, module + /// doc): recipes carry the trivial ranks, the profession openers carry zeros. A column slip + /// fails loudly. Skips without client data. + #[test] + fn real_skill_line_ability_reads_requirement_and_trivial_ranks() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // Recipes: (spell, line, req, trivial_low, trivial_high) — raw rows, module doc. + // 2963 Bolt of Linen Cloth (Tailoring 197), 2330 Minor Healing Potion (Alchemy 171), + // 2538 Charred Wolf Meat (Cooking 185), 3920 Crafted Light Shot (Engineering 202). + for (spell, line, req, low, high) in [ + (2963u32, 197u32, 1u32, 25u32, 50u32), + (2330, 171, 1, 55, 95), + (2538, 185, 1, 45, 85), + (3920, 202, 1, 30, 60), + ] { + let sla = cat.ability(spell).expect("recipe has an SLA row"); + assert_eq!( + ( + sla.skill_id, + sla.req_skill_value, + sla.trivial_low, + sla.trivial_high + ), + (line, req, low, high), + "spell {spell}" + ); + } + + // The openers (effect-47 window spells) sit on their line with zero trivial ranks: + // 3908 Tailoring → 197, 7411 Enchanting → 333. + for (spell, line) in [(3908u32, 197u32), (7411, 333)] { + let sla = cat.ability(spell).expect("opener has an SLA row"); + assert_eq!( + (sla.skill_id, sla.trivial_low, sla.trivial_high), + (line, 0, 0) + ); + } + } + + /// `SkillLineCategory.dbc` + the line→category join on the real build-5875 files (0437 phase + /// 4) — expected values read straight off the raw dump (module doc). Skips without client + /// data. + #[test] + fn real_skill_categories_name_and_order_the_pane_groups() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // The raw rows: (id, name, displayOrder). + assert_eq!(cat.category(7), Some(("Class Skills", 2))); + assert_eq!(cat.category(11), Some(("Professions", 3))); + assert_eq!(cat.category(9), Some(("Secondary Skills", 4))); + assert_eq!(cat.category(6), Some(("Weapon Skills", 5))); + assert_eq!(cat.category(10), Some(("Languages", 7))); + assert_eq!( + cat.category(SKILL_CATEGORY_NOT_DISPLAYED), + Some(("Not Displayed", 8)) + ); + assert_eq!(cat.category(0), None); + + // The join: Tailoring (197) is a Profession; First Aid (129) is Secondary; the Fire + // school (8) is a Class Skill; Common (98) is a Language. + for (line, category) in [(197u32, 11u32), (129, 9), (8, 7), (98, 10)] { + assert_eq!( + cat.line(line).map(|l| l.category_id), + Some(category), + "line {line}" + ); + } + } + + /// `SkillRaceClassInfo.flags & 0x20` on the real build-5875 file: professions and secondary + /// skills are abandonable, class/weapon/language lines are not — the unlearn button's real + /// data split (a human warrior, race 1 / class 1). Skips without client data. + #[test] + fn real_abandonable_split_professions_yes_weapons_no() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_skill_line_catalog(&mut chain).expect("load skill lines"); + + // PRIMARY professions carry 0xA0 (0x80 sort | 0x20 unlearnable) — the red circle-slash: + // Blacksmithing, Tailoring, Engineering, Alchemy, Enchanting, Leatherworking, Skinning. + for line in [164u32, 197, 202, 171, 333, 165, 393] { + assert!(cat.abandonable(line, 1, 1), "line {line} is abandonable"); + } + // SECONDARY skills (First Aid/Fishing/Cooking) are 0x80 only — famously NOT droppable in + // 1.12 — and class school (Fire: no human-warrior row at all) / weapon / Defense / + // language / riding lines aren't either. + for line in [129u32, 356, 185, 8, 43, 95, 98, 762] { + assert!(!cat.abandonable(line, 1, 1), "line {line} is not"); + } + // Unknown race/class → no button (the conservative arm). + assert!(!cat.abandonable(164, 0, 0)); + } +} diff --git a/crates/benilla-formats/src/sound_entries.rs b/crates/benilla-formats/src/sound_entries.rs new file mode 100644 index 00000000..a0ee3aa3 --- /dev/null +++ b/crates/benilla-formats/src/sound_entries.rs @@ -0,0 +1,218 @@ +//! SoundEntries.dbc loader — the central **sound-kit** table every audio trigger resolves through +//! (decision 0070): a kit = up to 10 weighted variation files + volume/flags/distance parameters. +//! Kits are playable by id and **by name** (the client's `PlaySoundByName`, RE `0x458030`, is a +//! name-hash into this table — the Lua `PlaySound("igMainMenuOpen")` path). +//! +//! Layout — VERIFIED against build 5875 (xxd + row decode of the extracted file, 2026-07-02): the +//! WDBC header reports **4623 records · 29 fields · 116 B/record**. Fields: +//! `ID(0), SoundType(1), Name(2, str), File[10](3..12, str), Freq[10](13..22), +//! DirectoryBase(23, str), Volume(24, f32), Flags(25), MinDistance(26, f32), +//! DistanceCutoff(27, f32), EAXDef(28)`. Spot-checked on row 3: `type 1 "Invisibility Impact", +//! "Dispel_Low_Base.wav" ×1, dir "Sound\Spells", vol 1.0, flags 0, min 8, cutoff 45, EAX 2`. +//! NOTE the wowdev-wiki 5875 struct claims a 30-column layout (separate `maxDistance` + +//! `soundEntriesAdvancedID`) — it is **wrong**; trust this byte-verified one (decision 0070). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; + +const SOUND_ENTRIES: &str = "DBFilesClient\\SoundEntries.dbc"; + +/// `Flags` bits observed in the 5875 data (domain: 0/1/0x20/0x21/0x22/0x200/0x201/0x220/0x400/ +/// 0x420). The DBC word is copied **raw** into the runtime kit flag word (`0x45c139`, wow-re +/// `benilla-pins.md` B2, VERIFIED) and the two variation gates read separate bits: `0x400` = +/// pitch variation (`0x458da0`), `0x800` = volume variation (`0x458c60`). No 5875 kit sets +/// `0x800` — volume variation is dormant in this build's data. `0x20` no-duplicates, `0x200` +/// looping (0.5.3-era wowdev meanings, behavior-consistent). +pub mod sound_kit_flags { + pub const NO_DUPLICATES: u32 = 0x20; + pub const LOOPING: u32 = 0x200; + pub const VARY_PITCH: u32 = 0x400; + pub const VARY_VOLUME: u32 = 0x800; +} + +/// One sound kit: the resolved variation list + the playback parameters the kit player consumes. +pub struct SoundKit { + pub id: u32, + /// `SoundType` — the kit's category (1 spells, 2 UI, 3 footsteps, … 28 zone music, 50 zone + /// ambience). Drives the volume-category pick and the specialized runtime caches. + pub sound_type: u32, + /// The `PlaySoundByName` key (e.g. `"igMainMenuOpen"`, `"LevelUp"`). + pub name: String, + /// Variation files as `(full MPQ path, weight)` — `DirectoryBase\File[i]` joined here so + /// consumers never re-derive paths; only non-empty slots, weight from the matching `Freq[i]`. + pub files: Vec<(String, u32)>, + /// Base volume `[0,1]` (the per-shot variation math scales this — wow-re `0x458c60`). + pub volume: f32, + pub flags: u32, + /// Full-volume radius fed to the backend's min/max rolloff (FMOD `Sample_SetMinMaxDistance`). + pub min_distance: f32, + /// Selection/cull radius: the `d² < cutoff²` audibility gate + per-frame virtualization + /// (wow-re `0x45cdf0`/`0x7a5000`). `0` = non-positional (no 3D cull). + pub distance_cutoff: f32, + /// Per-kit EAX class (0/1/2 in the data) — reverb-send tier, consumed with the reverb slice. + pub eax_def: u32, +} + +/// All kits, resolvable by id or (case-insensitively) by name. +pub struct SoundKitCatalog { + kits: HashMap, + /// Lowercased `Name` → id — the client's name lookup is a case-insensitive hash. + by_name: HashMap, +} + +impl SoundKitCatalog { + pub fn get(&self, id: u32) -> Option<&SoundKit> { + self.kits.get(&id) + } + + /// `PlaySoundByName` parity: resolve a kit by its `Name` column, case-insensitive. + pub fn by_name(&self, name: &str) -> Option<&SoundKit> { + self.by_name + .get(&name.to_ascii_lowercase()) + .and_then(|id| self.kits.get(id)) + } + + pub fn len(&self) -> usize { + self.kits.len() + } + + pub fn is_empty(&self) -> bool { + self.kits.is_empty() + } + + /// An empty catalog for consumers' pure-logic unit tests (selector state machines that need + /// a catalog-shaped owner but no data). + pub fn empty_for_tests() -> Self { + Self { + kits: HashMap::new(), + by_name: HashMap::new(), + } + } +} + +/// 29 fields — module docs carry the verified layout. +fn sound_entries_schema() -> Schema { + let mut s = Schema::new("SoundEntries"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("SoundType", FieldType::UInt32)); + s.add_field(SchemaField::new("Name", FieldType::String)); + for i in 0..10 { + s.add_field(SchemaField::new(format!("File{i}"), FieldType::String)); + } + for i in 0..10 { + s.add_field(SchemaField::new(format!("Freq{i}"), FieldType::UInt32)); + } + s.add_field(SchemaField::new("DirectoryBase", FieldType::String)); + s.add_field(SchemaField::new("Volume", FieldType::Float32)); + s.add_field(SchemaField::new("Flags", FieldType::UInt32)); + s.add_field(SchemaField::new("MinDistance", FieldType::Float32)); + s.add_field(SchemaField::new("DistanceCutoff", FieldType::Float32)); + s.add_field(SchemaField::new("EAXDef", FieldType::UInt32)); + s +} + +/// Read SoundEntries.dbc off the patch chain into a [`SoundKitCatalog`]. +pub fn load_sound_kit_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(SOUND_ENTRIES) + .with_context(|| format!("reading {SOUND_ENTRIES}"))?; + let rs = parse(&bytes, sound_entries_schema(), "SoundEntries")?; + let mut kits = HashMap::with_capacity(rs.records().len()); + let mut by_name = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let name = str_at(&rs, r, 2).unwrap_or_default(); + let dir = str_at(&rs, r, 23).unwrap_or_default(); + let mut files = Vec::new(); + for i in 0..10 { + let Some(file) = str_at(&rs, r, 3 + i).filter(|f| !f.is_empty()) else { + continue; + }; + let weight = u32_at(r, 13 + i).unwrap_or(0); + let path = if dir.is_empty() { + file + } else { + format!("{dir}\\{file}") + }; + files.push((path, weight)); + } + if !name.is_empty() { + by_name.insert(name.to_ascii_lowercase(), id); + } + kits.insert( + id, + SoundKit { + id, + sound_type: u32_at(r, 1).unwrap_or(0), + name, + files, + volume: f32_at(r, 24).unwrap_or(1.0), + flags: u32_at(r, 25).unwrap_or(0), + min_distance: f32_at(r, 26).unwrap_or(0.0), + distance_cutoff: f32_at(r, 27).unwrap_or(0.0), + eax_def: u32_at(r, 28).unwrap_or(0), + }, + ); + } + Ok(SoundKitCatalog { kits, by_name }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end on the **real** build-5875 table: the byte-verified 29-field layout parses all + /// 4623 kits; the spot-checked row (ID 3) reads back exactly; the `PlaySoundByName` lookup is + /// case-insensitive; and a kit's joined `DirectoryBase\File` path is a real, readable chain + /// file (guards the path join against a shifted column). Skips without client data. + #[test] + fn real_sound_entries_parse_and_resolve() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_sound_kit_catalog(&mut chain).expect("load sound kits"); + assert_eq!(cat.len(), 4623, "all 5875 SoundEntries rows load"); + + // The row decoded byte-by-byte while pinning the layout (module docs). + let kit = cat.get(3).expect("kit 3 exists"); + assert_eq!(kit.name, "Invisibility Impact"); + assert_eq!(kit.sound_type, 1); + assert_eq!(kit.files.len(), 1); + assert_eq!( + kit.files[0], + ("Sound\\Spells\\Dispel_Low_Base.wav".into(), 1) + ); + assert_eq!(kit.volume, 1.0); + assert_eq!(kit.flags, 0); + assert_eq!(kit.min_distance, 8.0); + assert_eq!(kit.distance_cutoff, 45.0); + assert_eq!(kit.eax_def, 2); + + // Name lookup, case-insensitive (the client name-hash ignores case). + let ui = cat.by_name("IGMINIMAPZOOMIN").expect("UI kit by name"); + assert_eq!(ui.id, 823); + assert_eq!(ui.sound_type, 2, "type 2 = UI"); + + // The joined path of a UI kit resolves to real bytes on the chain. + let (path, _) = &ui.files[0]; + let bytes = chain.read(path).expect("kit file readable off the chain"); + assert!( + bytes.len() > 1000, + "{path} is a real WAV ({} B)", + bytes.len() + ); + } +} diff --git a/crates/benilla-formats/src/sound_provider.rs b/crates/benilla-formats/src/sound_provider.rs new file mode 100644 index 00000000..d930110e --- /dev/null +++ b/crates/benilla-formats/src/sound_provider.rs @@ -0,0 +1,173 @@ +//! `SoundProviderPreferences.dbc` — the zone reverb presets (EAX listener properties). +//! +//! Layout — VERIFIED against build 5875 (header + full row dump, 2026-07-03): **38 × 24 × 96 B**: +//! `ID(0), Description(1,str), Flags(2), EAXEnvironmentSelection(3), EAXDecayTime(4,f), +//! EAX2EnvironmentSize(5,f), EAX2EnvironmentDiffusion(6,f), EAX2Room(7,i), EAX2RoomHF(8,i), +//! EAX2DecayHFRatio(9,f), EAX2Reflections(10,i), EAX2ReflectionsDelay(11,f), EAX2Reverb(12,i), +//! EAX2ReverbDelay(13,f), EAX2RoomRolloff(14,f), EAX2AirAbsorption(15,f), EAX3RoomLF(16,i), +//! EAX3DecayLFRatio(17,f), EAX3EchoTime(18,f), EAX3EchoDepth(19,f), EAX3ModulationTime(20,f), +//! EAX3ModulationDepth(21,f), EAX3HFReference(22,f), EAX3LFReference(23,f)`. +//! External cross-check: rows 66–92 are the canonical EAX preset table (`PRESET_GENERIC` decay +//! 1.49 s, Room −1000 mB, Reflections −2602 mB @ 0.007 s, Reverb 200 mB @ 0.011 s — the published +//! EAX SDK values, byte-exact), which pins every column's meaning independently of the wiki. +//! Levels are **millibels** (dB × 100), times in seconds. +//! +//! Who references these: `AreaTable` cols 5/6 (dry/underwater — in 1.12 only 8 areas carry a dry +//! pref, all dungeon floors; 568 carry underwater pref 11) and `WMOAreaTable` cols 4/5 (the real +//! payload: ~4 000 interior group rows, CAVE/AUDITORIUM/ARENA — wired when WMO interior +//! containment lands). The struct carries the EAX2 core the mixer's reverb consumes; the EAX3 +//! extras (cols 16–23) are near-constant defaults in 1.12 and stay unparsed until a backend uses +//! them. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +/// One reverb preset — EAX2 listener properties, in EAX units (mB levels, seconds). +pub struct SoundProvider { + pub id: u32, + /// Preset name ("PRESET_CAVE", "Underwater", …) — debug/display only. + pub name: String, + pub flags: u32, + /// Reverberation decay time, seconds (EAX range 0.1–20). + pub decay_time: f32, + /// Master room effect level, mB (−10000..0). + pub room: i32, + /// Room effect high-frequency level, mB (−10000..0) — the muffle of the wet signal. + pub room_hf: i32, + /// High-frequency to overall decay ratio (0.1–2; <1 = highs die faster). + pub decay_hf_ratio: f32, + /// Early-reflections level, mB (−10000..1000). + pub reflections: i32, + /// Late-reverberation level, mB (−10000..2000). + pub reverb: i32, + /// Environment diffusion (0–1; low = echoey, high = smooth). + pub env_diffusion: f32, + /// Apparent room size, meters-ish (EAX environment size, 1–100). + pub env_size: f32, +} + +/// All presets by id. +pub struct SoundProviderCatalog { + providers: HashMap, +} + +impl SoundProviderCatalog { + pub fn get(&self, id: u32) -> Option<&SoundProvider> { + self.providers.get(&id) + } + + pub fn len(&self) -> usize { + self.providers.len() + } + + pub fn is_empty(&self) -> bool { + self.providers.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("SoundProviderPreferences"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("Description", FieldType::String)); + s.add_field(SchemaField::new("Flags", FieldType::UInt32)); + s.add_field(SchemaField::new( + "EAXEnvironmentSelection", + FieldType::UInt32, + )); + s.add_field(SchemaField::new("EAXDecayTime", FieldType::Float32)); + s.add_field(SchemaField::new("EAX2EnvironmentSize", FieldType::Float32)); + s.add_field(SchemaField::new( + "EAX2EnvironmentDiffusion", + FieldType::Float32, + )); + s.add_field(SchemaField::new("EAX2Room", FieldType::UInt32)); + s.add_field(SchemaField::new("EAX2RoomHF", FieldType::UInt32)); + s.add_field(SchemaField::new("EAX2DecayHFRatio", FieldType::Float32)); + s.add_field(SchemaField::new("EAX2Reflections", FieldType::UInt32)); + s.add_field(SchemaField::new("EAX2ReflectionsDelay", FieldType::Float32)); + s.add_field(SchemaField::new("EAX2Reverb", FieldType::UInt32)); + s.add_field(SchemaField::new("EAX2ReverbDelay", FieldType::Float32)); + s.add_field(SchemaField::new("EAX2RoomRolloff", FieldType::Float32)); + s.add_field(SchemaField::new("EAX2AirAbsorption", FieldType::Float32)); + for name in [ + "EAX3RoomLF", + "EAX3DecayLFRatio", + "EAX3EchoTime", + "EAX3EchoDepth", + "EAX3ModulationTime", + "EAX3ModulationDepth", + "EAX3HFReference", + "EAX3LFReference", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `SoundProviderPreferences.dbc` off the patch chain. +pub fn load_sound_provider_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\SoundProviderPreferences.dbc") + .context("reading SoundProviderPreferences.dbc")?; + let rs = parse(&bytes, schema(), "SoundProviderPreferences")?; + let mut providers = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let i32_at = |i: usize| u32_at(r, i).unwrap_or(0) as i32; + providers.insert( + id, + SoundProvider { + id, + name: str_at(&rs, r, 1).unwrap_or_default(), + flags: u32_at(r, 2).unwrap_or(0), + decay_time: f32_at(r, 4).unwrap_or(0.0), + room: i32_at(7), + room_hf: i32_at(8), + decay_hf_ratio: f32_at(r, 9).unwrap_or(1.0), + reflections: i32_at(10), + reverb: i32_at(12), + env_diffusion: f32_at(r, 6).unwrap_or(1.0), + env_size: f32_at(r, 5).unwrap_or(1.0), + }, + ); + } + Ok(SoundProviderCatalog { providers }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: PRESET_GENERIC (67) carries the published EAX SDK values and the + /// Underwater preset (11) the aggressive HF kill — the two rows the column map hangs on. + /// Skips without client data. + #[test] + fn real_provider_table_decodes() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_sound_provider_catalog(&mut chain).expect("load providers"); + assert_eq!(cat.len(), 38); + + let generic = cat.get(67).expect("PRESET_GENERIC"); + assert_eq!(generic.name, "PRESET_GENERIC"); + assert!((generic.decay_time - 1.49).abs() < 1e-3); + assert_eq!(generic.room, -1000); + assert_eq!(generic.room_hf, -100); + assert_eq!(generic.reflections, -2602); + assert_eq!(generic.reverb, 200); + + let underwater = cat.get(11).expect("Underwater"); + assert_eq!(underwater.name, "Underwater"); + assert_eq!(underwater.room_hf, -10000); + assert_eq!(underwater.reverb, 1700); + } +} diff --git a/crates/benilla-formats/src/sound_water.rs b/crates/benilla-formats/src/sound_water.rs new file mode 100644 index 00000000..c6af937c --- /dev/null +++ b/crates/benilla-formats/src/sound_water.rs @@ -0,0 +1,100 @@ +//! `SoundWaterType.dbc` — the liquid-class → ambient-loop-kit map of the **above-water liquid +//! ambient-loop system** (wow-re `liquid-ambience-loop.md`, §5-verified; benilla decision 0506): +//! the continuous ocean/river/lava/slime sound the client plays near liquid. The driver reads the +//! nearest wet cell's MCLQ low nibble as `class = nibble & 3`, `speed = nibble >> 2`, and resolves +//! the kit **data-driven** through this table (store `[0xc0d898]`; not hardcoded ids): 16-byte +//! records `ID, SoundType(class), FluidSpeed(4·speed), SoundEntriesID`. +//! +//! 5875 content: river 0/4/8 → 1111 RiverStill / 1112 RiverSlow / 1113 RiverFast; ocean → 1114 +//! at every speed; magma 0→3072 LavaPoolLoop, 4/8→3052 LavaFlowLoop; slime → 3880 SlimeLoop. + +use std::collections::HashMap; + +use anyhow::Result; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +/// The `(SoundType, FluidSpeed) → SoundEntries kit` map. +pub struct WaterSoundCatalog { + by_key: HashMap<(u32, u32), u32>, +} + +impl WaterSoundCatalog { + /// The loop kit for an MCLQ/MLIQ cell low nibble (`class = nibble & 3`, `FluidSpeed = + /// nibble & 0xc` — the nibble packs `class + 4·speed`). `None` for a nibble the table + /// doesn't cover (incl. the `0xf` dry sentinel). + pub fn kit_for_nibble(&self, nibble: u8) -> Option { + let n = u32::from(nibble & 0xf); + self.by_key.get(&(n & 3, n & 0xc)).copied() + } + + pub fn len(&self) -> usize { + self.by_key.len() + } + + pub fn is_empty(&self) -> bool { + self.by_key.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("SoundWaterType"); + for name in ["ID", "SoundType", "FluidSpeed", "SoundEntriesID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `SoundWaterType.dbc` off the patch chain. +pub fn load_water_sound_catalog(chain: &mut Chain) -> Result { + let bytes = chain.read_file("DBFilesClient\\SoundWaterType.dbc")?; + let rs = parse(&bytes, schema(), "SoundWaterType")?; + let mut by_key = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(class), Some(speed), Some(kit)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3)) + else { + continue; + }; + if kit != 0 { + by_key.insert((class, speed), kit); + } + } + Ok(WaterSoundCatalog { by_key }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table, end to end: every wet nibble resolves, and the mapping matches the + /// §5's byte-cited dispatch (river split by authored speed, ocean uniform, magma pool/flow). + /// Skips without client data. + #[test] + fn real_table_resolves_every_wet_nibble() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_water_sound_catalog(&mut chain).expect("load SoundWaterType"); + assert_eq!(cat.len(), 12, "all 5875 rows load"); + // nibble = class + 4·speed + assert_eq!(cat.kit_for_nibble(0), Some(1111), "RiverStill"); + assert_eq!(cat.kit_for_nibble(4), Some(1112), "RiverSlow"); + assert_eq!(cat.kit_for_nibble(8), Some(1113), "RiverFast"); + assert_eq!(cat.kit_for_nibble(1), Some(1114), "Ocean"); + assert_eq!(cat.kit_for_nibble(5), Some(1114), "Ocean at any speed"); + assert_eq!(cat.kit_for_nibble(2), Some(3072), "LavaPoolLoop"); + assert_eq!(cat.kit_for_nibble(6), Some(3052), "LavaFlowLoop"); + assert_eq!(cat.kit_for_nibble(3), Some(3880), "SlimeLoop"); + assert_eq!(cat.kit_for_nibble(7), Some(3880), "SlimeLoop fast"); + assert_eq!( + cat.kit_for_nibble(0xf), + None, + "dry sentinel resolves nothing" + ); + } +} diff --git a/crates/benilla-formats/src/spell_focus.rs b/crates/benilla-formats/src/spell_focus.rs new file mode 100644 index 00000000..a5eb4219 --- /dev/null +++ b/crates/benilla-formats/src/spell_focus.rs @@ -0,0 +1,95 @@ +//! SpellFocusObject.dbc — the table naming the world object a spell must be cast NEAR (an Anvil, a +//! Forge, a Cooking Fire): the crafting book's "Requires: …" line resolves a recipe's +//! `Spell.dbc RequiresSpellFocus` id here (decision 0437). The proximity *check* is the server's +//! (`Spell::CheckCast`'s focus search) — the client only names the requirement. +//! +//! Layout byte-checked on the raw 5875 file this session (a struct-unpack dump: 138 records × 10 +//! fields, record size 40): `ID@0` · the 8-locale `Name` block (enUS first ⇒ **Name = column 1**) · +//! its flags word (9). Anchor rows: 1 "Anvil" · 2 "Loom" · 3 "Forge" · 4 "Cooking Fire". + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const SPELL_FOCUS_OBJECT: &str = "DBFilesClient\\SpellFocusObject.dbc"; +const SPELL_FOCUS_FIELDS: usize = 10; +const COL_NAME_ENUS: usize = 1; + +/// `SpellFocusObject.Id → Name` — the "Requires: Anvil" vocabulary. +pub struct SpellFocusCatalog { + names: HashMap, +} + +impl SpellFocusCatalog { + /// The display name for a `RequiresSpellFocus` id, or `None` for 0/unknown (no requirement + /// line at all). + pub fn name(&self, focus_id: u32) -> Option<&str> { + self.names.get(&focus_id).map(String::as_str) + } + + pub fn len(&self) -> usize { + self.names.len() + } + + pub fn is_empty(&self) -> bool { + self.names.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("SpellFocusObject"); + for i in 0..SPELL_FOCUS_FIELDS { + let ty = if i == COL_NAME_ENUS { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("F{i}"), ty)); + } + s +} + +/// Load SpellFocusObject.dbc from the patch chain into a [`SpellFocusCatalog`]. +pub fn load_spell_focus_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(SPELL_FOCUS_OBJECT) + .with_context(|| format!("reading {SPELL_FOCUS_OBJECT}"))?; + let rs = parse(&bytes, schema(), "SpellFocusObject.dbc")?; + let mut names = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(name) = str_at(&rs, r, COL_NAME_ENUS) { + names.insert(id, name); + } + } + Ok(SpellFocusCatalog { names }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The profession-focus rows on the real build-5875 file, byte-anchored (module doc's dump): + /// a column slip lands on another locale/flags column and fails loudly. Skips without client + /// data. + #[test] + fn real_spell_focus_names_the_profession_objects() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_focus_catalog(&mut chain).expect("load SpellFocusObject.dbc"); + + assert_eq!(cat.name(1), Some("Anvil")); + assert_eq!(cat.name(3), Some("Forge")); + assert_eq!(cat.name(4), Some("Cooking Fire")); + assert_eq!(cat.name(0), None, "0 = no requirement"); + assert_eq!(cat.len(), 138, "the 5875 file's full row count"); + } +} diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual.rs new file mode 100644 index 00000000..34f75022 --- /dev/null +++ b/crates/benilla-formats/src/spell_visual.rs @@ -0,0 +1,713 @@ +//! `SpellVisual.dbc` + `SpellVisualKit.dbc` — the per-lifecycle-stage kit chain a cast plays +//! (decision 0099 phase 2's data plane; schemas pinned by decision 0107). +//! +//! Layout — VERIFIED against build 5875 (WDBC header dump of the extracted files, 2026-07-04; +//! wow-re `system/dbc/scratch/spellvisual-schema.md` @ commit `ff91e7eb`, corroborated by vmangos +//! `DBCStructure.h:613-640` `SpellVisualEntry` and our own header bytes): +//! +//! - **SpellVisual**: 2165 records × 16 fields × 64 B, all-`u32` (a 1-byte empty string block). +//! Field 0 = id; **field 1 = precastKit · field 2 = castKit · field 3 = impactKit · field 4 = +//! stateKit · field 5 = channelKit** — each a `SpellVisualKit` id, `0` = no kit at that stage. +//! The missile block (decision 0099 phase 4, wow-re `spell-visual-lifecycle.md` §Q4 — every +//! consumption byte-cited in the client's missile spawn `0x60a3d0`): **field 7 = the missile's +//! `SpellVisualEffectName` id** (`<1` → the ammo/weapon model; unresolvable → the client's +//! literal `Spells\ErrorCube.mdx`), **field 9 = the destination-attachment ordinal** (an index +//! into [`MISSILE_ATTACH_TABLE`], the client's `0x860a18` — dumped in wow-re +//! `spell-visual-apply.md` §5), and **field 10 = the missile's in-flight LOOP sound** +//! ([`SoundEntries`] id — Fireball's `FireMissileLoop`, the thrown dagger's `WeaponLoop`; the +//! client's per-missile loop handle `CMissile+0x44`, wow-re `w2f1.md`). Field 6 (`hasMissile`) +//! is never read — the spawn gate is `Spell.dbc` Speed alone; field 8 (`missilePathType`) is +//! dead-by-absence; field 13 (the caster's ground-arrival kit) is not read here yet. Stage +//! semantics (decision 0107 verdict 2): the stage sets *lifetime policy* only — +//! every populated slot on a reached row fires, precast persisting (reaped spell-id-keyed) +//! while cast/impact self-terminate. +//! - **SpellVisualKit**: 1772 records × 35 fields × 140 B, all-`u32`. Field 0 = id; **field 2 = +//! the `AnimationData.dbc` animation id** (fed to the same `PlayAnimation` one-shot/overlay +//! route as melee swings — decision 0107 verdict 3); **field 13 = a `SoundEntries.dbc` id**; +//! **fields 3–11 are the nine `SpellVisualEffectName` emitter slots** (attach-point VFX — +//! decision 0099 phase 3, read here since that phase). Each slot's attach tag is a compile-time +//! immediate in the client's slot loop (`0x60edf0`, byte-cited push sites — wow-re +//! `spell-visual-apply.md` §1.3) and a **direct M2 `AttachmentID`**: in kit-field order, +//! [`KIT_SLOT_TAGS`]. Field 12 is the missile effect slot (phase 4), field 14 a visual-group +//! fallback id — neither is read here. +//! - **SpellVisualEffectName**: 5 fields × 20 B. Field 0 = id; field 1 = a name string — a debug +//! label, and the lookup key for the client's boot-time HARDCODED-effect matcher (`0x61f5b0` +//! over a 14-string table: loot art, footsteps, breath, level-up…; wow-re +//! `loot-corpse-effect.md` + `levelup-ding.md`) — the `"HARDCODED *"` rows load into +//! [`SpellVisualCatalog::hardcoded_effect`]'s name→path map (two consumers today: the corpse +//! sparkle and the level-up ding); **field 2 = the effect model's `.mdx` path** (the +//! column every consumer reads — fields 3/4 are dead-by-absence, wow-re +//! `spellvisual-schema.md`; the emitter *scale* comes from kit CharProc params × the quality +//! tier, never from this table). +//! +//! **The none-sentinel, found empirically here (not pinned in the wow-re note):** on the real +//! table, "no value" for both field 2 (anim) and field 13 (sound) is written as **either `0` or +//! `0xFFFFFFFF`**, not consistently one or the other. Scanning all 1772 kits: 41 carry anim `0`, +//! 875 carry anim `0xFFFFFFFF`, 856 carry a real id (max 203, inside `AnimationData.dbc`'s 208 +//! rows). Restricting to the 1433 kits actually reachable from a live `spell_template` visual +//! chain doesn't change the picture (692 are `-1` vs 26 `0` vs 715 real) — the `-1` form is the +//! majority for **impact**-stage kits specifically (966/1200: most magic impacts carry no body +//! reaction) while **cast**-stage kits are majority real (1168/1347: most casts do animate). +//! `sound` (field 13) shows the same dual encoding on a handful of rows (5 kits carry +//! `0xFFFFFFFF` alongside 647 carrying plain `0`). [`VisualKit::anim_id`]/[`VisualKit::sound`] +//! fold both forms to `None` so no consumer ever sees the raw sentinels. +//! +//! **Verified chain (Fireball, spell 133 → visual 67):** precast 30 / cast 38 / impact 286 / +//! state 0 / channel 0; kit 38 (cast) → anim 53 (`AnimationData` "SpellCastDirected") / sound +//! 1484; kit 286 (impact) → anim 9 (`AnimationData` "CombatWound" — a hit-reaction anim on the +//! target, corroborating decision 0107's wound-flinch ids 8–10) / sound 1507. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const SPELL_VISUAL: &str = "DBFilesClient\\SpellVisual.dbc"; +const SPELL_VISUAL_KIT: &str = "DBFilesClient\\SpellVisualKit.dbc"; +const SPELL_VISUAL_EFFECT_NAME: &str = "DBFilesClient\\SpellVisualEffectName.dbc"; + +const SPELL_VISUAL_FIELDS: usize = 16; +const SPELL_VISUAL_KIT_FIELDS: usize = 35; + +/// The nine kit emitter slots' attach tags, **in kit-field order** (fields 3–11 ↔ this array's +/// indices 0–8). Byte-pinned compile-time immediates in the client's slot loop (wow-re +/// `spell-visual-apply.md` §1.3), each a **direct M2 `AttachmentID`**: Head(0x14), Chest(0x22), +/// Base(0x13), LeftHand(0x15), RightHand(0x16), Breath(0x11), Special1–3(0x17–0x19). +pub const KIT_SLOT_TAGS: [u16; 9] = [0x14, 0x22, 0x13, 0x15, 0x16, 0x11, 0x17, 0x18, 0x19]; + +/// The missile **destination-attachment** ordinal table — the client's `0x860a18`, dumped +/// byte-for-byte (wow-re `spell-visual-apply.md` §5, 11 live entries): [`KIT_SLOT_TAGS`] in +/// kit-field order plus `0xf`/`0x10` at ordinals 9/10. `SpellVisual` field 9 indexes here to the +/// M2 attach tag the missile homes to on a live target ([`VisualStages::missile_attach`]). +pub const MISSILE_ATTACH_TABLE: [u16; 11] = [ + 0x14, 0x22, 0x13, 0x15, 0x16, 0x11, 0x17, 0x18, 0x19, 0xf, 0x10, +]; + +/// The DBC's alternate "no value" encoding for a foreign-key-ish column (module docs) — folded to +/// `None` alongside plain `0`. +const NONE_SENTINEL: u32 = u32::MAX; + +/// One `SpellVisual.dbc` row: the five lifecycle-stage `SpellVisualKit` ids a cast may fire +/// through (`0` = no kit at that stage, this crate's usual absent-FK convention) + the missile +/// block's live columns (model, dest-attach, flight sound — module docs; phase 4). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VisualStages { + pub precast: u32, + pub cast: u32, + pub impact: u32, + pub state: u32, + pub channel: u32, + /// Field 7: the projectile's `SpellVisualEffectName` id (`0` here covers the client's `<1` + /// ammo-fallback gate — the raw column is never negative on the real table). The missile + /// *exists* whenever `Spell.dbc` Speed > 0, model or not. + pub missile_model: u32, + /// Field 9: the destination-attachment ordinal — index [`MISSILE_ATTACH_TABLE`] for the M2 + /// attach tag the missile homes to on a live target. + pub missile_attach: u32, + /// Field 10: the `SoundEntries.dbc` id the missile sounds while it travels — a LOOPING whoosh + /// on the thrown/ranged projectiles (the thrown dagger's `WeaponLoop.wav`, id 3318), the + /// client's per-missile loop handle (`CMissile+0x44`, wow-re `w2f1.md`; its volume is proximity + /// -shaped by `0x61d790`). `None` = the row names no flight sound. Rung by + /// `crate::entities::missile`, stopped when the projectile arrives. + pub missile_sound: Option, + /// Field 14: the `SoundEntries.dbc` id the `$TRD` anim event rings at the work/craft strike + /// keyframe — the client's `$TRD` handler `0x62faa0` resolves the in-flight spell's visual to + /// this 16-dword row and plays its dword 14 (`[row+0x38]`, wow-re + /// `sound/scratch/gather-sound-anim-events.md`; decision 0562). Mining's visual 93 carries + /// 1143 "Mining Impact" (the pick clang), Herb's 91 carries 1142, the smithing crafts' 395 + /// carries 1143 (the hammer). `None` = no strike sound. + pub strike_sound: Option, +} + +/// One `SpellVisualKit.dbc` row's body-animation + sound + the nine attach-point emitter slots +/// (the columns consumed through decision 0099 phase 3; see the module doc for what remains). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VisualKit { + /// The `AnimationData.dbc` id this kit plays on the unit (`None` = no animation for this kit + /// — see the module doc's none-sentinel finding; common on impact kits). + pub anim_id: Option, + /// The `SoundEntries.dbc` kit id this stage rings (`None` = silent). + pub sound: Option, + /// The nine emitter slots (kit fields 3–11): each a `SpellVisualEffectName` id, in kit-field + /// order — slot `i` attaches at [`KIT_SLOT_TAGS`]`[i]`. Both none-sentinels fold to `None` + /// like [`Self::anim_id`]. Iterate populated slots via [`Self::effects`]. + pub effect_slots: [Option; 9], +} + +impl VisualKit { + /// The populated emitter slots as `(M2 attachment id, SpellVisualEffectName id)` pairs, in + /// kit-field order — the client fires **all** populated slots at **every** stage (stage sets + /// lifetime policy only, wow-re `spell-visual-apply.md` §1.3). + pub fn effects(&self) -> impl Iterator + '_ { + self.effect_slots + .iter() + .enumerate() + .filter_map(|(i, e)| e.map(|id| (KIT_SLOT_TAGS[i], id))) + } +} + +/// `SpellVisual.dbc` × `SpellVisualKit.dbc` × `SpellVisualEffectName.dbc`, each kept in its own +/// id space (three different tables' keys — never conflate them, mirroring +/// [`crate::AreaSoundCatalog`]'s per-table map split). +pub struct SpellVisualCatalog { + visuals: HashMap, + kits: HashMap, + /// `SpellVisualEffectName` id → the effect model's `.mdx` path (field 2 — the table's one + /// column the kit slots consume). + effect_paths: HashMap, + /// The `"HARDCODED *"`-named rows, name → path — the client's engine-spawned effect set, + /// resolved BY NAME once at boot exactly like this (`0x61f5b0` matches a 14-string baked + /// table against the name column: loot art, footsteps, breath, level-up…; wow-re + /// `loot-corpse-effect.md` + `levelup-ding.md`). Two consumers today: "HARDCODED Loot Art" + /// (id 14 → `Particles\LootFX.mdl`, the corpse sparkle) and "HARDCODED Unit Level Up" + /// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding). + hardcoded: HashMap, +} + +impl SpellVisualCatalog { + /// Build a catalog from explicit visual/kit tables — for tests and synthetic fixtures (the + /// [`crate::SpellCatalog::from_displays`] convention). The live path is + /// [`load_spell_visual_catalog`]. Carries no effect paths and no hardcoded rows. + pub fn from_tables(visuals: HashMap, kits: HashMap) -> Self { + Self::from_tables_with_paths(visuals, kits, HashMap::new()) + } + + /// [`Self::from_tables`] plus effect paths, for fixtures exercising the kit → effect-model + /// chain. The live path is [`load_spell_visual_catalog`]. + pub fn from_tables_with_paths( + visuals: HashMap, + kits: HashMap, + effect_paths: HashMap, + ) -> Self { + Self { + visuals, + kits, + effect_paths, + hardcoded: HashMap::new(), + } + } + + /// A `SpellVisual.dbc` row's stage kits by its id (`Spell.dbc` column 115, `spells::SpellDisplay::visual`). + pub fn stages(&self, visual_id: u32) -> Option<&VisualStages> { + self.visuals.get(&visual_id) + } + + /// A `SpellVisualKit.dbc` row by its id (one of [`VisualStages`]'s five fields). + pub fn kit(&self, kit_id: u32) -> Option<&VisualKit> { + self.kits.get(&kit_id) + } + + /// The number of `SpellVisual` rows loaded. + pub fn len(&self) -> usize { + self.visuals.len() + } + + pub fn is_empty(&self) -> bool { + self.visuals.is_empty() + } + + /// The number of `SpellVisualKit` rows loaded. + pub fn kit_len(&self) -> usize { + self.kits.len() + } + + /// A `SpellVisualEffectName` id's effect-model `.mdx` path (one of a kit's + /// [`VisualKit::effects`] slot values). `None` for an unknown id or an empty path. + pub fn effect_path(&self, effect_id: u32) -> Option<&str> { + self.effect_paths + .get(&effect_id) + .map(String::as_str) + .filter(|p| !p.is_empty()) + } + + /// An engine-spawned hardcoded effect's model path, by the client's own baked name string + /// ([`SpellVisualCatalog::hardcoded`]). Mirrors the client's boot name-resolve + /// (`0x61f5b0`, stricmp-family — hence the case-insensitive compare over the tiny set); + /// `None` when the shipped table names no such row. + pub fn hardcoded_effect(&self, name: &str) -> Option<&str> { + self.hardcoded + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + .filter(|p| !p.is_empty()) + } + + /// The `"HARDCODED Loot Art"` row's model path — the lootable-corpse sparkle, riding the + /// shared hardcoded map (one name-resolve mechanism for the whole engine-spawned set). + pub fn loot_art_path(&self) -> Option<&str> { + self.hardcoded_effect("HARDCODED Loot Art") + } +} + +fn n_u32_schema(name: &str, fields: usize) -> Schema { + let mut s = Schema::new(name); + for i in 0..fields { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + s +} + +/// `SpellVisualEffectName`'s 5-field schema: id · name (string — the HARDCODED-effect lookup +/// key) · model `.mdx` path (string) · two dead columns (module docs). +fn effect_name_schema() -> Schema { + let mut s = Schema::new("SpellVisualEffectName"); + for i in 0..5 { + let ty = if i == 1 || i == 2 { + FieldType::String + } else { + FieldType::UInt32 + }; + s.add_field(SchemaField::new(format!("F{i}"), ty)); + } + s +} + +/// Fold the DBC's dual none-encoding (module docs) to `Option`. +fn some_unless_none(v: u32) -> Option { + (v != 0 && v != NONE_SENTINEL).then_some(v) +} + +/// Read `SpellVisual.dbc` + `SpellVisualKit.dbc` off the patch chain. +pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result { + let sv_bytes = chain + .read_file(SPELL_VISUAL) + .context("reading SpellVisual.dbc")?; + let sv_set = parse( + &sv_bytes, + n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS), + "SpellVisual.dbc", + )?; + let mut visuals = HashMap::with_capacity(sv_set.records().len()); + for r in sv_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let g = |i: usize| u32_at(r, i).unwrap_or(0); + visuals.insert( + id, + VisualStages { + precast: g(1), + cast: g(2), + impact: g(3), + state: g(4), + channel: g(5), + missile_model: g(7), + missile_attach: g(9), + missile_sound: u32_at(r, 10).and_then(some_unless_none), + strike_sound: u32_at(r, 14).and_then(some_unless_none), + }, + ); + } + + let svk_bytes = chain + .read_file(SPELL_VISUAL_KIT) + .context("reading SpellVisualKit.dbc")?; + let svk_set = parse( + &svk_bytes, + n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS), + "SpellVisualKit.dbc", + )?; + let mut kits = HashMap::with_capacity(svk_set.records().len()); + for r in svk_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16); + let sound = u32_at(r, 13).and_then(some_unless_none); + let mut effect_slots = [None; 9]; + for (i, slot) in effect_slots.iter_mut().enumerate() { + *slot = u32_at(r, 3 + i).and_then(some_unless_none); + } + kits.insert( + id, + VisualKit { + anim_id, + sound, + effect_slots, + }, + ); + } + + let sven_bytes = chain + .read_file(SPELL_VISUAL_EFFECT_NAME) + .context("reading SpellVisualEffectName.dbc")?; + let sven_set = parse( + &sven_bytes, + effect_name_schema(), + "SpellVisualEffectName.dbc", + )?; + let mut effect_paths = HashMap::with_capacity(sven_set.records().len()); + let mut hardcoded = HashMap::new(); + for r in sven_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + if let Some(path) = str_at(&sven_set, r, 2) { + // The engine-spawned set is looked up by the name column — keep exactly the rows + // the client's own boot name-resolve can hit (the "HARDCODED " prefix; its + // matchers are stricmp-family, so lookups compare case-insensitively). + if let Some(name) = str_at(&sven_set, r, 1).filter(|n| n.starts_with("HARDCODED ")) { + hardcoded.insert(name, path.clone()); + } + effect_paths.insert(id, path); + } + } + + Ok(SpellVisualCatalog { + visuals, + kits, + effect_paths, + hardcoded, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal synthetic WDBC (20-byte header + fixed-width u32 records, no strings — both + /// tables are all-`u32`) — the same shape `benilla-dbc`'s own tests build (`items.rs` + /// reproduces it too), so this adapter is testable without a real client install. + fn build_wdbc(record_count: u32, field_count: u32, records: &[u8]) -> Vec { + let record_size = field_count * 4; + assert_eq!(records.len(), (record_count * record_size) as usize); + let mut b = Vec::new(); + b.extend_from_slice(b"WDBC"); + b.extend_from_slice(&record_count.to_le_bytes()); + b.extend_from_slice(&field_count.to_le_bytes()); + b.extend_from_slice(&record_size.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); // empty string block + b.extend_from_slice(records); + b + } + + fn u32le(v: u32) -> [u8; 4] { + v.to_le_bytes() + } + + /// One `SpellVisual` row: id, the five stage kits, then the missile block — field 7 (model + /// effect id) and field 9 (dest-attach ordinal) live, the rest zeroed to fill 16 fields. + #[allow(clippy::too_many_arguments)] + fn spell_visual_row( + id: u32, + precast: u32, + cast: u32, + impact: u32, + state: u32, + channel: u32, + missile_model: u32, + missile_attach: u32, + ) -> Vec { + let mut rec = Vec::new(); + for v in [id, precast, cast, impact, state, channel] { + rec.extend(u32le(v)); + } + for f in 6..SPELL_VISUAL_FIELDS { + rec.extend(u32le(match f { + 7 => missile_model, + 9 => missile_attach, + _ => 0, + })); + } + rec + } + + /// One `SpellVisualKit` row: id, an unpinned field 1, anim (field 2), nine zeroed emitter + /// slots (3..12), sound (field 13), then zeroed trailing columns to fill 35 fields. + fn spell_visual_kit_row(id: u32, anim: u32, sound: u32) -> Vec { + let mut rec = Vec::new(); + rec.extend(u32le(id)); + rec.extend(u32le(0)); // field 1, unpinned + rec.extend(u32le(anim)); // field 2 + for _ in 3..13 { + rec.extend(u32le(0)); // fields 3..12 (9 emitter slots + missile) + } + rec.extend(u32le(sound)); // field 13 + for _ in 14..SPELL_VISUAL_KIT_FIELDS { + rec.extend(u32le(0)); + } + rec + } + + #[test] + fn header_shape_matches_the_pinned_layout() { + // A single-row file of each still has to satisfy the pinned field/record-size shape — + // exercises the schema's own field-count check against a header lying about it would be + // a separate (missing) test; this one just guards our row builders stay in lock-step with + // SPELL_VISUAL_FIELDS/SPELL_VISUAL_KIT_FIELDS. + let sv = spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0); + assert_eq!(sv.len(), SPELL_VISUAL_FIELDS * 4, "64B record"); + let svk = spell_visual_kit_row(1, 0, 0); + assert_eq!(svk.len(), SPELL_VISUAL_KIT_FIELDS * 4, "140B record"); + } + + #[test] + fn parses_stage_kits_and_zero_means_no_kit_at_that_stage() { + let mut records = Vec::new(); + records.extend(spell_visual_row(67, 30, 38, 286, 0, 0, 365, 1)); // Fireball's visual row + records.extend(spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0)); // an all-silent row + let bytes = build_wdbc(2, SPELL_VISUAL_FIELDS as u32, &records); + let rs = parse( + &bytes, + n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS), + "t", + ) + .unwrap(); + + let mut visuals = HashMap::new(); + for r in rs.records() { + let id = u32_at(r, 0).unwrap(); + let g = |i: usize| u32_at(r, i).unwrap_or(0); + visuals.insert( + id, + VisualStages { + precast: g(1), + cast: g(2), + impact: g(3), + state: g(4), + channel: g(5), + missile_model: g(7), + missile_attach: g(9), + missile_sound: u32_at(r, 10).and_then(some_unless_none), + strike_sound: u32_at(r, 14).and_then(some_unless_none), + }, + ); + } + assert_eq!( + visuals[&67], + VisualStages { + precast: 30, + cast: 38, + impact: 286, + state: 0, + channel: 0, + missile_model: 365, + missile_attach: 1, + missile_sound: None, + strike_sound: None, + } + ); + assert_eq!( + visuals[&1], + VisualStages::default(), + "an all-zero row is all-silent" + ); + } + + #[test] + fn kit_anim_and_sound_fold_both_none_sentinels_to_none() { + let mut records = Vec::new(); + records.extend(spell_visual_kit_row(38, 53, 1484)); // Fireball's cast kit + records.extend(spell_visual_kit_row(1, 0, 0)); // plain-zero "none" + records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX)); // the -1 "none" form + let bytes = build_wdbc(3, SPELL_VISUAL_KIT_FIELDS as u32, &records); + let rs = parse( + &bytes, + n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS), + "t", + ) + .unwrap(); + + let mut kits = HashMap::new(); + for r in rs.records() { + let id = u32_at(r, 0).unwrap(); + let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16); + let sound = u32_at(r, 13).and_then(some_unless_none); + kits.insert( + id, + VisualKit { + anim_id, + sound, + ..Default::default() + }, + ); + } + assert_eq!( + kits[&38], + VisualKit { + anim_id: Some(53), + sound: Some(1484), + ..Default::default() + } + ); + assert_eq!(kits[&1], VisualKit::default(), "plain 0 = none"); + assert_eq!(kits[&2], VisualKit::default(), "0xFFFFFFFF = none too"); + } + + /// The emitter-slot surface: kit-field order maps to [`KIT_SLOT_TAGS`], both none-sentinels + /// fold out, and `effects()` yields only the populated pairs. + #[test] + fn kit_effect_slots_pair_with_their_attach_tags() { + let kit = VisualKit { + // Fireball's precast shape: LeftHand (slot index 3) + RightHand (slot index 4). + effect_slots: [ + None, + None, + None, + Some(287), + Some(287), + None, + None, + None, + None, + ], + ..Default::default() + }; + assert_eq!( + kit.effects().collect::>(), + vec![(0x15, 287), (0x16, 287)], + "LeftHand then RightHand, kit-field order" + ); + assert_eq!(VisualKit::default().effects().count(), 0); + } + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent) — this + /// crate's established gate (`anim_data.rs`, `spell_catalog.rs`, …). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// End-to-end on the real build-5875 tables: the byte-verified header shape (2165×16/64B · + /// 1772×35/140B) and the full Fireball chain (module doc's "Verified chain") — a schema drift + /// or column slip fails loudly. Skips without client data. + #[test] + fn real_spell_visual_chain_resolves_fireball() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_visual_catalog(&mut chain).expect("load SpellVisual/SpellVisualKit"); + assert_eq!(cat.len(), 2165, "all 5875 SpellVisual rows load"); + assert_eq!(cat.kit_len(), 1772, "all 5875 SpellVisualKit rows load"); + + // Fireball (spell 133) → visual 67 (spells.rs's column-115 pin). + let stages = cat.stages(67).expect("Fireball's SpellVisual row"); + assert_eq!( + *stages, + VisualStages { + precast: 30, + cast: 38, + impact: 286, + state: 0, + channel: 0, + missile_model: 365, + missile_attach: 1, + // Field 10: the fireball's in-flight loop (SoundEntries 3011 → FireMissileLoop.wav). + missile_sound: Some(3011), + strike_sound: None, + } + ); + // The gathering/work strike sounds (decision 0562): Mining's visual 93 carries the pick + // clang in field 14 (SoundEntries 1143 "Mining Impact" = MiningHitA-E), Herb's 91 the + // search rustle (1142) - the $TRD anim event's operands. + assert_eq!( + cat.stages(93).and_then(|s| s.strike_sound), + Some(1143), + "Mining's visual carries the field-14 strike clang" + ); + assert_eq!( + cat.stages(91).and_then(|s| s.strike_sound), + Some(1142), + "Herb Gathering's visual carries the field-14 rustle" + ); + + // A basic thrown attack borrows the equipped weapon's substitute visual (98 — the thrown + // dagger's ItemDisplayInfo col 10): no missile MODEL (it flies the weapon itself) but a + // flight loop in field 10 (SoundEntries 3318 → WeaponLoop.wav) — the whoosh the projectile + // carries while it travels. + let thrown = cat + .stages(98) + .expect("the thrown-weapon substitute SpellVisual"); + assert_eq!( + thrown.missile_model, 0, + "a thrown weapon flies its own model, not an effect" + ); + assert_eq!( + thrown.missile_sound, + Some(3318), + "the thrown weapon's flight loop" + ); + // The missile chain (phase 4): field 7 → the projectile's own model, field 9 → the + // chest attach the missile homes to (ordinal 1 → 0x22). + assert_eq!( + cat.effect_path(stages.missile_model), + Some("Spells\\Fireball_Missile_Low.mdx"), + "the flying fireball's model" + ); + assert_eq!( + MISSILE_ATTACH_TABLE[stages.missile_attach as usize], 0x22, + "Fireball homes onto the target's chest" + ); + + // The engine-spawned hardcoded set resolves by the client's own baked names — the ding + // (row 21, byte-verified `0x61f5b0`/`0x8618e0`, decision 0304's §5 fold-back). + assert_eq!( + cat.hardcoded_effect("HARDCODED Unit Level Up"), + Some("Spells\\LevelUp\\LevelUp.mdl"), + "the level-up pillar resolves by name" + ); + assert!( + cat.hardcoded_effect("HARDCODED Loot Art").is_some(), + "the sibling hardcoded rows ride the same map" + ); + + let cast_kit = cat.kit(stages.cast).expect("cast kit"); + assert_eq!( + cast_kit.anim_id, + Some(53), + "AnimationData id 53 = SpellCastDirected" + ); + assert_eq!(cast_kit.sound, Some(1484)); + + let impact_kit = cat.kit(stages.impact).expect("impact kit"); + assert_eq!( + impact_kit.anim_id, + Some(9), + "AnimationData id 9 = CombatWound, the target's hit reaction" + ); + assert_eq!(impact_kit.sound, Some(1507)); + + // The precast kit's emitter slots (phase 3): effect 287 on both hands, and its + // SpellVisualEffectName path — the glowing-hands chain end to end. + let precast_kit = cat.kit(stages.precast).expect("precast kit"); + assert_eq!( + precast_kit.effects().collect::>(), + vec![(0x15, 287), (0x16, 287)], + "Fire_Precast_Hand on LeftHand + RightHand" + ); + assert_eq!( + cat.effect_path(287), + Some("Spells\\Fire_Precast_Hand.mdx"), + "SpellVisualEffectName field 2 = the effect model path" + ); + // The impact kit's chest burst — the phase-4 arrival hand-off will play this. + assert_eq!( + cat.kit(stages.impact) + .unwrap() + .effects() + .collect::>(), + vec![(0x22, 321)], + "MoltenBlast_Impact_Chest on the Chest attach" + ); + } + + /// The real 5875 `SpellVisualEffectName`: the boot-time HARDCODED name matcher's one consumed + /// row — `"HARDCODED Loot Art"` is id 14 → `Particles\LootFX.mdl` (values pre-checked against + /// the raw DBC bytes), and the `.m2` it names ships in the chain, so the lootable-corpse + /// sparkle can actually load (wow-re `loot-corpse-effect.md`). Skips without client data. + #[test] + fn real_effect_name_table_resolves_the_loot_art_row() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_visual_catalog(&mut chain).expect("load the visual catalog"); + assert_eq!(cat.loot_art_path(), Some("Particles\\LootFX.mdl")); + // The model the row names ships in the chain (consumers rewrite .mdl → .m2 to load it). + assert!( + chain.read_file("Particles\\LootFX.m2").is_ok(), + "LootFX.m2 must ship in the chain" + ); + } +} diff --git a/crates/benilla-formats/src/spells/cast_times.rs b/crates/benilla-formats/src/spells/cast_times.rs new file mode 100644 index 00000000..b7fbe306 --- /dev/null +++ b/crates/benilla-formats/src/spells/cast_times.rs @@ -0,0 +1,128 @@ +//! `SpellCastTimes.dbc` — the base/per-level/minimum cast time (ms) a spell's `CastingTimeIndex` +//! column ([`crate::spells::SpellDisplay::casting_time_index`]) resolves against. Feeds the +//! level-scaled cast-time formula — `Spell_C::GetCastTime 0x6e3340`, byte-confirmed by the +//! 2026-07-10 wow-re §5 cross-check (`wave-cooldown.md`, wow-re commit `f2c563c9`): it reads +//! CastingTimeIndex `[SpellRec+0x48]`, resolves this table's recordsById at `[0xc0d878]`, scales +//! base/perLevel floored to the row minimum, and applies spell-mod op `0xa` +//! (SPELLMOD_CASTING_TIME). The row layout is settled independently (WoWDBDefs' +//! `1.0.0.3980`–`1.12.3.6141` layout `$id$ID<32> Base<32> PerLevel<32> Minimum<32>`, covering +//! build 5875 — cross-checked against the real extracted file below). +//! +//! **Row layout** — pinned on the extracted 5875 file (52 records × 4 fields, 16 B/record): +//! `ID(0)`, `Base(1)` = the ms a flat 1.12 tooltip shows, `PerLevel(2, **signed**` — a handful of +//! rows shrink cast time per caster level, e.g. row 10 = `{1000, -100, 500}`), `Minimum(3)` = the +//! floor the level scaling clamps to. Row 1 = `{0, 0, 0}` — the client's universal **instant** +//! sentinel; every non-cast-time spell's `CastingTimeIndex` points here (Frost Armor, Battle +//! Shout, Fire Blast, Auto Shot — all probed). Row 16 = `{1500, 0, 1500}` — Fireball rank 1 +//! (spell 133)'s real cast time, cross-checked end-to-end against the local vmangos +//! `spell_template` (`castingTimeIndex` 16 for entry 133). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{i32_at, parse, u32_at}; + +/// One `SpellCastTimes.dbc` row (module doc's row law). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpellCastTime { + /// The cast time a flat (level-independent) tooltip render shows, ms. `0` = instant. + pub base_ms: u32, + /// Signed ms added per caster level above the spell's `BaseLevel` (can be negative — cast + /// time shrinking as the caster levels); `0` for the overwhelming majority of rows. + pub per_level_ms: i32, + /// The floor the level-scaled cast time clamps to. + pub minimum_ms: u32, +} + +/// `SpellCastTimes.dbc`, by row id ([`crate::spells::SpellDisplay::casting_time_index`]). +#[derive(Default)] +pub struct SpellCastTimeCatalog { + times: HashMap, +} + +impl SpellCastTimeCatalog { + pub fn get(&self, index: u32) -> Option<&SpellCastTime> { + self.times.get(&index) + } + + pub fn len(&self) -> usize { + self.times.len() + } + + pub fn is_empty(&self) -> bool { + self.times.is_empty() + } +} + +const SPELL_CAST_TIMES: &str = "DBFilesClient\\SpellCastTimes.dbc"; +const SPELL_CAST_TIMES_FIELDS: usize = 4; + +/// Load `SpellCastTimes.dbc` off the patch chain ([`SpellCastTime`]'s row law). +pub fn load_spell_cast_times(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(SPELL_CAST_TIMES) + .context("reading SpellCastTimes.dbc")?; + let mut schema = Schema::new("SpellCastTimes"); + for i in 0..SPELL_CAST_TIMES_FIELDS { + match i { + 2 => schema.add_field(SchemaField::new("PerLevel", FieldType::Int32)), + _ => schema.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)), + } + } + let set = parse(&bytes, schema, "SpellCastTimes.dbc")?; + let mut times = HashMap::new(); + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + times.insert( + id, + SpellCastTime { + base_ms: u32_at(r, 1).unwrap_or(0), + per_level_ms: i32_at(r, 2).unwrap_or(0), + minimum_ms: u32_at(r, 3).unwrap_or(0), + }, + ); + } + Ok(SpellCastTimeCatalog { times }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `SpellCastTimes.dbc` on the real data — the module doc's own probe rows. Skips without + /// client data. + #[test] + fn real_spell_cast_times_read_the_probed_rows() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let times = load_spell_cast_times(&mut chain).expect("load SpellCastTimes"); + + // Row 1: the universal instant sentinel. + let instant = times.get(1).expect("row 1"); + assert_eq!( + (instant.base_ms, instant.per_level_ms, instant.minimum_ms), + (0, 0, 0) + ); + + // Row 16: Fireball rank 1's real cast time (vmangos spell_template.castingTimeIndex==16 + // for entry 133). + let fireball = times.get(16).expect("row 16"); + assert_eq!(fireball.base_ms, 1500); + + // Row 10: a level-scaling row — the signed per_level_ms actually goes negative. + let scaling = times.get(10).expect("row 10"); + assert_eq!( + (scaling.base_ms, scaling.per_level_ms, scaling.minimum_ms), + (1000, -100, 500) + ); + + assert_eq!(times.len(), 52, "5875 ships 52 SpellCastTimes rows"); + } +} diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs new file mode 100644 index 00000000..1f8ed511 --- /dev/null +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -0,0 +1,661 @@ +//! Data-gated regression tests for [`super::load_spell_catalog`] — every column pin documented in +//! `spells/mod.rs`'s module doc, exercised end-to-end against the real build-5875 `Spell.dbc` (and, +//! for the tooltip-arc columns, the new `SpellCastTimes.dbc`/`SpellDuration.dbc` catalogs). Split +//! out of `mod.rs` purely for file size — this is still `crate::spells`'s own test suite, not a +//! separate concern. Every test skips (passes) without `/WoW/Data`. + +use super::*; + +/// The learn-spell hop (decision 0247): a class trainer offers a LEARN *wrapper* spell, not the +/// ability — the wire id is never in `SkillLineAbility`, so the tree must hop through the taught +/// spell to group it. Probed on real 5875 data: the warrior wrappers resolve to their abilities +/// (Heroic Strike 78 via 1605, Charge 100 via 1738, Rend 772 via 1423, Battle Shout 6673 via +/// 6674), the wrappers themselves carry no skill line, and the taught abilities do. This is the +/// exact failure that emptied the trainer tree until the hop landed. Skips without client data. +#[test] +fn real_learn_spell_hop_resolves_the_taught_ability() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let spells = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + let skills = crate::skill_lines::load_skill_line_catalog(&mut chain).expect("load skill lines"); + + for (wrapper, ability, line) in [(1605u32, 78u32, 26u32), (1738, 100, 26), (6674, 6673, 256)] { + assert_eq!( + spells.learned_spell(wrapper), + Some(ability), + "learn wrapper {wrapper} teaches ability {ability}" + ); + assert_eq!( + skills.spell_to_line(wrapper), + None, + "the wrapper {wrapper} is not itself in SkillLineAbility (the bug's root)" + ); + assert_eq!( + skills.spell_to_line(ability), + Some(line), + "the taught ability {ability} groups under skill line {line}" + ); + assert!( + spells.get(ability).is_some_and(|d| !d.name.is_empty()), + "the taught ability carries the display name" + ); + } +} + +/// The attribute columns + the ranged gate on the real build-5875 `Spell.dbc` — a column slip +/// fails loudly. Values are the vmangos `spell_template` rows the module doc's pin used. +/// Skips without client data. +#[test] +fn real_spell_catalog_reads_ranged_attributes() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // Auto Shot: SPELL_ATTR_RANGED (0x2, in 0x50012) + auto-repeat (0x20). No visual — the + // missile is the wire ammo. + let auto_shot = cat.get(75).expect("Auto Shot"); + assert_eq!(auto_shot.attributes, 0x50012); + assert_eq!(auto_shot.attributes_ex2, 0x20); + assert!(auto_shot.ranged_attack()); + assert_eq!(auto_shot.visual, 0, "the shot spells have no SpellVisual"); + assert_eq!(auto_shot.speed, 40.0); + + // Wand Shoot: the 0x18&0x2 side of the client gate, plus auto-repeat. + let shoot = cat.get(5019).expect("Shoot (wand)"); + assert_eq!(shoot.attributes, 0x12); + assert_eq!(shoot.attributes_ex2, 0x20); + assert!(shoot.ranged_attack()); + + // Throw: ranged-attribute but not auto-repeat — still arms the ranged stance. + let throw = cat.get(2764).expect("Throw"); + assert_eq!(throw.attributes, 0x410012); + assert_eq!(throw.attributes_ex2, 0); + assert!(throw.ranged_attack()); + + // Fireball: neither bit — a plain cast never arms ranged. + let fireball = cat.get(133).expect("Fireball"); + assert_eq!(fireball.attributes, 0x10000); + assert_eq!(fireball.attributes_ex2, 0); + assert!(!fireball.ranged_attack()); + + // Effect[0] (column 61): the auto-attack 6603 "Attack" carries SPELL_EFFECT_ATTACK (78) — + // the client's own melee-substitution trigger (decision 0231); an ordinary spell doesn't. + // A column slip on Effect[0] fails here. + let attack = cat.get(6603).expect("Attack"); + assert_eq!(attack.effect_1, 78, "6603 Effect[0] == SPELL_EFFECT_ATTACK"); + assert!(attack.is_melee_auto_attack()); + assert!(!fireball.is_melee_auto_attack()); + assert!( + !auto_shot.is_melee_auto_attack(), + "Auto Shot is ranged (Effect 58), not melee" + ); +} + +/// The aura-bar display filter on the real build-5875 `Spell.dbc` (decisions 0268 + 0385): the +/// warrior stances carry `SPELL_ATTR_EX_NO_AURA_ICON` and the internal proc auras (Defensive +/// State 5301/5302) carry `SPELL_ATTR_DO_NOT_DISPLAY` (`0x80`) — the two bits the reference's +/// cache builder (`PlayerAuras_Update 0x4e4170`) refuses a slot for (its `Attributes` read is +/// byte-width) — while the everyday warrior buff (Battle Shout), an ordinary long buff +/// (Power Word: Fortitude), and the uncancelable world buff Echoes of Lordaeron (`Attributes` +/// dword sign bit, which is NOT a display filter) stay visible. The exact attribute values pin +/// columns 6/7 — a column slip fails loudly. Skips without client data. +#[test] +fn real_spell_catalog_hides_stances_from_the_aura_bar() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // The stances: Battle carries NO_AURA_ICON | CAST_WHEN_LEARNED, the other two just + // NO_AURA_ICON (extracted Spell.dbc, cross-checked against vmangos spell_template). + let battle = cat.get(2457).expect("Battle Stance"); + assert_eq!(battle.attributes_ex, 0x9000_0000); + assert!(battle.hidden_from_aura_bar()); + let defensive = cat.get(71).expect("Defensive Stance"); + assert_eq!(defensive.attributes_ex, 0x1000_0000); + assert!(defensive.hidden_from_aura_bar()); + let berserker = cat.get(2458).expect("Berserker Stance"); + assert_eq!(berserker.attributes_ex, 0x1000_0000); + assert!(berserker.hidden_from_aura_bar()); + + // The internal proc auras: Defensive State 5302 rides a visible wire slot (not passive) + // but carries `SPELL_ATTR_DO_NOT_DISPLAY`, so the reference never shows it (director's + // report, 2026-07-14: it showed on our bar, sometimes with its timer). + let def_state = cat.get(5302).expect("Defensive State"); + assert_eq!(def_state.attributes, 0x2000_0190); + assert!(def_state.hidden_from_aura_bar()); + let def_state_dnd = cat.get(5301).expect("Defensive State (DND)"); + assert_eq!(def_state_dnd.attributes, 0x1d0); + assert!(def_state_dnd.hidden_from_aura_bar()); + + // The auras a warrior actually watches stay on the bar. + let shout = cat.get(6673).expect("Battle Shout"); + assert!(!shout.hidden_from_aura_bar()); + let fortitude = cat.get(1243).expect("Power Word: Fortitude"); + assert!(!fortitude.hidden_from_aura_bar()); + + // The dword sign bit (`SPELL_ATTR_NO_AURA_CANCEL`) is NOT a display filter — the cache + // builder's `Attributes` read is byte-width (decision 0385). Echoes of Lordaeron is + // uncancelable yet displays on the reference; the sign-bit transcription would hide it. + let echoes = cat.get(1386).expect("Echoes of Lordaeron"); + assert_eq!(echoes.attributes, 0x8800_0100); + assert!(!echoes.hidden_from_aura_bar()); +} + +/// `rank`/`passive` on the real build-5875 `Spell.dbc` — the module doc's own probe spells +/// (every rank of Fireball, plus Frost Armor/Corruption/Fire Blast) all carry their literal +/// "Rank N" subtext, and a representative passive (a weapon-skill spell, `SPELL_ATTR_PASSIVE` +/// module doc) reads `passive == true` while an ordinary active spell reads `false`. Skips +/// without client data. +#[test] +fn real_spell_catalog_reads_rank_and_passive() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // Fireball's first three ranks — even rank 1 carries the literal "Rank 1" (module doc). + assert_eq!(cat.get(133).unwrap().rank.as_deref(), Some("Rank 1")); + assert_eq!(cat.get(143).unwrap().rank.as_deref(), Some("Rank 2")); + assert_eq!(cat.get(145).unwrap().rank.as_deref(), Some("Rank 3")); + assert_eq!(cat.get(168).unwrap().rank.as_deref(), Some("Rank 1")); // Frost Armor + assert_eq!(cat.get(172).unwrap().rank.as_deref(), Some("Rank 1")); // Corruption + assert_eq!(cat.get(2136).unwrap().rank.as_deref(), Some("Rank 1")); // Fire Blast + + // None of the above are passive; a weapon-skill spell (One-Handed Swords, id 201 — + // `SPELL_ATTR_PASSIVE`'s own probe set) is. + assert!(!cat.get(133).unwrap().passive); + assert!( + cat.get(201).unwrap().passive, + "One-Handed Swords is passive" + ); +} + +/// The spellbook add-gate on the real build-5875 `Spell.dbc` (decision 0227; the wow-re §5's +/// own concrete probe spells): displayable player spells pass, and the three hidden classes — +/// a language, an armor proficiency, a weapon proficiency (all `Attributes 0xC0`) — fail. A +/// column slip on castUI (3) or the gate bits fails loudly. Skips without client data. +#[test] +fn real_spell_catalog_gates_the_spellbook() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // Shown: Fireball's ranks, Frostbolt, Polymorph — ordinary cast spells (bit 0x80 clear, + // castUI 0). + for id in [133, 143, 145, 116, 118] { + let d = cat.get(id).unwrap_or_else(|| panic!("spell {id}")); + assert!( + d.in_spellbook(), + "spell {id} should show ({:#x})", + d.attributes + ); + assert_eq!(d.attributes & 0x80, 0, "spell {id} is not DO_NOT_DISPLAY"); + assert_eq!(d.cast_ui, 0, "an ordinary spell reads castUI 0"); + } + + // Hidden: a language, cloth/leather armor proficiency, a weapon proficiency — each + // `0xC0 = PASSIVE | DO_NOT_DISPLAY`, so `in_spellbook()` is false. + for (id, what) in [ + (668u32, "Language: Common"), + (9078, "Cloth"), + (9077, "Leather"), + (196, "One-Handed Axes"), + ] { + let d = cat.get(id).unwrap_or_else(|| panic!("spell {id} {what}")); + assert_eq!( + d.attributes & 0xC0, + 0xC0, + "{what} ({id}) is PASSIVE|DO_NOT_DISPLAY" + ); + assert!(!d.in_spellbook(), "{what} ({id}) is hidden from the book"); + } +} + +/// `open_lock_type` on the real Spell.dbc — the OPEN_LOCK effect (col 61 == 0x21) and its +/// `LockType` (EffectMiscValue, col 106). Cross-verifies with `Lock.dbc`: a Copper Vein's skill +/// slot names LockType index 3, and spell 2575 "Mining" opens exactly that. A column slip on +/// either 61 or 106 breaks the match. Skips without client data. +#[test] +fn real_spell_catalog_reads_open_lock_types() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // The gathering/lockpick openers carry SPELL_EFFECT_OPEN_LOCK; `open_lock_type` is the + // LockType they open — the same indices Lock.dbc's skill slots name (mining vein → 3). + assert_eq!( + cat.get(2575).unwrap().open_lock_type, + Some(3), + "Mining opens LockType 3" + ); + assert_eq!( + cat.get(2366).unwrap().open_lock_type, + Some(2), + "Herb Gathering opens LockType 2" + ); + assert_eq!( + cat.get(1804).unwrap().open_lock_type, + Some(1), + "Pick Lock opens LockType 1" + ); + // A plain damage spell opens no lock (Effect[0] is not OPEN_LOCK). + assert_eq!( + cat.get(133).unwrap().open_lock_type, + None, + "Fireball opens no lock" + ); + + // The totem (tool) and reagent columns the pre-send possession check reads (decision 0552; + // the ref's `0x6e4000` at SpellRec+0xA0/+0xA8 = cols 40-41 / 42-49+50-57). A column slip + // here silently breaks "Requires Mining Pick" / "Missing reagent: …". + assert_eq!( + cat.get(2575).unwrap().totems, + [2901, 0], + "Mining requires the Mining Pick (2901)" + ); + assert_eq!( + cat.get(8613).unwrap().totems, + [7005, 0], + "Skinning requires the Skinning Knife (7005)" + ); + let slow_fall = cat.get(130).unwrap(); + assert_eq!( + slow_fall.reagents[0], + (17056, 1), + "Slow Fall consumes one Light Feather (17056)" + ); + assert_eq!(cat.get(133).unwrap().totems, [0, 0]); +} + +/// The cooldown/cost/range columns on the real build-5875 `Spell.dbc`, pinned 2026-07-10 +/// against the vmangos `spell_template` rows (MAX(build) ≤ 5875 per entry — the module's +/// established cross-check). A slip on any of columns 2/19/20/31/32/36/156/157/158 fails +/// loudly. Skips without client data. +#[test] +fn real_spell_catalog_reads_cooldown_cost_and_range_columns() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // Fireball r1: no cooldown, the ordinary GCD (133/1500), 30 mana, 35yd range row. + let fireball = cat.get(133).unwrap(); + assert_eq!( + ( + fireball.category, + fireball.recovery_ms, + fireball.category_recovery_ms + ), + (0, 0, 0) + ); + assert_eq!( + (fireball.start_recovery_category, fireball.start_recovery_ms), + (133, 1500) + ); + assert_eq!((fireball.power_type, fireball.mana_cost), (0, 30)); + assert_eq!(fireball.range_index, 35); + assert!(!fireball.cooldown_on_event()); + + // Charge: category 44 with a 15 s category cooldown, rage (1), NO GCD pair, range row 95. + let charge = cat.get(100).unwrap(); + assert_eq!( + ( + charge.category, + charge.recovery_ms, + charge.category_recovery_ms + ), + (44, 0, 15000) + ); + assert_eq!( + (charge.start_recovery_category, charge.start_recovery_ms), + (0, 0) + ); + assert_eq!(charge.power_type, 1, "Charge costs rage"); + assert_eq!(charge.range_index, 95); + + // Feign Death: a 30 s own-spell RecoveryTime and SPELL_ATTR_COOLDOWN_ON_EVENT (bit 25 of + // attributes 0x2151400 — the on-hold family). + let feign = cat.get(5384).unwrap(); + assert_eq!(feign.recovery_ms, 30_000); + assert!( + feign.cooldown_on_event(), + "Feign Death is cooldown-on-event" + ); + + // Lay on Hands: the hour-long category cooldown (56 / 3_600_000). + let loh = cat.get(633).unwrap(); + assert_eq!((loh.category, loh.category_recovery_ms), (56, 3_600_000)); + + // ManaCostPercentage's own nonzero probe rows (the flat sample was all-zero): 370 + // Purge r1 = 10, 527 Dispel Magic r1 = 18. + assert_eq!(cat.get(370).unwrap().mana_cost_pct, 10); + assert_eq!(cat.get(527).unwrap().mana_cost_pct, 18); +} + +/// The cast-arm targeting columns ([`COL_TARGETS`] 13 / [`COL_IMPLICIT_TARGET_A1`] 82) on the +/// real build-5875 `Spell.dbc` — each row chosen to pin a distinct switch arm or `Targets` +/// bit (values cross-checked against the `0x6e5250` arm map, wow-re `wave-cast.md`). Skips +/// without client data. +#[test] +fn real_spell_catalog_reads_cast_targeting_columns() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // The implicit-target enum: 6 = single enemy (hostile bit), 1 = self, 21 = single + // friend (assist bit), 20 = party-around-caster (a no-op arm → mask stays 0). + assert_eq!(cat.get(133).unwrap().implicit_target_a1, 6, "Fireball"); + assert_eq!(cat.get(7302).unwrap().implicit_target_a1, 1, "Ice Armor"); + assert_eq!(cat.get(5384).unwrap().implicit_target_a1, 1, "Feign Death"); + assert_eq!( + cat.get(1459).unwrap().implicit_target_a1, + 21, + "Arcane Intellect" + ); + assert_eq!( + cat.get(6673).unwrap().implicit_target_a1, + 20, + "Battle Shout" + ); + + // The `Targets` seed mask: 0 for ordinary casts; Resurrection carries the corpse-ally + // bit 15, Skinning unit bit 1 + the requires-explicit-selection gate bit 10. + assert_eq!(cat.get(133).unwrap().targets, 0); + assert_eq!(cat.get(6673).unwrap().targets, 0); + assert_eq!(cat.get(2006).unwrap().targets, 0x8000, "Resurrection"); + assert_eq!(cat.get(8613).unwrap().targets, 0x402, "Skinning"); +} + +/// The usable-walk columns (§2a) on the real build-5875 data — one pinned row per gate +/// family — plus the form-gate law over the real form flags. Skips without client data. +#[test] +fn real_spell_catalog_reads_usable_walk_columns() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + let forms = load_shapeshift_forms(&mut chain).expect("load SpellShapeshiftForm"); + + // Claw: cat form (1) required. Ambush: stealth form (30) + a dagger equipped + the + // only-stealthed attribute. Execute: battle/berserker stances + a melee weapon + + // the target's healthless-20% aura state. Revenge: the caster's defense state. + let claw = cat.get(1082).unwrap(); + assert_eq!(claw.stances, 0x1); + let ambush = cat.get(8676).unwrap(); + assert_eq!(ambush.stances, 0x2000_0000); + assert_ne!(ambush.attributes & ATTR_ONLY_STEALTHED, 0); + assert_eq!( + ( + ambush.equipped_item_class, + ambush.equipped_item_subclass_mask + ), + (2, 0x8000) + ); + let execute = cat.get(5308).unwrap(); + assert_eq!((execute.stances, execute.target_aura_state), (0x50000, 2)); + assert_eq!(cat.get(6572).unwrap().caster_aura_state, 1, "Revenge"); + // Auto Shot: bows/guns/crossbows. Slow Fall: one Light Feather. + let auto_shot = cat.get(75).unwrap(); + assert_eq!( + ( + auto_shot.equipped_item_class, + auto_shot.equipped_item_subclass_mask + ), + (2, 0x4000c) + ); + assert_eq!(cat.get(130).unwrap().reagents[0], (17056, 1), "Slow Fall"); + + // The form flags: warrior Battle Stance (17) is a *stance* (flags1 bit 0), druid Cat + // Form (1) is a true shapeshift — the actAsShifted fork's data. + assert!(forms.get(&17).unwrap().is_stance()); + assert!(!forms.get(&1).unwrap().is_stance()); + // The bonus-bar column still reads through the richer row (Cat → page 1). + assert_eq!(forms.get(&1).unwrap().bonus_bar, 1); + + // The form-gate law on the real rows: Claw usable in cat, not unshifted; Fireball + // usable unshifted AND in Battle Stance (a stance), not in Cat Form (a shapeshift); + // Execute usable in Battle (17), not in Defensive (18). + assert!(claw.usable_in_form(1, false)); + assert!(!claw.usable_in_form(0, false)); + let fireball = cat.get(133).unwrap(); + assert!(fireball.usable_in_form(0, false)); + assert!(fireball.usable_in_form(17, true)); + assert!(!fireball.usable_in_form(1, false)); + assert!(execute.usable_in_form(17, true)); + assert!(!execute.usable_in_form(18, true)); +} + +/// The tooltip-arc columns (decision 0274 P2) on the real build-5875 `Spell.dbc`, pinned +/// 2026-07-10: description/aura-description text, DurationIndex/CastingTimeIndex/ProcChance, +/// and the per-effect arrays — end-to-end through the new [`load_spell_cast_times`]/ +/// [`load_spell_durations`] catalogs. Skips without client data. +#[test] +fn real_spell_catalog_reads_tooltip_columns() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + let cast_times = load_spell_cast_times(&mut chain).expect("load SpellCastTimes"); + let durations = load_spell_durations(&mut chain).expect("load SpellDuration"); + + // Fireball r1: the description's own opening line, and its real DoT-tail duration — it is + // NOT an "instant, no duration" spell (the description literally says "over $d": a 2 s + // apply-aura tick, effect slot 1, running the full 4 s tail). + let fireball = cat.get(133).unwrap(); + assert!( + fireball + .description + .as_deref() + .unwrap() + .starts_with("Hurls a fiery ball that causes"), + "Fireball description: {:?}", + fireball.description + ); + assert_eq!(fireball.casting_time_index, 16); + assert_eq!( + cast_times.get(16).unwrap().base_ms, + 1500, + "Fireball's real cast time" + ); + assert_eq!(fireball.duration_index, 35); + assert_eq!( + durations.get(35).unwrap().base_ms, + 4000, + "Fireball's DoT-tail duration, not zero — it genuinely has one" + ); + assert_eq!( + fireball.proc_chance, 101, + "vmangos's always-triggers sentinel" + ); + assert_eq!( + (fireball.effect_base_points[0], fireball.effect_die_sides[0]), + (13, 9), + "Fireball r1's direct-damage roll: 14-22" + ); + assert_eq!( + (fireball.effect_apply_aura[1], fireball.effect_amplitude[1]), + (3, 2000), + "Fireball's periodic-damage tail: SPELL_AURA_PERIODIC_DAMAGE ticking every 2s" + ); + + // Frost Armor: a real 30-minute buff, an instant cast, a nonempty short aura blurb, and its + // own description names the exact chill-proc spell (6136) its EffectTriggerSpell[1] holds. + let frost_armor = cat.get(168).unwrap(); + assert!(frost_armor + .aura_description + .as_deref() + .is_some_and(|s| !s.is_empty())); + assert_eq!(frost_armor.duration_index, 30); + assert_eq!(durations.get(30).unwrap().base_ms, 1_800_000, "30 minutes"); + assert_eq!(frost_armor.casting_time_index, 1); + assert_eq!(cast_times.get(1).unwrap().base_ms, 0, "instant"); + assert_eq!( + frost_armor.effect_apply_aura[0], 22, + "SPELL_AURA_MOD_RESISTANCE" + ); + assert_eq!( + frost_armor.effect_trigger_spell[1], 6136, + "the chill proc the description text itself names" + ); + + // Fire Blast: a direct-damage spell with no aura component at all — empty aura text, no + // duration row. + let fire_blast = cat.get(2136).unwrap(); + assert_eq!(fire_blast.aura_description, None); + assert_eq!(fire_blast.duration_index, 0); + assert!(durations.get(0).is_none(), "no row 0 in SpellDuration.dbc"); + + // Auto Shot / Feign Death: the signed EffectBasePoints sentinel (-1, weapon-damage/no + // fixed roll) actually round-trips through i32 — a column slip to unsigned would read + // 4294967295 here instead. + assert_eq!(cat.get(75).unwrap().effect_base_points[0], -1, "Auto Shot"); + assert_eq!( + cat.get(5384).unwrap().effect_base_points[0], + -1, + "Feign Death" + ); +} + +/// The combat-initiation classes on the real build-5875 `Spell.dbc` — the two accessor masks +/// the cast seam's queue/attack-start logic keys on ([`SpellDisplay::on_next_swing`] `0x404`, +/// [`SpellDisplay::initiates_auto_attack`] adding `AttributesEx & 0x200`), pinned against the +/// vmangos `spell_template` rows read at decision time (2026-07-14). A column slip or a mask +/// slip fails loudly. Skips without client data. +#[test] +fn real_spell_catalog_classifies_combat_initiation() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // (spell, on_next_swing, initiates_auto_attack) + for (id, name, next_swing, initiates) in [ + (78u32, "Heroic Strike", true, true), // Attributes 0x50014 + (845, "Cleave", true, true), // Attributes 0x50014, Ex 0x200 + (2973, "Raptor Strike", true, true), // Attributes 0x50404 + (772, "Rend", false, true), // Ex 0x8000200 + (7386, "Sunder Armor", false, true), // Ex 0x8000200 + (1464, "Slam", false, true), // Ex 0x8000200 + (100, "Charge", false, false), // Ex 0x400 — neither bit + (6673, "Battle Shout", false, false), // Ex 0x0 + (6603, "Attack", false, false), // the auto-attack pseudo-spell itself + (133, "Fireball", false, false), // an ordinary cast + ] { + let d = cat + .get(id) + .unwrap_or_else(|| panic!("{name} ({id}) in the catalog")); + assert_eq!( + d.on_next_swing(), + next_swing, + "{name} ({id}) on_next_swing (Attributes {:#x})", + d.attributes + ); + assert_eq!( + d.initiates_auto_attack(), + initiates, + "{name} ({id}) initiates_auto_attack (Attributes {:#x}, Ex {:#x})", + d.attributes, + d.attributes_ex + ); + // The §5's one DBC-owed bit (wow-re `combat-feel-law.md` @ c445713b): `AttributesEx2 & + // 0x100000` (INITIATE_COMBAT_POST_CAST) defers a spell's attack-start to SMSG_SPELL_GO — + // a client path benilla leaves unbuilt because no spell here carries the bit. This pins + // that from the real client DBC: in particular Charge (100) is bit20-CLEAR, so vanilla + // Charge starts no auto-attack through ANY client channel. + assert_eq!( + d.attributes_ex2 & 0x0010_0000, + 0, + "{name} ({id}) must not carry INITIATE_COMBAT_POST_CAST (Ex2 {:#x}) — the deferred \ + GO-time attack-start is unbuilt", + d.attributes_ex2 + ); + } +} + +/// The crafting columns (decision 0437) on the real build-5875 `Spell.dbc`: `EffectItemType` +/// (103-105) and `RequiresSpellFocus` (15), cross-checked against the live vmangos +/// `spell_template` rows queried at pin time (2963 → creates 2996, 2738 → 2845, 3920 → 8067 with +/// BasePoints[0]=199; 2538 Charred Wolf Meat → focus 4 Cooking Fire; 2738 Copper Axe → focus 1 Anvil). +/// A column slip fails loudly. Skips without client data. +#[test] +fn real_crafting_columns_read_created_item_and_focus() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_spell_catalog(&mut chain).expect("load Spell/SpellIcon"); + + // (recipe spell, created item, focus): Bolt of Linen Cloth, Minor Healing Potion, + // Copper Axe, Crafted Light Shot, Charred Wolf Meat. + for (spell, item, focus) in [ + (2963u32, 2996u32, 0u32), + (2330, 118, 0), + (2738, 2845, 1), // Blacksmithing needs the Anvil (focus 1) + (3920, 8067, 0), + (2538, 2679, 4), + ] { + let d = cat.get(spell).expect("recipe in the catalog"); + assert_eq!( + d.effect_1, SPELL_EFFECT_CREATE_ITEM, + "spell {spell} creates" + ); + assert_eq!(d.effect_item_type[0], item, "spell {spell} created item"); + assert_eq!(d.requires_spell_focus, focus, "spell {spell} focus"); + } + + // Crafted Light Shot's 200-per-craft: BasePoints[0]=199, DieSides[0]=1 → made = 199+1. + let shots = cat.get(3920).expect("Crafted Light Shot"); + assert_eq!(shots.effect_base_points[0], 199); + assert_eq!(shots.effect_die_sides[0], 1); + + // The openers carry effect 47 and no product: Tailoring 3908, Enchanting 7411. + for opener in [3908u32, 7411] { + let d = cat.get(opener).expect("opener in the catalog"); + assert_eq!(d.effect_1, SPELL_EFFECT_TRADE_SKILL, "opener {opener}"); + assert_eq!( + d.effect_item_type, [0; 3], + "opener {opener} creates nothing" + ); + } +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs new file mode 100644 index 00000000..baff4c2a --- /dev/null +++ b/crates/benilla-formats/src/spells/display.rs @@ -0,0 +1,451 @@ +//! `SpellDisplay` — one spell's display + usable-walk surface (the column pins its fields +//! read are documented on `spells/mod.rs`'s module header, which this file's every intra-doc +//! link points back to). Split out of `mod.rs` purely for file size — still `crate::spells`'s +//! own type, not a separate concern. + +use super::*; + +/// One spell's display identity. +pub struct SpellDisplay { + pub name: String, + /// `SpellNameSubtext` enUS (column 129, module docs) — "Rank N" for most leveled spells, a + /// bare descriptor ("Racial", "Passive", …) for some, or absent for a handful of unranked + /// spells. `None` when the column is empty ([`crate::dbc::str_at`]'s own empty-means-absent + /// contract) — the spellbook slot then shows no second line at all. + pub rank: Option, + /// The icon texture's MPQ path (`Interface\Icons\…`, extensionless as the DBC stores it); + /// `None` when the spell's icon id is 0/unknown (render the fallback question mark). + pub icon: Option, + /// `SpellVisual.dbc` id (column 115, module docs) — `0` = no visual chain (a silent cast). + /// Resolve through [`crate::SpellVisualCatalog`] for the lifecycle-stage kits. + pub visual: u32, + /// The projectile travel speed, world units/sec (column 37, module docs). `0` = instant + /// impact (decision 0099's `Speed==0` gate — no missile phase). + pub speed: f32, + /// `Attributes` (column 6, module docs) — consumed by [`Self::ranged_attack`] (`0x2`), + /// [`Self::in_spellbook`] (`0x20`/`0x80`), [`Self::cooldown_on_event`] (bit 25), and the + /// aura-bar display filter [`Self::hidden_from_aura_bar`] (`0x80`). + pub attributes: u32, + /// `AttributesEx` (column 7, `SpellRec+0x1c`) — only bit `0x10000000` is consumed + /// ([`Self::hidden_from_aura_bar`]'s `SPELL_ATTR_EX_NO_AURA_ICON` half). + pub attributes_ex: u32, + /// `AttributesEx2` (column 8, module docs) — only bit `0x20` is consumed + /// ([`Self::ranged_attack`]). + pub attributes_ex2: u32, + /// `AttributesEx3` (column 9, `SpellRec+0x24`) — only bit `0x8000` is consumed + /// ([`Self::melee_white_damage`]). + pub attributes_ex3: u32, + /// `Attributes & 0x40` (`SPELL_ATTR_PASSIVE`, module docs) — the spellbook's gray-and-refuse + /// gate (consumed by `benilla-ui/src/script/spellbook.rs`, decision 0216 §8). + pub passive: bool, + /// `castUI` (column 3, `SpellRec+0xc`, module docs) — the third of the spellbook add-gate's + /// three exclusions ([`Self::in_spellbook`]). Nonzero keeps the spell out of the book; reads + /// 0 for every ordinary player spell. + pub cast_ui: u32, + /// `Effect[0]` (column 61, module docs) — the first effect type. Only + /// [`Self::is_melee_auto_attack`] consumes it (`== SPELL_EFFECT_ATTACK`). + pub effect_1: u32, + /// The `LockType` index this spell can open — its `EffectMiscValue` at whichever of the three + /// effects is `SPELL_EFFECT_OPEN_LOCK` — or `None` if it opens no lock. The GameObject + /// interact-cast (decision 0239) matches this against a lock slot's `LockType` index across the + /// player's known spells: "Opening" for keyless chests, "Mining"/"Herb Gathering"/"Pick Lock" + /// for skill locks. + pub open_lock_type: Option, + /// `Dispel` (column 4, `SpellRec+0x10`) — the `SpellDispelType.dbc` id. The reference client + /// reads exactly this field for `GetPlayerBuffDispelType` / `UnitDebuff`'s third return + /// (byte-verified, decision 0257). Read it through [`Self::debuff_type`]. + pub dispel: u32, + /// `Category` (column 2, [`COL_CATEGORY`]) — the shared-cooldown category; `0` = none. + pub category: u32, + /// `RecoveryTime` ms (column 19) — the spell's own cooldown. + pub recovery_ms: u32, + /// `InterruptFlags` ([`COL_INTERRUPT_FLAGS`]) — what breaks this spell's cast. Ordinary + /// timed casts carry `0xf`; the movement bit (`0x1`, vmangos + /// `SPELL_INTERRUPT_FLAG_MOVEMENT`) gates the cast bar's local self-cancel + /// (`benilla::ui_cast`, where the client-side semantics are documented). + pub interrupt_flags: u32, + /// `ChannelInterruptFlags` ([`COL_CHANNEL_INTERRUPT_FLAGS`]) — what breaks this spell's + /// running channel; the bits live in the *aura*-interrupt space (vmangos tests it with + /// `AURA_INTERRUPT_MOVING_CANCELS`), not `InterruptFlags`'. `0` for non-channels. + pub channel_interrupt_flags: u32, + /// `CategoryRecoveryTime` ms (column 20) — the category's shared cooldown. + pub category_recovery_ms: u32, + /// `StartRecoveryCategory` (column 157) — the GCD category (133 for ordinary spells). + pub start_recovery_category: u32, + /// `StartRecoveryTime` ms (column 158) — the GCD duration (1500 for ordinary spells; 0 = no GCD). + pub start_recovery_ms: u32, + /// `powerType` (column 31) — 0 mana, 1 rage, 3 energy (vmangos `Powers`). + pub power_type: u32, + /// `manaCost` (column 32) — the flat cast cost in `power_type`'s unit. + pub mana_cost: u32, + /// `ManaCostPercentage` (column 156) — percent of base mana added to the flat cost (Judgement + /// and kin); 0 for most spells. + pub mana_cost_pct: u32, + /// `rangeIndex` (column 36) — the `SpellRange.dbc` row ([`SpellRangeCatalog`]). + pub range_index: u32, + /// `Targets` (column 13, [`COL_TARGETS`]) — the `TARGET_FLAG_*` seed mask of the cast-arm's + /// targeting flag_word. `0` for ordinary casts (the implicit-target switch supplies the bits). + pub targets: u32, + /// `EffectImplicitTargetA[0]` (column 82, [`COL_IMPLICIT_TARGET_A1`]) — the implicit-target + /// enum the cast-arm's switch adjusts the flag_word by (and the usable walk's + /// CanAttack/CanAssist fork inside the TargetAuraState leg keys on: 6 = enemy, 21 = friend). + pub implicit_target_a1: u32, + /// `Stances` (column 11) — forms the spell is *explicitly* castable in, `1 << (form-1)` each + /// (the form gate [`Self::usable_in_form`]). 0 = no form requirement of its own. + pub stances: u32, + /// `StancesNot` (column 12) — forms the spell is explicitly forbidden in. + pub stances_not: u32, + /// `CasterAuraState` (column 16) — required caster aura-state index (`1 << (n-1)` against + /// `UNIT_FIELD_AURASTATE`); 0 = none. Revenge's defense state (1). + pub caster_aura_state: u32, + /// `TargetAuraState` (column 17) — required aura-state index on the CURRENT TARGET; 0 = + /// none. The one target-dependent usable leg (§2a leg 10): Execute's healthless-20% (2). + pub target_aura_state: u32, + /// `Totem[2]` (columns 40-41) — tool items that must be PRESENT (not consumed): fishing + /// pole-less fishing, blacksmith hammers. 0 = unused slot. + pub totems: [u32; 2], + /// `Reagent[8]`/`ReagentCount[8]` (columns 42-49/50-57) as (item entry, count) pairs — + /// consumed materials. Entry 0 = unused slot. + pub reagents: [(u32, u32); 8], + /// `EquippedItemClass` (column 58, signed) — the item class a worn item must match (2 = + /// weapon for Auto Shot's bow); `-1` = no requirement. + pub equipped_item_class: i32, + /// `EquippedItemSubClassMask` (column 59) — `1 << subclass` mask refining + /// [`Self::equipped_item_class`] (`0x4000c` = bows|guns|crossbows). + pub equipped_item_subclass_mask: u32, + /// `RequiresSpellFocus` ([`COL_REQUIRES_SPELL_FOCUS`]) — the `SpellFocusObject.dbc` object + /// that must be nearby (1 Anvil, 3 Forge, 4 Cooking Fire — [`crate::spell_focus`]); 0 = none. + /// The crafting book's "Requires: …" line (0437). + pub requires_spell_focus: u32, + /// The `SpellShapeshiftForm.dbc` form id this spell shifts into — the `EffectMiscValue` of + /// its first `SPELL_AURA_MOD_SHAPESHIFT` apply-aura effect — or `None` for a non-form spell. + /// The stance bar's admission + isActive keys (wow-re `shapeshift-bar-api.md`, VERIFIED). + pub shapeshift_form: Option, + /// `StanceBarOrder` ([`COL_STANCE_BAR_ORDER`], signed) — the stance bar's sort key + /// (ascending, negative last, spell id tiebreak). + pub stance_bar_order: i32, + /// `ActiveIconID`'s resolved texture path ([`COL_ACTIVE_ICON_ID`]) — shown on the stance + /// button while this form is active, when present (druid forms); `None` falls back to `icon`. + pub active_icon: Option, + /// `Description` enUS (column 138, module docs) — the tooltip body, raw `$`-token text + /// un-substituted (decision 0274 P2's engine resolves the tokens; this is the source text). + /// `None` when the column is empty. + pub description: Option, + /// `AuraDescription` enUS (column 147, module docs) — the buff/debuff-icon tooltip text (a + /// shorter restatement of the periodic/ongoing effect); `None` when empty (most direct-damage/ + /// direct-effect spells with no aura component, e.g. Fire Blast). + pub aura_description: Option, + /// `DurationIndex` (column 30, module docs) — the [`SpellDurationCatalog`] row. `0` resolves to + /// no row (an instant-hit spell with no periodic/aura tail). + pub duration_index: u32, + /// `CastingTimeIndex` (column 18, module docs) — the [`SpellCastTimeCatalog`] row. Row 1 is the + /// universal instant sentinel (`0` ms). + pub casting_time_index: u32, + /// `ProcChance` (column 25, module docs) — percent chance to trigger on the proc event + /// `procFlags` names (not read here); vmangos's own `101` sentinel means "always, no roll". + pub proc_chance: u32, + /// `EffectBasePoints[3]` (column 76, module docs, **signed**) — each effect's roll floor. + /// `-1` is the weapon-damage/no-fixed-roll sentinel (Auto Shot, Feign Death). + pub effect_base_points: [i32; 3], + /// `EffectDieSides[3]` (column 64, module docs, **signed**) — each effect's die size; the roll + /// is `base_points + 1 ..= base_points + die_sides` (`die_sides <= 1` collapses to a flat value). + pub effect_die_sides: [i32; 3], + /// `EffectBaseDice[3]` (column 67, module docs). + pub effect_base_dice: [i32; 3], + /// `EffectAmplitude[3]` ms (column 94, module docs) — a periodic effect's tick period; `0` = + /// not periodic. + pub effect_amplitude: [u32; 3], + /// `EffectApplyAuraName[3]` (column 91 == [`COL_EFFECT_APPLY_AURA_1`]) — each effect's + /// `SpellAuraDefines` aura-type enum; `0` = not an apply-aura effect. Slot 0 is also read via + /// [`Self::shapeshift_form`]'s derivation. + pub effect_apply_aura: [u32; 3], + /// `EffectRadiusIndex[3]` (column 88, module docs) — each effect's `SpellRadius.dbc` row (not + /// loaded by this crate yet); `0` = no radius (a single-target effect). + pub effect_radius_index: [u32; 3], + /// `EffectChainTarget[3]` (column 100, module docs) — extra chain/jump targets beyond the + /// primary; `0` for the overwhelming majority of spells (thinnest-evidence pin — module docs). + pub effect_chain_targets: [u32; 3], + /// `EffectMultipleValue[3]` (column 97, module docs, f32) — a chain/multi-target falloff + /// multiplier; `0.0` for most effects. + pub effect_multiple_value: [f32; 3], + /// `EffectTriggerSpell[3]` (column 109 == [`COL_EFFECT_TRIGGER_1`]) — each effect's triggered + /// spell id (a proc, a learn-wrapper's taught ability, Frost Armor's `$6136` chill proc); `0` = + /// no trigger. + pub effect_trigger_spell: [u32; 3], + /// `EffectItemType[3]` ([`COL_EFFECT_ITEM_TYPE_1`]) — the item entry a + /// `SPELL_EFFECT_CREATE_ITEM` effect creates (the crafting book's product, 0437); `0` = none. + pub effect_item_type: [u32; 3], + /// `EffectMiscValue[3]` (column 106 == [`COL_EFFECT_MISC_1`], **signed**) — each effect's + /// misc payload. For an effect-47 opener, slot 0 is the **window routing key** (wow-re + /// `tradeskill` node, VERIFIED at `0x6e4bd7`: `!= 0` → the CraftFrame — Enchanting 3, Beast + /// Training 1 — else the TradeSkillFrame). The OPEN_LOCK/shapeshift slots are already + /// exposed derived ([`Self::open_lock_type`]/[`Self::shapeshift_form`]); this is the raw + /// array. + pub effect_misc_value: [i32; 3], +} + +/// Hand-rolled so `equipped_item_class` defaults to the data's own "no requirement" (`-1`) — +/// a derived 0 would be a *real* class requirement, silently tripping the usable walk's +/// equipped-item leg on every synthetic/`..Default::default()` spell. +impl Default for SpellDisplay { + fn default() -> Self { + SpellDisplay { + name: String::new(), + rank: None, + icon: None, + visual: 0, + speed: 0.0, + attributes: 0, + attributes_ex: 0, + attributes_ex2: 0, + attributes_ex3: 0, + passive: false, + cast_ui: 0, + effect_1: 0, + open_lock_type: None, + dispel: 0, + category: 0, + recovery_ms: 0, + interrupt_flags: 0, + channel_interrupt_flags: 0, + category_recovery_ms: 0, + start_recovery_category: 0, + start_recovery_ms: 0, + power_type: 0, + mana_cost: 0, + mana_cost_pct: 0, + range_index: 0, + targets: 0, + implicit_target_a1: 0, + stances: 0, + stances_not: 0, + caster_aura_state: 0, + target_aura_state: 0, + totems: [0; 2], + reagents: [(0, 0); 8], + equipped_item_class: -1, + equipped_item_subclass_mask: 0, + requires_spell_focus: 0, + shapeshift_form: None, + stance_bar_order: 0, + active_icon: None, + description: None, + aura_description: None, + duration_index: 0, + casting_time_index: 0, + proc_chance: 0, + effect_base_points: [0; 3], + effect_die_sides: [0; 3], + effect_base_dice: [0; 3], + effect_amplitude: [0; 3], + effect_apply_aura: [0; 3], + effect_radius_index: [0; 3], + effect_chain_targets: [0; 3], + effect_multiple_value: [0.0; 3], + effect_trigger_spell: [0; 3], + effect_item_type: [0; 3], + effect_misc_value: [0; 3], + } + } +} + +/// The `SpellDispelType.dbc` ids the debuff border tints by (vmangos `DispelType`, +/// `SpellDefines.h:679-692`). The reference's `DebuffTypeColor` table keys on exactly these four +/// names and colors everything else — including `0` (none) and the untinted 5–10 (stealth, +/// invisibility, enrage, …) — with its `"none"` entry. +const DISPEL_NAMES: [(u32, &str); 4] = [(1, "Magic"), (2, "Curse"), (3, "Disease"), (4, "Poison")]; + +impl SpellDisplay { + /// The dispel-class name a debuff border tints by (`"Magic"`/`"Curse"`/`"Disease"`/`"Poison"`), + /// or `None` for an undispellable class — the `debuffType` return of `UnitAura`/`UnitDebuff`, + /// whose `nil` the reference maps to `DebuffTypeColor["none"]`. + /// + /// The real client resolves the name through `SpellDispelType.dbc`; we map the four ids the UI + /// actually distinguishes, which are stable and English in that table's enUS column. Nothing + /// reads the other ids. + pub fn debuff_type(&self) -> Option<&'static str> { + DISPEL_NAMES + .iter() + .find(|(id, _)| *id == self.dispel) + .map(|(_, name)| *name) + } + + /// The client's ranged-stance gate, verbatim: `AttributesEx2 & 0x20` (auto-repeat: Auto Shot, + /// wand Shoot) **or** `Attributes & 0x2` (uses the ranged slot: every Shoot variant, Throw). + /// Byte-verified in wow-re (`SpellRec+0x20&0x20 || +0x18&0x2` — the test every ranged trigger + /// runs: the `SMSG_SPELL_START` stance/ammo sites `0x6e78b6`/`0x6e78f3` and the local cast-send + /// site `0x6e5930`). + pub fn ranged_attack(&self) -> bool { + self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 || self.attributes & ATTR_RANGED != 0 + } + + /// The auto-repeat attribute **alone** (`AttributesEx2 & 0x20`) — the narrower gate on the + /// client's local auto-repeat state bit (`[+0xd58] & 0x200`, written only at the local + /// cast-send `0x6e593b`), which drives the shooter's standing Load/Hold idle. `Throw` is + /// ranged but not auto-repeat — it never sets this. + pub fn auto_repeat(&self) -> bool { + self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 + } + + /// `AttributesEx3 & 0x400000` — `SPELL_ATTR_EX3_CASTING_CANCELS_AUTOREPEAT`: a *running* + /// auto-repeat carrying this bit is cancelled when any new cast begins (the client's + /// `0x60959e` test on the **cached** spell, wow-re `nocked-ammo-cancel.md` §Q-B-5). Exactly + /// ONE of the 22357 `Spell.dbc` records has it — wand Shoot 5019; Auto Shot (75) survives + /// new casts, which is how hunter shot-weaving works at all. + pub fn casting_cancels_autorepeat(&self) -> bool { + self.attributes_ex3 & 0x0040_0000 != 0 + } + + /// The ranged-slot attribute **alone** (`Attributes & 0x2`) — the client's weapon-visual + /// fallback gate (`0x60d46a`): a ranged-slot spell whose own `SpellVisual` resolves to nothing + /// borrows the equipped ranged weapon's `ItemDisplayInfo` visual for its fire animation — + /// how Throw/Auto Shot/wand Shoot animate at all (byte-verified, wow-re + /// `throw-ranged-attack-anim.md`; every one of them has `SpellVisual1 = 0`). + pub fn ranged_slot(&self) -> bool { + self.attributes & ATTR_RANGED != 0 + } + + /// `AttributesEx3 & 0x8000` — this spell's damage numbers render **melee-white**, not + /// spell-gold: the second half of the combat-text color law's `B` bit (byte-verified: the + /// emitter `0x6128b0` tests `sign(byte[SpellRec+0x25])` = bit 15 of the `+0x24` word, wow-re + /// `combattext-color-law.md`; the "no record ⇒ melee" half lives at the call sites). On the + /// real 5875 DBC the bit is set on exactly the ranged basic shots — Auto Shot 75, Shoot Bow + /// 2480, Throw 2764, wand Shoot 5019 — whose damage ships as spell packets yet floats white + /// (vmangos delivers them via `SMSG_SPELLNONMELEEDAMAGELOG`; the modern name is + /// `SPELL_ATTR3_NORMAL_RANGED_ATTACK`). Decision 0376. + pub fn melee_white_damage(&self) -> bool { + self.attributes_ex3 & ATTR_EX3_NORMAL_RANGED_ATTACK != 0 + } + + /// The ranged-shot cooldown pad's gate (`0x6e2b60` at `0x6e2c2c`–`0x6e2c47`, byte-verified — + /// wow-re `ranged-cooldown-sweep.md`): on the caster's own SPELL_GO self-insert, a + /// `Attributes & 0x2` spell WITHOUT `AttributesEx2 & 0x20000` + /// (`SPELL_ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS`) adds the caster's live + /// `UNIT_FIELD_RANGEDATTACKTIME` to its category recovery — how the Throw / wand Shoot + /// button sweeps the weapon's speed with `Spell.dbc` recovery all zero and no server packet + /// (the category-0 Auto Shot inserts the same timer but no read can surface it). + pub fn ranged_speed_cooldown(&self) -> bool { + self.attributes & ATTR_RANGED != 0 + && self.attributes_ex2 & ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS == 0 + } + + /// Whether this spell appears in the spellbook — the client's book **add-gate**, byte-verified + /// (decision 0227; wow-re `system/ui/scratch/spellbook-book-build.md`, `CGPlayer_C::AddSpell + /// 0x5e9c20` → the classify+append `0x4b25b0`). A known spell is kept OUT of the book when any + /// of three gates trips: `Attributes & 0x80` (`SPELL_ATTR_DO_NOT_DISPLAY` — every language, + /// armor/weapon proficiency, and hidden racial passive), `Attributes & 0x20` + /// (`SPELL_ATTR_IS_TRADESKILL` — professions divert to a name-only path, no book slot), or + /// `castUI != 0`. Concrete: Fireball 133 (`Attributes 0x10000`, castUI 0) is shown; + /// Language:Common 668 / Cloth 9078 / One-Handed Axes 196 (all `0xC0 = PASSIVE|DO_NOT_DISPLAY`) + /// are hidden. Passive-but-shown spells exist (a passive is NOT itself a book gate); only the + /// three bits above hide a spell. + pub fn in_spellbook(&self) -> bool { + self.attributes & (ATTR_DO_NOT_DISPLAY | ATTR_IS_TRADESKILL) == 0 && self.cast_ui == 0 + } + + /// The **melee auto-attack** — `Effect[0] == SPELL_EFFECT_ATTACK (78)`, the client's own + /// effect-type trigger for the equipped-weapon icon substitution (decision 0231; wow-re + /// `attack-icon-substitution.md`, resolvers `0x4b3f8a`/`0x4e59de`). In 1.12 the only spell + /// carrying it is 6603 "Attack" (its `SpellIconID` → the `Temp` placeholder the client never + /// shows, substituting the main-hand weapon icon instead). + pub fn is_melee_auto_attack(&self) -> bool { + self.effect_1 == SPELL_EFFECT_ATTACK + } + + /// The **ranged** icon-substitution gate — `Attributes & 0x2` AND `AttributesEx2 & 0x20`, + /// both bits (decision 0231's deferred ranged case; wow-re `attack-icon-substitution.md` §5, + /// byte-verified: the paired tests at `0x4b3f99`/`0x4b3f9f` and `0x4e5a2e`/`0x4e5a34` gate the + /// call into the ranged helper `0x4e6990`). Auto Shot 75 and wand Shoot 5019 carry both; + /// Throw 2764 carries only `0x2` and keeps its own icon. + pub fn ranged_icon_substitution(&self) -> bool { + self.attributes & ATTR_RANGED != 0 && self.attributes_ex2 & ATTR_EX2_AUTO_REPEAT != 0 + } + + /// `SPELL_ATTR_COOLDOWN_ON_EVENT` (Attributes bit 25) — the cooldown record is stored **on + /// hold** (timers parked) until `SMSG_COOLDOWN_EVENT` starts it ([`ATTR_COOLDOWN_ON_EVENT`]). + pub fn cooldown_on_event(&self) -> bool { + self.attributes & ATTR_COOLDOWN_ON_EVENT != 0 + } + + /// The on-next-swing class (`Attributes & 0x404`, [`ATTR_ON_NEXT_SWING`]) — the spell queues + /// on the server's melee slot and fires on the next swing instead of casting. The client's + /// one mask for the class: the already-casting exemption (a queued one never blocks another + /// cast), the queued-melee slot tracking, and the range law's self-cast short-circuit all + /// test exactly these bits. + pub fn on_next_swing(&self) -> bool { + self.attributes & ATTR_ON_NEXT_SWING != 0 + } + + /// Whether *casting* this spell turns on the melee auto-attack **at the send** — + /// `Spell_C::TryCast`'s post-send tail (`6e51b5`), byte-verified whole by the 2026-07-14 + /// wow-re §5 (`combat-feel-law.md` @ c445713b): fires the attack entry `0x6131a0` iff the + /// cast committed+sent this call (`[0xcead5c] == spell_id`), the rec passes + /// `[ebp-2] = 0x6e5200 && Ex2-bit20 CLEAR`, and no attack is already running (`0x60ecb0`). + /// With `0x6e5200 = (Attr & 0x404) || (AttrEx & 0x200) || (AttrEx2 & 0x100000)`, the + /// bit20-clear exclusion reduces the send-time law to exactly this: on-next-swing + /// (the queued strike needs swings to fire at all) or [`ATTR_EX_INITIATES_COMBAT`] + /// (Rend, Sunder Armor, Slam), and not [`ATTR_EX2_INITIATE_COMBAT_POST_CAST`] (whose + /// GO-deferred start we don't build — dormant by data). **Path-independent**: action + /// button, spellbook, and `CastSpellByName` all share the one `TryCast` tail — 1.12 has + /// no macro-side opt-out. + pub fn initiates_auto_attack(&self) -> bool { + (self.attributes & ATTR_ON_NEXT_SWING != 0 + || self.attributes_ex & ATTR_EX_INITIATES_COMBAT != 0) + && self.attributes_ex2 & ATTR_EX2_INITIATE_COMBAT_POST_CAST == 0 + } + + /// Hidden from the player's buff bar — the cache-builder's **display filter** + /// (`PlayerAuras_Update 0x4e4170`'s append pass, sites `0x4e42b6`–`0x4e42c8`). The `Attributes` + /// clause is a **byte-width** read — `mov cl,[SpellRec+0x18]; test cl,cl; js` — so it tests + /// bit `0x80` (`SPELL_ATTR_DO_NOT_DISPLAY`), NOT the dword sign bit that wow-re's + /// `aura-display-pipeline.md` §3 originally transcribed (decision 0385 corrects 0268; vmangos + /// corroborates: `SpellDefines.h:799` "not visible in spellbook or aura bar"). The other + /// clause is `SpellRec+0x1c & 0x10000000` (`SPELL_ATTR_EX_NO_AURA_ICON`). The aura stays live + /// on the wire and in `UNIT_FIELD_AURA` — the reference just never admits it to the display + /// cache, so `GetPlayerBuff` cannot return it. Warrior stances (the `AttributesEx` clause) and + /// internal proc auras like Defensive State 5302 (the `0x80` clause) are the everyday cases. + pub fn hidden_from_aura_bar(&self) -> bool { + self.attributes & ATTR_DO_NOT_DISPLAY != 0 || self.attributes_ex & ATTR_EX_NO_AURA_ICON != 0 + } + + /// A **tracking** spell — any of the three `EffectApplyAuraName` slots is + /// `TRACK_CREATURES`/`TRACK_RESOURCES`/`TRACK_STEALTHED` ([`TRACKING_AURA_TYPES`], the + /// byte-verified `{0x2c,0x2d,0x97}` test at `SpellRec+0x16c..+0x174`). A tracking aura is + /// excluded from **every** aura display — the player-cache rebuild skips it *before* the + /// insert (so the buff bar never shows Find Minerals), and `IsAuraDisplayable 0x519860` + /// hides it from other units' rows the same way. Instead, the rebuild records the last + /// visible tracking spell for `GetTrackingTexture` — the minimap's `MiniMapTrackingFrame` + /// icon (its one consumer; `benilla::ui_aura` mirrors the whole law). + pub fn tracking_aura(&self) -> bool { + self.effect_apply_aura + .iter() + .any(|a| TRACKING_AURA_TYPES.contains(a)) + } + + /// The shapeshift-form gate — the usable walk's leg 6 (`0x612480`, wow-re §2a: reads the + /// caster form, builds `1 << (form-1)`, tests **StancesNot** then **Stances**, then the + /// Attributes-bit-16 / AttributesEx2-bit-19 composition). The composition over the form's + /// stance flag is the vmangos corroboration (`SpellEntry::GetErrorAtShapeshiftedCast`, + /// `SpellEntry.cpp:1030` — anchored to exactly the byte-named fields): a *stance*-flagged + /// form (warrior stances, stealth — `SpellShapeshiftForm.flags1 & 1`) does not count as + /// "shapeshifted", so ordinary spells stay usable in it; a true shapeshift (cat, bear) + /// blocks NOT_SHAPESHIFT spells; and a form-requiring spell out of its form is unusable + /// unless AttributesEx2 bit 19 waives the requirement. + pub fn usable_in_form(&self, form: u8, form_is_stance: bool) -> bool { + let stance_bit = if form == 0 { 0 } else { 1u32 << (form - 1) }; + if self.stances_not & stance_bit != 0 { + return false; + } + if self.stances & stance_bit != 0 { + return true; + } + if form != 0 && !form_is_stance { + // A true shapeshift: NOT_SHAPESHIFT spells are blocked; a spell needing some + // other form is too. + self.attributes & ATTR_NOT_SHAPESHIFT == 0 && self.stances == 0 + } else { + // Unshifted (or a stance): a form-requiring spell is unusable here unless waived. + self.stances == 0 || self.attributes_ex2 & ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED != 0 + } + } +} diff --git a/crates/benilla-formats/src/spells/duration.rs b/crates/benilla-formats/src/spells/duration.rs new file mode 100644 index 00000000..6e4d7eae --- /dev/null +++ b/crates/benilla-formats/src/spells/duration.rs @@ -0,0 +1,152 @@ +//! `SpellDuration.dbc` — the base/per-level/max duration (ms) a spell's `DurationIndex` column +//! ([`crate::spells::SpellDisplay::duration_index`]) resolves against. Feeds the duration +//! formula — `Spell_C::GetDuration 0x6ea000`, byte-confirmed by the 2026-07-10 wow-re §5 +//! cross-check (`wave-cooldown.md`, wow-re commit `f2c563c9`): it reads DurationIndex +//! `[SpellRec+0x78]`, resolves this table's recordsById at `[0xc0d828]`, and applies spell-mod +//! op `1` (SPELLMOD_DURATION). The row layout is settled independently (WoWDBDefs' +//! `1.0.0.3980`–`1.12.3.6141` layout `$id$ID<32> Duration<32> DurationPerLevel<32> +//! MaxDuration<32>`, covering build 5875 — cross-checked against the real extracted file below). +//! +//! **Row layout** — pinned on the extracted 5875 file (82 records × 4 fields, 16 B/record): `ID(0)`, +//! `Duration(1)` = the ms a flat 1.12 tooltip shows, `DurationPerLevel(2)`, `MaxDuration(3)`. All +//! three are **signed**: row 21 = `{-1, 0, -1}` — the client's "permanent, until cancelled" +//! sentinel (a stance/passive-style aura with no timer; [`SpellDuration::is_permanent`]) — and a +//! few rows (e.g. 427) carry a negative base with a nonzero per-level term for level-scaling +//! formulas this crate doesn't evaluate; the raw triple is carried faithfully. Row 30 = +//! `{1_800_000, 0, 1_800_000}` — Frost Armor (spell 168)'s real 30-minute duration, cross-checked +//! end-to-end against the local vmangos `spell_template` (`durationIndex` 30 for entry 168). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{i32_at, parse, u32_at}; + +/// One `SpellDuration.dbc` row (module doc's row law). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SpellDuration { + /// The duration a flat (level-independent) tooltip render shows, ms. `-1` = permanent + /// ([`Self::is_permanent`]). + pub base_ms: i32, + /// Signed ms added per caster level above the spell's `BaseLevel`; `0` for most rows. + pub per_level_ms: i32, + /// The ceiling the level-scaled duration clamps to. + pub max_ms: i32, +} + +impl SpellDuration { + /// The client's "permanent, no timer" sentinel (`base_ms == -1`) — a stance or passive-style + /// aura that lasts until cancelled, not a countdown. + pub fn is_permanent(&self) -> bool { + self.base_ms == -1 + } +} + +/// `SpellDuration.dbc`, by row id ([`crate::spells::SpellDisplay::duration_index`]). +#[derive(Default)] +pub struct SpellDurationCatalog { + durations: HashMap, +} + +impl SpellDurationCatalog { + /// Test-only seeding (the token engine's unit tests build tiny catalogs). + #[cfg(test)] + pub(crate) fn insert_for_tests(&mut self, index: u32, base_ms: i32) { + self.durations.insert( + index, + SpellDuration { + base_ms, + per_level_ms: 0, + max_ms: base_ms, + }, + ); + } + + pub fn get(&self, index: u32) -> Option<&SpellDuration> { + self.durations.get(&index) + } + + pub fn len(&self) -> usize { + self.durations.len() + } + + pub fn is_empty(&self) -> bool { + self.durations.is_empty() + } +} + +const SPELL_DURATION: &str = "DBFilesClient\\SpellDuration.dbc"; +const SPELL_DURATION_FIELDS: usize = 4; + +/// Load `SpellDuration.dbc` off the patch chain ([`SpellDuration`]'s row law). +pub fn load_spell_durations(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(SPELL_DURATION) + .context("reading SpellDuration.dbc")?; + let mut schema = Schema::new("SpellDuration"); + for i in 0..SPELL_DURATION_FIELDS { + if i == 0 { + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + } else { + schema.add_field(SchemaField::new(format!("F{i}"), FieldType::Int32)); + } + } + let set = parse(&bytes, schema, "SpellDuration.dbc")?; + let mut durations = HashMap::new(); + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + durations.insert( + id, + SpellDuration { + base_ms: i32_at(r, 1).unwrap_or(0), + per_level_ms: i32_at(r, 2).unwrap_or(0), + max_ms: i32_at(r, 3).unwrap_or(0), + }, + ); + } + Ok(SpellDurationCatalog { durations }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `SpellDuration.dbc` on the real data — the module doc's own probe rows. Skips without + /// client data. + #[test] + fn real_spell_durations_read_the_probed_rows() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let durations = load_spell_durations(&mut chain).expect("load SpellDuration"); + + // Row 30: Frost Armor's real 30-minute duration (vmangos spell_template.durationIndex==30 + // for entry 168). + let frost_armor = durations.get(30).expect("row 30"); + assert_eq!( + ( + frost_armor.base_ms, + frost_armor.per_level_ms, + frost_armor.max_ms + ), + (1_800_000, 0, 1_800_000) + ); + assert!(!frost_armor.is_permanent()); + + // Row 21: the permanent sentinel. + let permanent = durations.get(21).expect("row 21"); + assert_eq!(permanent.base_ms, -1); + assert!(permanent.is_permanent()); + + // Row 1: a short, ordinary duration. + let short = durations.get(1).expect("row 1"); + assert_eq!((short.base_ms, short.per_level_ms), (10_000, 0)); + + assert_eq!(durations.len(), 82, "5875 ships 82 SpellDuration rows"); + } +} diff --git a/crates/benilla-formats/src/spells/forms.rs b/crates/benilla-formats/src/spells/forms.rs new file mode 100644 index 00000000..9a3aa24f --- /dev/null +++ b/crates/benilla-formats/src/spells/forms.rs @@ -0,0 +1,92 @@ +//! `SpellShapeshiftForm.dbc` — bonus bars + the form gate's stance flag: the per-form +//! `BonusActionBar` page the action bar flips to on shifting, and the `flags1` stance bit +//! ([`ShapeshiftForm::is_stance`]) [`crate::spells::SpellDisplay::usable_in_form`] reads to tell a +//! true shapeshift (blocks NOT_SHAPESHIFT spells) from a stance (doesn't). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{i32_at, parse, str_at, u32_at}; + +const SHAPESHIFT_FORM: &str = "DBFilesClient\\SpellShapeshiftForm.dbc"; + +/// One `SpellShapeshiftForm.dbc` row's consumed fields. +#[derive(Clone, Debug, Default)] +pub struct ShapeshiftForm { + /// **BonusActionBar** (field 1) — the stance page the bar flips to (0 = none). + pub bonus_bar: u32, + /// **Name** (the locstring at fields 2..10; enUS slot) — "Battle Stance", "Cat Form" — + /// the `SPELL_REQUIRED_FORM` "Requires %s" cell of the spell tooltip (the 0276 line law §3.6). + pub name: String, + /// `flags1` (field 11, vmangos `SpellShapeshiftFormEntry`). Bit 0 = the form is a *stance* + /// (warrior stances, stealth): it does NOT count as "shapeshifted" for the form gate — + /// vmangos `SHAPESHIFT_FLAG_STANCE`, the `actAsShifted` fork of + /// [`SpellDisplay::usable_in_form`]. + pub flags: u32, + /// **creatureType** (field 12, int32 — the byte-carved `+0x30` read, wow-re + /// `track-predicates.md`): the form's creature-type OVERRIDE. The client's creature-type + /// resolver (`0x605570`) reads a shapeshifted unit's type from HERE before the creature + /// template or race table — a cat-form druid is a Beast (1) to the minimap tracking + /// predicates. `<= 0` reads Humanoid (the resolver's fallback; vmangos's own row comment). + /// Consumed by the tracking dots (decision 0564). + pub creature_type: i32, +} + +impl ShapeshiftForm { + /// `flags1 & 1` — see [`ShapeshiftForm::flags`]. + pub fn is_stance(&self) -> bool { + self.flags & 1 != 0 + } + + /// Clicking this form's stance button while it is ACTIVE cancels the form aura + /// (`CMSG_CANCEL_AURA`) — unless `flags1` bit `0x2` blocks it (`CastShapeshiftForm + /// 0x4b4810`'s guard at `0x4b4963`, wow-re `shapeshift-bar-api.md` VERIFIED: active + bit + /// set = silent no-op). 5875: the warrior stances carry `0x7` (blocked — you never cancel + /// OUT of a stance); druid forms (0x70/0x50), Stealth (0x1), Moonkin (0x41), Shadowform + /// (0x9), Ghost Wolf (0x40) all cancel. + pub fn cancelable(&self) -> bool { + self.flags & 0x2 == 0 + } +} + +/// `SpellShapeshiftForm.dbc` → form id → the consumed row ([`ShapeshiftForm`]). +/// +/// **BonusActionBar** is the client's own bar mapping (wow-re, byte-verified 2026-07-02: +/// `GetBonusBarOffset 0x4e7620` returns a cached global that the `UPDATE_BONUS_ACTIONBAR` +/// handler `0x4e4fc0` fills by indexing this DBC by the player's shapeshift form and reading +/// `rec->field[1]` — data, not a stance switch). 5875 values: Cat(1)→1, Bear(5)/DireBear(8)→3, +/// Battle(17)→1, Defensive(18)→2, Berserker(19)→3, Stealth(30)→1, everything else 0. +/// **flags1** feeds the form gate (`0x612480`, [`SpellDisplay::usable_in_form`]). +/// 32 records × 14 fields (56 B), verified on the extracted file. +pub fn load_shapeshift_forms(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file(SHAPESHIFT_FORM) + .context("reading SpellShapeshiftForm.dbc")?; + let mut schema = Schema::new("SpellShapeshiftForm"); + for i in 0..14 { + match i { + // Field 2: the name locstring's enUS slot (fields 2..10 = 8 locales + flag word). + 2 => schema.add_field(SchemaField::new("Name", FieldType::String)), + _ => schema.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)), + } + } + let set = parse(&bytes, schema, "SpellShapeshiftForm.dbc")?; + let mut map = HashMap::new(); + for r in set.records() { + if let Some(id) = u32_at(r, 0) { + map.insert( + id, + ShapeshiftForm { + bonus_bar: u32_at(r, 1).unwrap_or(0), + name: str_at(&set, r, 2).unwrap_or_default(), + flags: u32_at(r, 11).unwrap_or(0), + creature_type: i32_at(r, 12).unwrap_or(0), + }, + ); + } + } + Ok(map) +} diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs new file mode 100644 index 00000000..1acec1ad --- /dev/null +++ b/crates/benilla-formats/src/spells/mod.rs @@ -0,0 +1,642 @@ +//! Spell.dbc + SpellIcon.dbc loader — the spell **display** catalog the action bar reads +//! (decision 0068 slice 1): a spell id resolves to its name and its icon's BLP path. +//! +//! Layout — VERIFIED against build 5875 (empirical column derivation on the extracted files, +//! 2026-07-02): `Spell.dbc` is 22357 records × **173 fields** (692 B — matches wow-re's +//! byte-verified container fact, `system/dbc`). The two columns this catalog reads were pinned +//! by resolving known spells against the data: **SpellIconID = column 117** (78 "Heroic Strike" → +//! icon 856 `Ability_Rogue_Ambush`, 6673 "Battle Shout" → icon 456 `Ability_Warrior_BattleShout`; +//! both hit column 117 uniquely) and **SpellName enUS = column 120** (all probe spells resolve +//! their known name there and nowhere else — the head of the 8-locale + flags name block, +//! 120..128). `SpellIcon.dbc` is 1033 records × 2 fields: `ID(0), TextureFilename(1, str)` — paths +//! like `Interface\Icons\Ability_Rogue_Ambush` (no extension; the BLP loader appends it). +//! +//! **SpellVisualID = column 115 · projectile Speed (f32) = column 37** (decision 0107's phase-2 +//! data plane) — pinned empirically, 2026-07-04, against the local vmangos `mangos.spell_template` +//! (`spellVisual1`/`speed`, build ≤ 5875) cross-checked column-by-column over the extracted +//! `Spell.dbc`: for every nonzero `spellVisual1` in `spell_template` (1309 rows sampled, ids +//! 1..2000), column 115 is the **only** u32 column that matches, and for every nonzero `speed` +//! (61 rows), column 37 is the only f32 column that matches — both unique across all 173 columns. +//! Column 37 also matches wow-re's `SpellRec+0x94` lead bit-for-bit (`0x94 / 4 == 37`); the +//! `+0x1e0` visual-id lead does **not** hold raw (`0x1e0 / 4 == 120`, already pinned above as the +//! name string), confirming the in-memory record isn't a raw row copy for that field — column 115 +//! is the empirical column, not a translated offset. Spot-check (Fireball, entry 133): +//! `spell_template` gives `spellVisual1=67, speed=24.0`; `Spell.dbc` row 133 reads column 115 = 67, +//! column 37 = 24.0. `spellVisual2` (the DB's second visual column) is 0 for all but one spell in +//! the whole table — not read here. `speed == 0` means an instant-impact spell (decision 0099's +//! `Speed==0` gate — no missile phase); `visual == 0` means no `SpellVisual.dbc` row (silent cast). +//! +//! **Attributes = column 6 · AttributesEx2 = column 8** (decision 0099 phase 5's ranged-stance +//! gate) — pinned the same way, 2026-07-05: over every `spell_template` row with a nonzero +//! attribute word (2700 spells, ids ≤ 4000, each at its `MAX(build) ≤ 5875` row), column 6 is the +//! **only** column matching `attributes` (runner-up 49/2700) and column 8 the only one matching +//! `attributesEx2` — and both agree with wow-re's byte-verified in-memory reads (`SpellRec+0x18` / +//! `+0x20`: `0x18/4 == 6`, `0x20/4 == 8`; the record layout is raw this early, before the +//! translated tail that broke the `+0x1e0` lead above). Only two bits are consumed — the client's +//! ranged gate, [`SpellDisplay::ranged_attack`]; `Attributes` bit `0x40` — `SPELL_ATTR_PASSIVE` +//! (vmangos `SpellDefines.h`) — for [`SpellDisplay::passive`] (decision 0216 §8); and the +//! spellbook **add-gate** [`SpellDisplay::in_spellbook`] (decision 0227) — `Attributes` bit `0x80` +//! (`SPELL_ATTR_DO_NOT_DISPLAY`), bit `0x20` (`SPELL_ATTR_IS_TRADESKILL`), and **castUI = column +//! 3** (`SpellRec+0xc`, `0xc/4 == 3` — raw this early like columns 6/8). castUI was pinned by the +//! same wow-re byte read that pinned the gate (`system/ui/scratch/spellbook-book-build.md`, the +//! 2026-07-08 §5), not an empirical column derivation; it reads 0 for every ordinary player spell +//! (Fireball 133), so it is exercised only as a gate that never trips on the shown set. +//! +//! **SpellNameSubtext enUS = column 129** (decision 0216 §8) — pinned empirically the same way as +//! the name column: NameEnUs's own locale block runs `120..127` (8 locales) `+128` (the name's +//! flags word), so the very next `LocalizedString` head at `129` was the candidate; confirmed +//! against six real spells spanning the whole rank range (Fireball 133/143/145 → "Rank 1"/"Rank +//! 2"/"Rank 3", Frost Armor 168 → "Rank 1", Corruption 172 → "Rank 1", Fire Blast 2136 → "Rank +//! 1") — every one resolves at column 129 and nowhere else in `118..146`. Notably even a spell's +//! FIRST rank carries the literal text "Rank 1" (the ref's `SpellButton_UpdateButton` shows +//! whatever `subSpellName` answers, unconditionally — no "hide Rank 1" special case, matching +//! `SpellBookFrame.lua:379-402`). +//! +//! **Description enUS = column 138 · AuraDescription enUS = column 147** (decision 0274 P2, the +//! tooltip arc) — the loc-block arithmetic predicts both exactly: NameSubtext's own 9-dword block +//! (129 enUS + 130..136 the other 7 locales + 137 flags) is immediately followed by a Description +//! block (138 enUS .. 146 flags), then an AuraDescription block (147 enUS .. 155 flags), landing +//! *exactly* on the already-pinned `ManaCostPercentage = 156` (129 + 9 + 9 + 9 == 156) — two +//! independently-verified anchors 27 columns apart with no room for anything else. Confirmed by +//! content, not just arithmetic: read against every `spell_template` row carrying description/ +//! auraDescription text on the local vmangos DB (14278 spells), column 138 matches 13243/13258 +//! (99.89%) and column 147 matches 4215/4225 (99.76%) of the nonempty rows verbatim (raw, +//! un-substituted `$`-token text) — the same noise floor the *already-shipped* name(120)/ +//! nameSubtext(129) pins show against this same DB (14260/14278, 6015/6020: a handful of +//! vmangos-only QA/test spells diverge from the shipped client file, not a column slip). Spot +//! checks: Fireball 133 → "Hurls a fiery ball that causes $s1 Fire damage…"; Frost Armor 168's aura +//! text → "Increases Armor by $s1 and may slow attackers."; Fire Blast 2136 (a direct-damage spell +//! with no aura) → empty aura text. +//! +//! **DurationIndex = column 30 · CastingTimeIndex = column 18 · ProcChance = column 25** — pinned +//! empirically 2026-07-10 against the local vmangos `spell_template` (every entry at its own +//! `MAX(build) ≤ 5875` row, the module's established cross-check, ~22357 spells): each is the +//! *unique* column (of all 173) matching its named vmangos field on every nonzero-valued row — +//! `durationIndex` 11350/11350, `castingTimeIndex` 22354/22354, `procChance` 22129/22129, all +//! 100%. End-to-end confirmation through the new [`crate::SpellCastTimeCatalog`]/ +//! [`crate::SpellDurationCatalog`]: Fireball 133 → CastingTimeIndex 16 → `SpellCastTimes.dbc` row +//! 16 → 1500 ms; Frost Armor 168 → DurationIndex 30 → `SpellDuration.dbc` row 30 → 1,800,000 ms +//! (30 min) — both match this arc's own independently-stated expectation. This pin surfaced a +//! conflict with wow-re's then-current `wave-cooldown.md`, which labeled `[rec+0x48]` (== column +//! 18) *DurationIndex*; the dispatched wow-re §5 cross-check (2026-07-10, wow-re commit +//! `f2c563c9`) **byte-confirmed this module and corrected the note** — the mislabel ran deeper +//! than one offset: `Spell_C::GetCastTime` is `0x6e3340` (`6e336e: mov eax,[edi+0x48]` — +//! CastingTimeIndex → `SpellCastTimes` recordsById `[0xc0d878]`, spell-mod op `0xa` = +//! SPELLMOD_CASTING_TIME), the real `Spell_C::GetDuration` is `0x6ea000` (`6ea016: mov +//! eax,[edi+0x78]` — DurationIndex → `SpellDuration` recordsById `[0xc0d828]`, spell-mod op `1` = +//! SPELLMOD_DURATION), and `0x6e31b0` — the note's old "GetCastTime" — is `GetPowerCost`. The +//! in-memory SpellRec is a direct on-disk row image this deep (offset = column×4, anchored by +//! the verified neighbours `+0x4c`/col19 and `+0x90`/col36), so the byte reads and this +//! empirical map agree exactly: `0x48/4 == 18`, `0x78/4 == 30`. +//! +//! **Per-effect arrays** (each `[3]`, one slot per effect — pinned the same value-match way, same +//! cross-check population, 2026-07-10): `EffectDieSides` 64-66 (die roll, **signed**), +//! `EffectBaseDice` 67-69, `EffectBasePoints` 76-78 (the roll's floor, **signed** — Auto Shot 75 / +//! Feign Death 5384 both carry `-1`, a weapon-damage/no-fixed-roll sentinel), `EffectRadiusIndex` +//! 88-90 (`SpellRadius.dbc` row; Battle Shout 6673 carries 9, its "$a1 yards" party radius), +//! `EffectApplyAuraName` 91-93 (== the already-pinned [`COL_EFFECT_APPLY_AURA_1`]; 9612/9614, +//! 2488/2490, 595/595 — reused, not re-derived), `EffectAmplitude` 94-96 (periodic-tick ms; +//! Fireball 133's DoT tail ticks every 2000 ms), `EffectMultipleValue` 97-99 (f32), +//! `EffectChainTarget` 100-102 (100/101 at 113/10 nonzero samples, 100%; 102 has only *one* +//! nonzero row in the whole table — entry 26044, `1,1,1` across 100-102 — but lands exactly on the +//! consecutive-triple pattern every other array here confirms; flagged as the thinnest-evidence pin +//! of the batch), `EffectTriggerSpell` 109-111 (== the already-pinned [`COL_EFFECT_TRIGGER_1`]; +//! Frost Armor 168's own description text names the exact spell its `EffectTriggerSpell[1]` +//! resolves to — `$6136s2%`/`$6136s1%`, `6136` read straight off column 110). All match 100% except +//! `Effect[0]`/`Effect[1]`/`EffectApplyAuraName[0]`/`EffectApplyAuraName[1]`, which match all but 2 +//! rows each (the same 4 vmangos test entries — 11094/13043/11189/28332 — whose DB row diverges +//! from the shipped client file; not a column slip). +//! +//! Remaining columns (targeting/school-specific secondary fields, item-set data) stay dropped +//! until a consumer exists — the bar/book/tooltip want a face and a description, not a full spell +//! simulator (we render and speak the protocol; the server simulates). + +mod cast_times; +mod display; +mod duration; +mod forms; +mod radius; +mod ranges; +mod tokens; + +pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog}; +pub use display::SpellDisplay; +pub use duration::{load_spell_durations, SpellDuration, SpellDurationCatalog}; +pub use forms::{load_shapeshift_forms, ShapeshiftForm}; +pub use radius::{load_spell_radii, SpellRadius, SpellRadiusCatalog}; +pub use ranges::{load_spell_ranges, SpellRange, SpellRangeCatalog}; +pub use tokens::{substitute, TokenContext}; + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, i32_at, parse, str_at, u32_at}; + +const SPELL: &str = "DBFilesClient\\Spell.dbc"; + +const SPELL_FIELDS: usize = 173; +/// `Category` (`SpellRec+0x8`, `0x8/4 == 2` — raw like columns 3/6/8): the shared-cooldown +/// category (`SpellCategory.dbc` id) — potions 4, Aimed Shot 2, … `0` = no category. Pinned +/// empirically 2026-07-10 against the vmangos `spell_template` rows (12 spells incl. Charge +/// cat 44, Taunt 82, Lay on Hands 56, Hunter's Mark 411, wand Shoot 351 — all match column 2). +const COL_CATEGORY: usize = 2; +const COL_CAST_UI: usize = 3; +/// `RecoveryTime` (`SpellRec+0x4c`, `0x4c/4 == 19`) / `CategoryRecoveryTime` (`+0x50`, 20) — the +/// spell's own cooldown ms and its category's shared cooldown ms, the two inputs of the client's +/// `StartCooldown 0x6e2c60` + the `SMSG_SPELL_COOLDOWN` handler `0x6e9460` (wow-re +/// `wave-cooldown.md`/`wave-handlers.md`, VERIFIED). Same 12-spell empirical pin as +/// [`COL_CATEGORY`] (Feign Death 30000 rec, Charge 15000 catRec, Lay on Hands 3600000 catRec). +const COL_RECOVERY_TIME: usize = 19; +const COL_CATEGORY_RECOVERY_TIME: usize = 20; +/// `InterruptFlags` (21, `SpellRec+0x54`) / `ChannelInterruptFlags` (23, `SpellRec+0x5c`) — what +/// breaks a cast / a running channel. Pinned empirically 2026-07-17 by the module's established +/// cross-check (every vmangos `spell_template` row at its `MAX(build) ≤ 5875`): column 21 is the +/// **unique** column matching `interruptFlags` on all 6389 nonzero rows, column 23 unique on all +/// 343 nonzero `channelInterruptFlags` rows (`AuraInterruptFlags` = 22, 582/582, stays unread — +/// no consumer yet), and the raw-row arithmetic agrees (offset = column×4, chain-locked between +/// the verified neighbors `+0x50`/col 20 and col 25). Spot checks: Fireball 133 interrupt `0xf`, +/// channel 0; Arcane Missiles 5143 channel `0x7c0c`; First Aid 746 interrupt 0, channel `0x3c0e`. +/// Consumed by the cast bar's local self-cancel (`benilla::ui_cast`), where the bit semantics — +/// the client-side movement gate — are documented. +const COL_INTERRUPT_FLAGS: usize = 21; +const COL_CHANNEL_INTERRUPT_FLAGS: usize = 23; +/// `powerType` (31) / `manaCost` (32) / `ManaCostPercentage` (156) — the cast-cost triple +/// `IsUsableAction`'s not-enough-power verdict reads. Same empirical pin (Taunt/Charge pwr 1 = +/// rage; Pyroblast cost 125; pct pinned on its own nonzero rows 370/475/526/527/528 → 10/10/9/18/15). +const COL_POWER_TYPE: usize = 31; +const COL_MANA_COST: usize = 32; +const COL_MANA_COST_PCT: usize = 156; +/// `rangeIndex` (`SpellRec+0x90`, `0x90/4 == 36`) — the `SpellRange.dbc` row the byte-verified +/// `GetMinMaxRange 0x6e3480` resolves (wow-re `wave-cooldown.md`). Same empirical pin (Auto +/// Shot/Aimed Shot 114, Throw 74, Charge 95, Fireball 35). +const COL_RANGE_INDEX: usize = 36; +/// `StartRecoveryCategory` (`SpellRec+0x274`, `0x274/4 == 157`) / `StartRecoveryTime` (`+0x278`, +/// 158) — the **global-cooldown** pair `StartGlobalCooldown 0x6e2de0` reads at the local +/// cast-send (`0x6e58fb`, wow-re `wave-cast.md`, VERIFIED). Category 133 / 1500 ms for ordinary +/// spells; 0/0 for the GCD-free (Attack, Auto Shot, wand Shoot). Same 12-spell empirical pin. +const COL_START_RECOVERY_CATEGORY: usize = 157; +const COL_START_RECOVERY_TIME: usize = 158; +/// `Targets` (`SpellRec+0x34`, `0x34/4 == 13`) — the wire `TARGET_FLAG_*` seed mask the cast-arm +/// loads into its targeting flag_word (`0x6e525a`, wow-re `wave-cast.md`, VERIFIED). Empirical +/// pin against the binder's bit semantics: Resurrection 2006 = `0x8000` (corpse-ally bit 15), +/// Skinning 8613 = `0x402` (unit bit 1 + requires-explicit-selection bit 10), ground AoEs carry +/// `0x40` (dest location), enchant/poison rows `0x10` (item). `0` for ordinary casts. +const COL_TARGETS: usize = 13; +/// `EffectImplicitTargetA[0]` (`SpellRec+0x148`, `0x148/4 == 82`) — the implicit-target enum the +/// cast-arm's 62-case switch keys on (jump-table `0x6e5484`) to set/clear individual flag_word +/// bits. Empirical pin against the switch arms: Fireball 133 / Charge 100 = 6 (→ hostile bit 7), +/// Ice Armor 7302 / Feign Death 5384 = 1 (self — clears bit 10), Arcane Intellect 1459 / Lesser +/// Heal 2050 = 21 (→ assist bit 8), Battle Shout 6673 = 20 (party-area — a no-op arm). +const COL_IMPLICIT_TARGET_A1: usize = 82; +/// The usable-walk columns (`IsSpellUsableNow 0x6e3d60`'s §2a gate table, wow-re +/// `action-button-state-api.md`, byte-verified 2026-07-10; column = SpellRec-offset/4). +/// Empirical pins on the real 5875 data: Claw 1082 Stances `0x1` (cat = form 1), Ambush 8676 +/// Stances `0x20000000` (stealth = form 30) + EquippedItemClass 2 / SubClassMask `0x8000` +/// (dagger), Execute 5308 TargetAuraState 2 (healthless-20%) + Stances `0x50000` +/// (battle/berserker), Revenge 6572 CasterAuraState 1 (defense), Auto Shot 75 EquippedItemClass +/// 2 / SubClassMask `0x4000c` (bows/guns/crossbows), Slow Fall 130 Reagent[0] 17056 ×1 +/// (Light Feather). +const COL_STANCES: usize = 11; +const COL_STANCES_NOT: usize = 12; +const COL_CASTER_AURA_STATE: usize = 16; +const COL_TARGET_AURA_STATE: usize = 17; +const COL_TOTEM_1: usize = 40; +const COL_REAGENT_1: usize = 42; +const COL_REAGENT_COUNT_1: usize = 50; +const COL_EQUIPPED_ITEM_CLASS: usize = 58; +const COL_EQUIPPED_ITEM_SUBCLASS_MASK: usize = 59; +/// `RequiresSpellFocus` (`SpellRec+0x3c`, `0x3c/4 == 15` — the run the pinned neighbors chain-lock: +/// `Stances` 11-12, `Targets` 13, `CasterAuraState` 16). A `SpellFocusObject.dbc` id that must be +/// nearby to cast (1 Anvil, 3 Forge, 4 Cooking Fire — [`crate::spell_focus`]); 0 = none. Verified +/// against live vmangos `spell_template` rows on the real 5875 file (catalog_tests). +const COL_REQUIRES_SPELL_FOCUS: usize = 15; +/// `Dispel` — the `SpellDispelType.dbc` id (`SpellRec+0x10`; the byte offset chain-locks it to +/// `COL_CAST_UI` at `+0xc` and `COL_ATTRIBUTES` at `+0x18`). Decision 0257. +const COL_DISPEL: usize = 4; +const COL_ATTRIBUTES: usize = 6; +/// `AttributesEx` (`SpellRec+0x1c` — chain-locked between `COL_ATTRIBUTES` at `+0x18` and +/// `COL_ATTRIBUTES_EX2` at `+0x20`). +const COL_ATTRIBUTES_EX: usize = 7; +const COL_ATTRIBUTES_EX2: usize = 8; +/// `AttributesEx3` (`SpellRec+0x24` — the next word in the same chain-locked run). Consumed by +/// [`SpellDisplay::melee_white_damage`]'s bit 15. +const COL_ATTRIBUTES_EX3: usize = 9; +const COL_SPEED: usize = 37; +/// `Effect[0]` (`SpellRec+0xf4`, `0xf4/4 == 61` — raw this early, like columns 6/8/37): the +/// spell's first effect type. The action bar / spellbook auto-attack icon substitution keys on it +/// (decision 0231): `Effect[0] == SPELL_EFFECT_ATTACK (78)` is the melee auto-attack, verified at +/// the bytes in wow-re (`system/ui/scratch/attack-icon-substitution.md`, resolvers `0x4b3f8a` / +/// `0x4e59de` both `cmp [SpellRec+0xf4], 0x4e`). +const COL_EFFECT_1: usize = 61; +/// `EffectMiscValue[0]` (column 106). The Spell.dbc effect block is 15 parallel `[3]` arrays from +/// `Effect@61`; `EffectMiscValue` is the 16th, at `61 + 15×3 = 106` — cross-checked by `SpellVisual` +/// landing at its known column 115 (`106 + 3×3`). For a `SPELL_EFFECT_OPEN_LOCK` effect this is the +/// `LockType` index the spell opens (mining = 3, herbalism = 2, per `Lock.dbc`). +const COL_EFFECT_MISC_1: usize = 106; +/// `EffectTriggerSpell[0]` — column 109 (`61 + 3×16`, past the 16 effect-array blocks: the three +/// `Effect`s at 61 are followed by die/base/aura/misc/etc. blocks, `EffectMiscValue` at 106 then +/// `EffectTriggerSpell` at 109, `SpellVisual` at 115). For a `SPELL_EFFECT_LEARN_SPELL` effect this is +/// the taught spell. +const COL_EFFECT_TRIGGER_1: usize = 109; +/// `SpellEffects` value `36` — `SPELL_EFFECT_LEARN_SPELL`: a trainer's learn wrapper carries it, and +/// its `EffectTriggerSpell` is the ability the player ends up with (decision 0247's taught spell). +const SPELL_EFFECT_LEARN_SPELL: u32 = 36; + +/// `SpellEffects` value `SPELL_EFFECT_OPEN_LOCK` — the lock-opening effect the GameObject +/// interact-cast matches (decision 0239; RE `cursor-system.md` §8, the client's +/// `cmp [SpellRec+0xf4], 0x21`). A spell carrying it opens the `LockType` its `EffectMiscValue` names. +const SPELL_EFFECT_OPEN_LOCK: u32 = 0x21; +/// `EffectApplyAuraName[0]` (column 91, `61 + 10×3` — tenth of the effect-`[3]` blocks; pinned on +/// the extracted 5875 file: every form spell — Battle Stance 2457, Bear 5487, Cat 768, Stealth +/// 1784, Moonkin 24858 — carries `36` here, and columns 94-96 don't). The stance bar's +/// admission key (wow-re `shapeshift-bar-api.md`, VERIFIED): `== SPELL_AURA_MOD_SHAPESHIFT`. +const COL_EFFECT_APPLY_AURA_1: usize = 91; +/// `AuraType` value `36` — `SPELL_AURA_MOD_SHAPESHIFT` (vmangos `SpellAuraDefines.h`): the aura +/// that changes the form byte; its `EffectMiscValue` is the `SpellShapeshiftForm.dbc` form id. +const SPELL_AURA_MOD_SHAPESHIFT: u32 = 36; +const COL_ICON_ID: usize = 117; +/// `ActiveIconID` (column 118, `SpellRec+0x1d8` — right after `SpellIconID`, wow-re +/// `shapeshift-bar-api.md` VERIFIED): the icon shown while the spell's form is ACTIVE, when +/// nonzero (druid forms carry icon 122, the "dismiss form" paw; warrior stances carry 0). +const COL_ACTIVE_ICON_ID: usize = 118; +/// `StanceBarOrder` (column 166, `SpellRec+0x298`, SIGNED — wow-re `shapeshift-bar-api.md` +/// VERIFIED: the stance-bar sort comparator `0x4b2bb0` orders ascending by it, `-1` last, spell +/// id as the tiebreak). Pinned on the extracted file: Battle 0 / Defensive 1 / Berserker 2, +/// Bear 0 / Aquatic 1 / Cat 2 / Travel 3 / Moonkin 4, Stealth −1. +const COL_STANCE_BAR_ORDER: usize = 166; +const COL_VISUAL_ID: usize = 115; +const COL_NAME_ENUS: usize = 120; +const COL_NAME_SUBTEXT_ENUS: usize = 129; +/// `Description` enUS (module docs — the loc-block arithmetic + content match). +const COL_DESCRIPTION_ENUS: usize = 138; +/// `AuraDescription` enUS (module docs). +const COL_AURA_DESCRIPTION_ENUS: usize = 147; + +/// `DurationIndex` (`SpellRec+0x78`, `0x78/4 == 30` — byte-confirmed by the wow-re §5, module +/// docs: `Spell_C::GetDuration 0x6ea000`'s own read). `0` = no row (an instant-hit spell with no +/// periodic/aura tail — Fire Blast 2136). +const COL_DURATION_INDEX: usize = 30; +/// `CastingTimeIndex` (`SpellRec+0x48`, `0x48/4 == 18` — byte-confirmed by the wow-re §5, module +/// docs: `Spell_C::GetCastTime 0x6e3340`'s own read). +const COL_CASTING_TIME_INDEX: usize = 18; +/// `ProcChance` (module docs) — percent, `101` is vmangos's own "always proc, no roll" convention +/// on top of the DBC's raw 0-100 scale (carried through unexamined; not a client-side reinterpretation). +const COL_PROC_CHANCE: usize = 25; + +/// The per-effect `[3]` arrays feeding the $-token tooltip engine (decision 0274 P2) — each column +/// is that family's slot-0 entry; slots 1/2 are `+1`/`+2` (module docs' consecutive-triple law, +/// confirmed for every array below). `EffectDieSides`/`EffectBasePoints` are **signed** (a roll's +/// floor can be negative, and `-1` is the weapon-damage/no-fixed-roll sentinel — Auto Shot, +/// Feign Death); the rest are their natural unsigned/float DBC type. +const COL_EFFECT_DIE_SIDES_1: usize = 64; +const COL_EFFECT_BASE_DICE_1: usize = 67; +const COL_EFFECT_BASE_POINTS_1: usize = 76; +const COL_EFFECT_RADIUS_INDEX_1: usize = 88; +const COL_EFFECT_AMPLITUDE_1: usize = 94; +const COL_EFFECT_MULTIPLE_VALUE_1: usize = 97; +const COL_EFFECT_CHAIN_TARGETS_1: usize = 100; +/// `EffectItemType[0]` — column 103, the `[3]` block between `EffectChainTarget` (100-102) and +/// `EffectMiscValue` (106-108) in the module docs' parallel-array walk. The item entry a +/// `SPELL_EFFECT_CREATE_ITEM` effect creates — the crafting book's product (0437). Verified +/// against live vmangos rows on the real 5875 file (catalog_tests: 2963 → 2996 Bolt of Linen +/// Cloth, 2738 → 2845 Copper Axe). +const COL_EFFECT_ITEM_TYPE_1: usize = 103; +// `EffectApplyAuraName[0]` reuses [`COL_EFFECT_APPLY_AURA_1`] (91) and `EffectTriggerSpell[0]` +// reuses [`COL_EFFECT_TRIGGER_1`] (109) — both already pinned above for the shapeshift/learn-spell +// hops; the tooltip arc exposes the full `[3]` array off the same columns instead of re-deriving. + +/// `AttributesEx3` bit `0x8000` — damage renders melee-white (`SPELL_ATTR3_NORMAL_RANGED_ATTACK`; +/// the combat-text emitter's `B`-bit flip, wow-re `combattext-color-law.md`). +const ATTR_EX3_NORMAL_RANGED_ATTACK: u32 = 0x8000; +/// `Attributes` bit `0x2` — the "uses the ranged slot" attribute (mangos `SPELL_ATTR_RANGED`). +/// One half of the client's ranged-stance gate (module docs). +const ATTR_RANGED: u32 = 0x2; +/// `AttributesEx2` bit `0x20` — the auto-repeat attribute (mangos `SPELL_ATTR_EX2_AUTO_REPEAT`); +/// the other half of the gate. Auto Shot and wand Shoot carry it. +const ATTR_EX2_AUTO_REPEAT: u32 = 0x20; +/// `AttributesEx2` bit `0x20000` — `SPELL_ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS` (vmangos +/// `SpellDefines.h`). Its absence on a ranged spell opts the cast into the ranged-speed cooldown +/// pad ([`SpellDisplay::ranged_speed_cooldown`]). +const ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS: u32 = 0x20000; +/// `Attributes` bit `0x40` — `SPELL_ATTR_PASSIVE` (vmangos `SpellDefines.h`'s `SpellAttributes` +/// enum, `0x00000040 // 6`): the spellbook's own passive gate ([`SpellDisplay::passive`]). The +/// ref's `SpellButton_UpdateButton` reads `IsSpellPassive` to gray the name/highlight art +/// (`SpellBookFrame.lua:379-390`) — a passive is real player state (a permanent weapon-skill/ +/// stance-derived buff), never something the player casts, so the engine's `CastSpell` binding +/// (decision 0216 §8, `benilla-ui/src/script/spellbook.rs`) refuses it outright rather than +/// sending a doomed cast the server would just reject. +const ATTR_PASSIVE: u32 = 0x40; +/// `Attributes` bit `0x80` — `SPELL_ATTR_DO_NOT_DISPLAY` (cmangos `SpellDefines.h`: "Hidden in +/// Spellbook, Aura Icon, Combat Log"): THE spellbook add-gate (decision 0227) AND the `Attributes` +/// half of the aura-bar display filter ([`SpellDisplay::hidden_from_aura_bar`] — the cache +/// builder's byte-width read at `0x4e42b6` tests exactly this bit). Every language, armor/weapon +/// proficiency, hidden racial passive, and internal proc aura (Defensive State 5302) carries it. +const ATTR_DO_NOT_DISPLAY: u32 = 0x80; +/// `Attributes` bit `0x20` — `SPELL_ATTR_IS_TRADESKILL` (cmangos `SpellDefines.h`): profession / +/// recipe spells the client diverts to a name-only path, never a book slot (decision 0227). See +/// [`SpellDisplay::in_spellbook`]. +const ATTR_IS_TRADESKILL: u32 = 0x20; +/// `AttributesEx` bit `0x10000000` — vmangos `SPELL_ATTR_EX_NO_AURA_ICON` ("Client doesn't display +/// these spells in aura bar"): the other display-filter bit. All three warrior stances carry it +/// (extracted 5875 `Spell.dbc`: 2457 `AttributesEx 0x90000000`, 71/2458 `0x10000000`). +const ATTR_EX_NO_AURA_ICON: u32 = 0x1000_0000; + +/// `SpellEffects` value `78` — `SPELL_EFFECT_ATTACK` (cmangos `SpellEffectDefines.h`): the melee +/// auto-attack effect. The only spell carrying it in 1.12 is 6603 "Attack", so the client's +/// effect-type trigger and a hardcoded-6603 trigger are behaviourally identical — but the client +/// checks the effect (decision 0231), so [`SpellDisplay::is_melee_auto_attack`] does too. +const SPELL_EFFECT_ATTACK: u32 = 78; + +/// The three **tracking** aura types (`EffectApplyAuraName` values, vmangos `SpellAuraDefines.h`): +/// `SPELL_AURA_TRACK_CREATURES` 44, `SPELL_AURA_TRACK_RESOURCES` 45, `SPELL_AURA_TRACK_STEALTHED` +/// 151 — byte-verified as the client's own set `{0x2c,0x2d,0x97}`, tested against the three +/// `EffectApplyAuraName` dwords at `SpellRec+0x16c..+0x174` in BOTH aura display filters: the +/// player-cache rebuild's Pass 2 (`0x4e42xx`, which also records the matching spell for +/// `GetTrackingTexture`) and the shared `IsAuraDisplayable 0x519860` (`0x5198c2`–`0x5198d3`) +/// the `UnitBuff`/`UnitDebuff` walks call (wow-re `ui/scratch/aura-display-pipeline.md` §3/§9a). +/// See [`SpellDisplay::tracking_aura`]. +const TRACKING_AURA_TYPES: [u32; 3] = [44, 45, 151]; + +/// `Attributes` bits `0x4` + `0x400` — the two ON_NEXT_SWING attributes (vmangos +/// `SPELL_ATTR_ON_NEXT_SWING_NO_DAMAGE` / `SPELL_ATTR_ON_NEXT_SWING`). The client always tests +/// them as ONE mask (`SpellRec+0x18 & 0x404` — the already-casting exemption `6e4d97`, the +/// self-cast range short-circuit `0x6e34fb`, the attribute predicate `0x6e5200`; wow-re +/// `wave-cast.md`): a spell of this class doesn't cast — it queues on the server's melee slot +/// and fires on the caster's next swing (Heroic Strike 78 carries `0x4`, Raptor Strike 2973 +/// `0x404`, Cleave 845 `0x4`). See [`SpellDisplay::on_next_swing`]. +const ATTR_ON_NEXT_SWING: u32 = 0x404; +/// `AttributesEx` bit `0x200` — vmangos `SPELL_ATTR_EX_INITIATES_COMBAT` ("Enables Auto-Attack"). +/// The server only reads it for pet AI (vmangos `Spell.cpp:4377`); the player-facing "casting +/// this starts my auto-attack" is client-side (one leg of predicate `0x6e5200`, read by +/// `TryCast`'s post-send tail `6e51b5` → the attack entry `0x6131a0`; byte-verified §5, wow-re +/// `combat-feel-law.md` @ c445713b). Rend/Sunder Armor/Slam/Sinister Strike carry it; Heroic +/// Strike and Charge do not. +const ATTR_EX_INITIATES_COMBAT: u32 = 0x200; +/// `AttributesEx2` bit `0x100000` — vmangos `SPELL_ATTR_EX2_INITIATE_COMBAT_POST_CAST` ("Client +/// will send CMSG_ATTACK_SWING after SMSG_SPELL_GO"). The §5-verified send-tail predicate +/// EXCLUDES it (`[ebp-2] = 0x6e5200 && Ex2-bit20 CLEAR`): a bit20 spell defers its attack-start +/// to the `SMSG_SPELL_GO` handler (`0x6e83c0`) instead of starting at send. No spell benilla +/// meets carries it (asserted against the real 5875 `Spell.dbc` in `catalog_tests.rs`), so the +/// deferred path stays unbuilt — this bit only *suppresses* the send-time start. +const ATTR_EX2_INITIATE_COMBAT_POST_CAST: u32 = 0x0010_0000; + +/// `Attributes` bit 25 (`0x0200_0000`) — `SPELL_ATTR_COOLDOWN_ON_EVENT` (vmangos; "disabled while +/// active"). The client's cooldown machinery stores such a spell's record **on hold** — timers +/// parked until `SMSG_COOLDOWN_EVENT` starts them (the `bl = (SpellRec+0x18 >> 0x19) & 1` read in +/// the `SMSG_SPELL_COOLDOWN` handler `0x6e9460`, wow-re `wave-handlers.md`, VERIFIED). Stealth, +/// Shield Wall — the "cooldown begins when the effect ends" family. +const ATTR_COOLDOWN_ON_EVENT: u32 = 0x0200_0000; + +/// The usable-walk attribute gates (§2a legs 1/6/7/8; names = vmangos `SpellDefines.h`, +/// bit positions = the byte-verified reads in `IsSpellUsableNow 0x6e3d60`): +/// bit 23 — castable while dead (leg 1's waiver); +/// bit 17 — only while stealthed, tested against `UNIT_FIELD_BYTES_1` byte 3's CREEP flag +/// (leg 7's `[+0x110]+0x213 & 2`; vmangos `UNIT_VIS_FLAGS_CREEP`, set by the stealth aura); +/// bit 16 — not while shapeshifted (a form-gate input, [`SpellDisplay::usable_in_form`]); +/// bit 28 — only out of combat, tested against `UNIT_FLAG_IN_COMBAT` (leg 8's unit-flag b19). +pub const ATTR_CASTABLE_WHILE_DEAD: u32 = 0x0080_0000; +pub const ATTR_ONLY_STEALTHED: u32 = 0x0002_0000; +const ATTR_NOT_SHAPESHIFT: u32 = 0x0001_0000; +pub const ATTR_NOT_IN_COMBAT: u32 = 0x1000_0000; +/// `AttributesEx2` bit 19 — the form requirement is waived while unshifted (vmangos +/// `SPELL_ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED`; a form-gate input). +const ATTR_EX2_ALLOW_WHILE_NOT_SHAPESHIFTED: u32 = 0x0008_0000; +/// `SpellEffects` value `47` — `SPELL_EFFECT_TRADE_SKILL`: the usable walk's early-out +/// (`0x6e3d99`, `Effect[0]==0x2f` ⇒ usable, skipping every gate). Also the crafting book's open +/// key (0437): `Spell_C::TryCast 0x6e4b60` branches on `Effect[0]==0x2f` and opens the window +/// client-side, never sending the cast (wow-re `wave-cast.md`, VERIFIED). +pub const SPELL_EFFECT_TRADE_SKILL: u32 = 47; +/// `SpellEffects` value `24` — `SPELL_EFFECT_CREATE_ITEM` (vmangos `SharedDefines.h`): a recipe's +/// product effect; its `EffectItemType` slot is the created item entry (0437). +pub const SPELL_EFFECT_CREATE_ITEM: u32 = 24; +/// `SpellEffects` value `95` — `SPELL_EFFECT_SKINNING` (vmangos `SharedDefines.h`, +/// `Spell::EffectSkinning`): the corpse-gathering cast the Skin cursor's right-click resolves +/// through (0437's gathering finish). +pub const SPELL_EFFECT_SKINNING: u32 = 95; +/// `SpellEffects` values `53`/`54` — `SPELL_EFFECT_ENCHANT_ITEM` (permanent) / +/// `SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY` (vmangos `SharedDefines.h`): the item-targeted craft +/// casts the CraftFrame sends with `TARGET_FLAG_ITEM` (0437 phase 3). +pub const SPELL_EFFECT_ENCHANT_ITEM: u32 = 53; +pub const SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY: u32 = 54; + +/// `Spell.dbc` × `SpellIcon.dbc`, joined: spell id → name + icon path, plus the **learn-spell map** +/// (a `SPELL_EFFECT_LEARN_SPELL` spell → the spell it teaches). Vanilla trainers offer a *learn* +/// spell — a thin wrapper whose only effect is "teach spell X" — not the ability itself; the ability +/// (with its skill line, its real icon) is the taught spell. [`Self::learned_spell`] follows that hop +/// so callers can resolve the ability from the trainer's wire id (decision 0247's `taughtSpell`). +pub struct SpellCatalog { + spells: HashMap, + learned_spell: HashMap, +} + +impl SpellCatalog { + /// Build a catalog from an explicit display map — for tests and synthetic fixtures. The live + /// path is [`load_spell_catalog`]. Carries no learn-spell map (synthetic ids teach nothing). + pub fn from_displays(spells: HashMap) -> Self { + Self { + spells, + learned_spell: HashMap::new(), + } + } + + pub fn get(&self, id: u32) -> Option<&SpellDisplay> { + self.spells.get(&id) + } + + /// Every loaded spell `(id, display)`, unordered — the corpus instruments' walk + /// (`benilla-extract partcensus` attributes effect models back to the spells that play them). + pub fn iter(&self) -> impl Iterator + '_ { + self.spells.iter().map(|(id, s)| (*id, s)) + } + + /// The spell a `SPELL_EFFECT_LEARN_SPELL` spell teaches (`None` if `id` teaches nothing — i.e. it + /// is a plain ability, not a learn wrapper). The trainer's wire id is a learn spell; the ability + /// (its skill line, its display) is what this resolves to (decision 0247's taught-spell hop). + pub fn learned_spell(&self, id: u32) -> Option { + self.learned_spell.get(&id).copied() + } + + pub fn len(&self) -> usize { + self.spells.len() + } + + pub fn is_empty(&self) -> bool { + self.spells.is_empty() + } +} + +/// Schema: 173 fields, all u32 except the ones we read as what they are (Speed/EffectMultipleValue +/// as f32; the name/name-subtext/description/aura-description block heads as strings; the rest of +/// each loc block stays u32 — unread). `EffectBasePoints`/`EffectDieSides` are genuinely signed but +/// read through [`i32_at`] regardless of the schema tag (a raw dword's bits don't change between +/// the `UInt32`/`Int32` variants — only which `_at` helper a caller reaches for), so they stay +/// untagged here like every other unread-as-a-type integer column. +fn spell_schema() -> Schema { + let mut s = Schema::new("Spell"); + for i in 0..SPELL_FIELDS { + if i == COL_NAME_ENUS { + s.add_field(SchemaField::new("NameEnUs", FieldType::String)); + } else if i == COL_NAME_SUBTEXT_ENUS { + s.add_field(SchemaField::new("NameSubtextEnUs", FieldType::String)); + } else if i == COL_DESCRIPTION_ENUS { + s.add_field(SchemaField::new("DescriptionEnUs", FieldType::String)); + } else if i == COL_AURA_DESCRIPTION_ENUS { + s.add_field(SchemaField::new("AuraDescriptionEnUs", FieldType::String)); + } else if i == COL_SPEED { + s.add_field(SchemaField::new("Speed", FieldType::Float32)); + } else if (COL_EFFECT_MULTIPLE_VALUE_1..COL_EFFECT_MULTIPLE_VALUE_1 + 3).contains(&i) { + s.add_field(SchemaField::new( + format!("EffectMultipleValue{}", i - COL_EFFECT_MULTIPLE_VALUE_1), + FieldType::Float32, + )); + } else { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + } + s +} + +/// Load the joined spell display catalog off the patch chain. +pub fn load_spell_catalog(chain: &mut Chain) -> Result { + let icons = crate::dbc::load_spell_icon_map(chain)?; + + let spell_bytes = chain.read_file(SPELL).context("reading Spell.dbc")?; + let spells_set = parse(&spell_bytes, spell_schema(), "Spell.dbc")?; + let mut spells: HashMap = HashMap::new(); + let mut learned_spell: HashMap = HashMap::new(); + for r in spells_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + // The learn-spell hop (decision 0247): a SPELL_EFFECT_LEARN_SPELL effect's EffectTriggerSpell + // is the ability this spell teaches — the trainer offers the learn wrapper, the taught spell + // carries the skill line + the real display. First LEARN effect wins. + for i in 0..3 { + if u32_at(r, COL_EFFECT_1 + i) == Some(SPELL_EFFECT_LEARN_SPELL) { + if let Some(taught) = u32_at(r, COL_EFFECT_TRIGGER_1 + i).filter(|&t| t != 0) { + learned_spell.entry(id).or_insert(taught); + break; + } + } + } + let name = str_at(&spells_set, r, COL_NAME_ENUS).unwrap_or_default(); + let rank = str_at(&spells_set, r, COL_NAME_SUBTEXT_ENUS); + let icon = u32_at(r, COL_ICON_ID) + .filter(|&i| i != 0) + .and_then(|i| icons.get(&i).cloned()); + let visual = u32_at(r, COL_VISUAL_ID).unwrap_or(0); + let speed = f32_at(r, COL_SPEED).unwrap_or(0.0); + let attributes = u32_at(r, COL_ATTRIBUTES).unwrap_or(0); + // The per-effect `[3]` arrays (module docs) — computed once so the shapeshift-form + // derivation below can reuse `effect_apply_aura` instead of re-reading the columns. + let effect_apply_aura: [u32; 3] = + std::array::from_fn(|i| u32_at(r, COL_EFFECT_APPLY_AURA_1 + i).unwrap_or(0)); + let effect_trigger_spell: [u32; 3] = + std::array::from_fn(|i| u32_at(r, COL_EFFECT_TRIGGER_1 + i).unwrap_or(0)); + spells.insert( + id, + SpellDisplay { + name, + rank, + icon, + visual, + speed, + attributes, + attributes_ex: u32_at(r, COL_ATTRIBUTES_EX).unwrap_or(0), + attributes_ex2: u32_at(r, COL_ATTRIBUTES_EX2).unwrap_or(0), + attributes_ex3: u32_at(r, COL_ATTRIBUTES_EX3).unwrap_or(0), + passive: attributes & ATTR_PASSIVE != 0, + cast_ui: u32_at(r, COL_CAST_UI).unwrap_or(0), + effect_1: u32_at(r, COL_EFFECT_1).unwrap_or(0), + // Scan the three effects for OPEN_LOCK and take that effect's LockType (its + // EffectMiscValue). Most openers carry it on Effect[0], but the scan is cheap. + open_lock_type: (0..3).find_map(|i| { + (u32_at(r, COL_EFFECT_1 + i)? == SPELL_EFFECT_OPEN_LOCK) + .then(|| u32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0)) + }), + dispel: u32_at(r, COL_DISPEL).unwrap_or(0), + category: u32_at(r, COL_CATEGORY).unwrap_or(0), + recovery_ms: u32_at(r, COL_RECOVERY_TIME).unwrap_or(0), + interrupt_flags: u32_at(r, COL_INTERRUPT_FLAGS).unwrap_or(0), + channel_interrupt_flags: u32_at(r, COL_CHANNEL_INTERRUPT_FLAGS).unwrap_or(0), + category_recovery_ms: u32_at(r, COL_CATEGORY_RECOVERY_TIME).unwrap_or(0), + start_recovery_category: u32_at(r, COL_START_RECOVERY_CATEGORY).unwrap_or(0), + start_recovery_ms: u32_at(r, COL_START_RECOVERY_TIME).unwrap_or(0), + power_type: u32_at(r, COL_POWER_TYPE).unwrap_or(0), + mana_cost: u32_at(r, COL_MANA_COST).unwrap_or(0), + mana_cost_pct: u32_at(r, COL_MANA_COST_PCT).unwrap_or(0), + range_index: u32_at(r, COL_RANGE_INDEX).unwrap_or(0), + targets: u32_at(r, COL_TARGETS).unwrap_or(0), + implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0), + stances: u32_at(r, COL_STANCES).unwrap_or(0), + stances_not: u32_at(r, COL_STANCES_NOT).unwrap_or(0), + caster_aura_state: u32_at(r, COL_CASTER_AURA_STATE).unwrap_or(0), + target_aura_state: u32_at(r, COL_TARGET_AURA_STATE).unwrap_or(0), + totems: std::array::from_fn(|i| u32_at(r, COL_TOTEM_1 + i).unwrap_or(0)), + reagents: std::array::from_fn(|i| { + ( + u32_at(r, COL_REAGENT_1 + i).unwrap_or(0), + u32_at(r, COL_REAGENT_COUNT_1 + i).unwrap_or(0), + ) + }), + equipped_item_class: u32_at(r, COL_EQUIPPED_ITEM_CLASS).unwrap_or(0) as i32, + equipped_item_subclass_mask: u32_at(r, COL_EQUIPPED_ITEM_SUBCLASS_MASK) + .unwrap_or(0), + requires_spell_focus: u32_at(r, COL_REQUIRES_SPELL_FOCUS).unwrap_or(0), + // The stance-bar keys (wow-re shapeshift-bar-api.md): the first MOD_SHAPESHIFT + // effect's MiscValue is the form id; order/active-icon read raw. + shapeshift_form: (0..3).find_map(|i| { + (effect_apply_aura[i] == SPELL_AURA_MOD_SHAPESHIFT) + .then(|| u32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0)) + }), + stance_bar_order: u32_at(r, COL_STANCE_BAR_ORDER).unwrap_or(0) as i32, + active_icon: u32_at(r, COL_ACTIVE_ICON_ID) + .filter(|&i| i != 0) + .and_then(|i| icons.get(&i).cloned()), + description: str_at(&spells_set, r, COL_DESCRIPTION_ENUS), + aura_description: str_at(&spells_set, r, COL_AURA_DESCRIPTION_ENUS), + duration_index: u32_at(r, COL_DURATION_INDEX).unwrap_or(0), + casting_time_index: u32_at(r, COL_CASTING_TIME_INDEX).unwrap_or(0), + proc_chance: u32_at(r, COL_PROC_CHANCE).unwrap_or(0), + effect_base_points: std::array::from_fn(|i| { + i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0) + }), + effect_die_sides: std::array::from_fn(|i| { + i32_at(r, COL_EFFECT_DIE_SIDES_1 + i).unwrap_or(0) + }), + effect_base_dice: std::array::from_fn(|i| { + i32_at(r, COL_EFFECT_BASE_DICE_1 + i).unwrap_or(0) + }), + effect_amplitude: std::array::from_fn(|i| { + u32_at(r, COL_EFFECT_AMPLITUDE_1 + i).unwrap_or(0) + }), + effect_apply_aura, + effect_radius_index: std::array::from_fn(|i| { + u32_at(r, COL_EFFECT_RADIUS_INDEX_1 + i).unwrap_or(0) + }), + effect_chain_targets: std::array::from_fn(|i| { + u32_at(r, COL_EFFECT_CHAIN_TARGETS_1 + i).unwrap_or(0) + }), + effect_multiple_value: std::array::from_fn(|i| { + f32_at(r, COL_EFFECT_MULTIPLE_VALUE_1 + i).unwrap_or(0.0) + }), + effect_trigger_spell, + effect_item_type: std::array::from_fn(|i| { + u32_at(r, COL_EFFECT_ITEM_TYPE_1 + i).unwrap_or(0) + }), + effect_misc_value: std::array::from_fn(|i| { + i32_at(r, COL_EFFECT_MISC_1 + i).unwrap_or(0) + }), + }, + ); + } + Ok(SpellCatalog { + spells, + learned_spell, + }) +} + +#[cfg(test)] +#[path = "catalog_tests.rs"] +mod tests; diff --git a/crates/benilla-formats/src/spells/radius.rs b/crates/benilla-formats/src/spells/radius.rs new file mode 100644 index 00000000..62237df2 --- /dev/null +++ b/crates/benilla-formats/src/spells/radius.rs @@ -0,0 +1,88 @@ +//! `SpellRadius.dbc` — the `$a` token's yard source (`EffectRadiusIndex` → +//! [`crate::spells::SpellDisplay::effect_radius_index`]; the 0276 verdict pins the token's read +//! as `[+0x160]` → SpellRadius.dbc). Pinned on the extracted 5875 file: 24 records × 4 fields +//! `{id, radius f32, radiusPerLevel f32, radiusMax f32}` — row 13 = 10.0 yd (the classic +//! Arcane Explosion radius), row 8 = 5.0, row 10 = 30.0. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; + +/// One `SpellRadius.dbc` row (yards; per-level scaling is 0 on every 5875 row). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpellRadius { + pub radius: f32, + pub per_level: f32, + pub max: f32, +} + +/// `SpellRadius.dbc`, by row id. +#[derive(Default)] +pub struct SpellRadiusCatalog { + rows: HashMap, +} + +impl SpellRadiusCatalog { + pub fn get(&self, index: u32) -> Option<&SpellRadius> { + self.rows.get(&index) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// Load `SpellRadius.dbc` off the patch chain. +pub fn load_spell_radii(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\SpellRadius.dbc") + .context("reading SpellRadius.dbc")?; + let mut schema = Schema::new("SpellRadius"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("Radius", FieldType::Float32)); + schema.add_field(SchemaField::new("RadiusPerLevel", FieldType::Float32)); + schema.add_field(SchemaField::new("RadiusMax", FieldType::Float32)); + let set = parse(&bytes, schema, "SpellRadius.dbc")?; + let mut rows = HashMap::new(); + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + rows.insert( + id, + SpellRadius { + radius: f32_at(r, 1).unwrap_or(0.0), + per_level: f32_at(r, 2).unwrap_or(0.0), + max: f32_at(r, 3).unwrap_or(0.0), + }, + ); + } + Ok(SpellRadiusCatalog { rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `SpellRadius.dbc` on the real data — the module doc's own probe rows. Skips without + /// client data. + #[test] + fn real_spell_radii_read_the_probed_rows() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let radii = load_spell_radii(&mut chain).expect("load SpellRadius"); + assert_eq!(radii.get(13).map(|r| r.radius), Some(10.0)); + assert_eq!(radii.get(8).map(|r| r.radius), Some(5.0)); + assert_eq!(radii.get(10).map(|r| r.radius), Some(30.0)); + } +} diff --git a/crates/benilla-formats/src/spells/ranges.rs b/crates/benilla-formats/src/spells/ranges.rs new file mode 100644 index 00000000..1889bc5a --- /dev/null +++ b/crates/benilla-formats/src/spells/ranges.rs @@ -0,0 +1,120 @@ +//! `SpellRange.dbc` — the `GetMinMaxRange 0x6e3480` inputs a spell's `rangeIndex` column +//! ([`crate::spells::SpellDisplay::range_index`]) resolves against: a min/max yard pair per row, +//! plus a melee-family flag whose branch substitutes the combat-reach sum for the authored pair. + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; + +/// One `SpellRange.dbc` row — the `GetMinMaxRange 0x6e3480` inputs (wow-re `wave-cooldown.md`, +/// VERIFIED: min f32 `+0x4`, max f32 `+0x8`, flags `+0xc` with **bit 0 = melee**, whose branch +/// substitutes the combat-reach sum floored at 5.0 for the authored pair). Pinned on the extracted +/// 5875 file (28 records × 22 fields): row 2 = {0, 5, flags 1} (melee), 114 = {8, 35} (Auto Shot), +/// 95 = {8, 25} (Charge), 35 = {0, 35} (Fireball). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpellRange { + pub min: f32, + pub max: f32, + /// Bit 0: the melee range family (combat-reach based, not the authored min/max). + pub flags: u32, +} + +impl SpellRange { + /// The melee-range family (flags bit 0) — `GetMinMaxRange`'s reach-sum branch. + pub fn is_melee(&self) -> bool { + self.flags & 1 != 0 + } +} + +/// `SpellRange.dbc`, by row id ([`SpellDisplay::range_index`]). +#[derive(Default)] +pub struct SpellRangeCatalog { + ranges: HashMap, +} + +impl SpellRangeCatalog { + pub fn get(&self, index: u32) -> Option<&SpellRange> { + self.ranges.get(&index) + } + + pub fn len(&self) -> usize { + self.ranges.len() + } + + pub fn is_empty(&self) -> bool { + self.ranges.is_empty() + } +} + +const SPELL_RANGE: &str = "DBFilesClient\\SpellRange.dbc"; +const SPELL_RANGE_FIELDS: usize = 22; + +/// Load `SpellRange.dbc` off the patch chain ([`SpellRange`]'s row law). +pub fn load_spell_ranges(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(SPELL_RANGE) + .context("reading SpellRange.dbc")?; + let mut schema = Schema::new("SpellRange"); + for i in 0..SPELL_RANGE_FIELDS { + match i { + 1 => schema.add_field(SchemaField::new("MinRange", FieldType::Float32)), + 2 => schema.add_field(SchemaField::new("MaxRange", FieldType::Float32)), + _ => schema.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)), + } + } + let set = parse(&bytes, schema, "SpellRange.dbc")?; + let mut ranges = HashMap::new(); + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + ranges.insert( + id, + SpellRange { + min: f32_at(r, 1).unwrap_or(0.0), + max: f32_at(r, 2).unwrap_or(0.0), + flags: u32_at(r, 3).unwrap_or(0), + }, + ); + } + Ok(SpellRangeCatalog { ranges }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `SpellRange.dbc` on the real data — the byte law's own probe rows (`GetMinMaxRange + /// 0x6e3480`): row 2 is the melee family (flags bit 0), 114 = Auto Shot's 8–35, 95 = + /// Charge's 8–25. Skips without client data. + #[test] + fn real_spell_ranges_read_the_byte_laws_rows() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let ranges = load_spell_ranges(&mut chain).expect("load SpellRange"); + + let melee = ranges.get(2).expect("row 2"); + assert_eq!((melee.min, melee.max), (0.0, 5.0)); + assert!(melee.is_melee(), "row 2 carries the melee flag"); + + let auto_shot = ranges.get(114).expect("row 114"); + assert_eq!((auto_shot.min, auto_shot.max), (8.0, 35.0)); + assert!(!auto_shot.is_melee()); + + let charge = ranges.get(95).expect("row 95"); + assert_eq!((charge.min, charge.max), (8.0, 25.0)); + + // A min-0 nuke row reads a true 0.0 min (row 4: Shadow Bolt, Frostbolt, wand Shoot) — + // the fcomp-vs-0.0 guard's input, so the min/max field mapping can't silently shift + // (0426: a manufactured min range refused point-blank casts). + let nuke = ranges.get(4).expect("row 4"); + assert_eq!((nuke.min, nuke.max), (0.0, 30.0)); + assert!(!nuke.is_melee()); + } +} diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs new file mode 100644 index 00000000..13e6c776 --- /dev/null +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -0,0 +1,399 @@ +//! The spell-description **$-token engine** (decision 0274 P2) — the substitution the real +//! client runs over `Spell.dbc` Description/AuraDescription text (and item trigger lines), with +//! the value formulas byte-verified by the 0276 fold-back (wow-re `tooltip-content-law.md`, +//! `0x5075f0 → 0x507710`, the effect-value core `0x6e3800`): +//! +//! - `$s` (and `$m`/`$M`): `MIN = BasePoints + BaseDice`, `MAX = BasePoints + DieSides·BaseDice` +//! — the general n-dice rule (the common `BaseDice = 1` case reduces to `base+1 … base+dieSides`). +//! `$s` prints one value when `MIN == MAX`, else `"MIN to MAX"`. The byte law also carries a +//! per-level term (`DicePerLevel·max(0, casterLevel − spellLevel)` inside both bounds, plus an +//! uncaptured `RealPointsPerLevel` float term); those columns aren't parsed yet — INTERIM the +//! flat value, which is exact for the overwhelming majority of 1.12 rows (per-level dice are +//! rare). Values print sign-absolute: the client shows "causes 12 damage", not "-12". +//! - `$o`: the over-time total `perTick · duration / period` (period = `EffectAmplitude`, +//! defaulting 5000 ms when 0 — the byte default). +//! - `$d`: the duration via `SpellDuration.dbc` — "until cancelled" when permanent; whole +//! seconds/minutes/hours text (INTERIM shape pending the `0x52fa50` formatter's pin). +//! - `$t` period seconds · `$a` radius yards (`SpellRadius.dbc`) · `$h` proc chance · `$x` chain +//! targets · `$e` the multiple-value float · `$r` range yards · `$u` stack/charge count +//! (unparsed — leaves the token in place, a visible fold-back flag). +//! - Cross-spell refs `$` (e.g. `$1234s1`) resolve through the caller's lookup. +//! - `$/N;`/`$*N;` divide/multiply the following token's value by N. +//! - `$l:;` picks by the last substituted numeric value; `$g:;` renders +//! the first (male) form until a caster-gender input exists. +//! +//! Unknown tokens pass through untouched (visible, greppable) rather than vanishing. + +use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog}; + +/// The inputs one substitution runs over. `lookup` resolves cross-spell references (`$1234s1`). +pub struct TokenContext<'a> { + pub durations: &'a SpellDurationCatalog, + pub radii: &'a SpellRadiusCatalog, + pub lookup: &'a dyn Fn(u32) -> Option<&'a SpellDisplay>, + /// The player's home-bind AREA name ("Goldshire") — the `$z` token (the hearthstone text + /// "Returns you to $z."). Fed from `SMSG_BINDPOINTUPDATE`'s areaId through AreaTable.dbc; + /// `None` (no bind seen yet) leaves the token raw, like any unresolved token. + pub home_area: Option<&'a str>, +} + +/// The byte-verified effect bounds (`0x6e3800`, flat term): `(min, max)`. +fn effect_bounds(d: &SpellDisplay, slot: usize) -> (i64, i64) { + let base = i64::from(*d.effect_base_points.get(slot).unwrap_or(&0)); + let dice = i64::from(*d.effect_base_dice.get(slot).unwrap_or(&0)); + let sides = i64::from(*d.effect_die_sides.get(slot).unwrap_or(&0)); + (base + dice, base + sides * dice) +} + +/// A spell's duration in ms (flat term; -1 = permanent, None = no duration row). +fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> Option { + let row = ctx.durations.get(d.duration_index)?; + Some(i64::from(row.base_ms)) +} + +/// Whole-unit duration text — INTERIM shape (the `0x52fa50` formatter's exact rendering is +/// unpinned): "N sec" / "N min" / "N hours", "until cancelled" for the permanent sentinel. +fn duration_text(ms: i64) -> String { + if ms < 0 { + return "until cancelled".into(); + } + let secs = ms / 1000; + if secs < 60 { + format!("{secs} sec") + } else if secs < 3600 { + format!("{} min", secs / 60) + } else { + format!("{} hours", secs / 3600) + } +} + +/// Trim a float to the client's terse style (no trailing zeros: 2.5 → "2.5", 3.0 → "3"). +fn trim_float(v: f64) -> String { + if (v - v.round()).abs() < 1e-9 { + format!("{}", v.round() as i64) + } else { + format!("{v:.1}") + } +} + +/// One token's substituted text + the numeric value the `$l` plural picker keys on. +fn token_value( + letter: char, + slot: usize, + d: &SpellDisplay, + ctx: &TokenContext, + scale: f64, +) -> Option<(String, f64)> { + let scaled = |v: i64| -> i64 { + if scale == 1.0 { + v + } else { + (v as f64 * scale).round() as i64 + } + }; + match letter.to_ascii_lowercase() { + 's' => { + let (min, max) = effect_bounds(d, slot); + let (min, max) = (scaled(min.abs()), scaled(max.abs())); + Some(if min == max { + (min.to_string(), min as f64) + } else { + (format!("{min} to {max}"), max as f64) + }) + } + 'm' if letter == 'm' => { + let (min, _) = effect_bounds(d, slot); + let v = scaled(min.abs()); + Some((v.to_string(), v as f64)) + } + 'm' => { + // 'M' + let (_, max) = effect_bounds(d, slot); + let v = scaled(max.abs()); + Some((v.to_string(), v as f64)) + } + 'o' => { + let (min, max) = effect_bounds(d, slot); + let period = i64::from(*d.effect_amplitude.get(slot).unwrap_or(&0)).max(0); + let period = if period == 0 { 5000 } else { period }; + let dur = duration_ms(d, ctx).unwrap_or(0).max(0); + let total = |v: i64| scaled((v.abs() * dur / period).max(0)); + let (tmin, tmax) = (total(min), total(max)); + Some(if tmin == tmax { + (tmin.to_string(), tmin as f64) + } else { + (format!("{tmin} to {tmax}"), tmax as f64) + }) + } + 'd' => { + let ms = duration_ms(d, ctx)?; + let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 }; + Some((duration_text(ms), v)) + } + 't' => { + let period = i64::from(*d.effect_amplitude.get(slot).unwrap_or(&0)); + let period = if period == 0 { 5000 } else { period }; + let v = period as f64 / 1000.0; + Some((trim_float(v), v)) + } + 'a' => { + let idx = *d.effect_radius_index.get(slot).unwrap_or(&0); + let r = ctx.radii.get(idx)?; + Some((trim_float(f64::from(r.radius)), f64::from(r.radius))) + } + 'h' => Some((d.proc_chance.to_string(), f64::from(d.proc_chance))), + 'x' => { + let v = *d.effect_chain_targets.get(slot).unwrap_or(&0); + Some((v.to_string(), f64::from(v))) + } + 'e' => { + let v = f64::from(*d.effect_multiple_value.get(slot).unwrap_or(&0.0)); + Some((trim_float(v), v)) + } + 'z' => { + // Player state, not spell data: the home-bind area name (see TokenContext). + let name = ctx.home_area?; + Some((name.to_string(), 0.0)) + } + 'r' => { + // The range's max yards — resolved by the caller's range catalog at view-build time + // would be cleaner, but the token is rare in descriptions; the display carries the + // index only, so leave unresolved here (pass through). + None + } + _ => None, + } +} + +/// Substitute every `$`-token in `text` against `spell` (byte-verified formulas, module doc). +pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> String { + let bytes = text.as_bytes(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + let mut last_value: f64 = 0.0; + while i < bytes.len() { + if bytes[i] != b'$' { + let ch_len = utf8_len(bytes[i]); + out.push_str(&text[i..i + ch_len]); + i += ch_len; + continue; + } + let start = i; + i += 1; + // $/N; or $*N; — scale the next token. + let mut scale = 1.0f64; + if i < bytes.len() && (bytes[i] == b'/' || bytes[i] == b'*') { + let op = bytes[i]; + let mut j = i + 1; + let num_start = j; + while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') { + j += 1; + } + if let Ok(n) = text[num_start..j].parse::() { + if j < bytes.len() && bytes[j] == b';' { + j += 1; + } + scale = if op == b'/' { 1.0 / n } else { n }; + i = j; + } + } + // Optional cross-spell id digits. + let id_start = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + let ref_spell: Option = if i > id_start { + text[id_start..i].parse().ok() + } else { + None + }; + // The plural / gender selectors. + if i < bytes.len() + && (bytes[i] == b'l' || bytes[i] == b'L' || bytes[i] == b'g' || bytes[i] == b'G') + { + let selector = bytes[i].to_ascii_lowercase(); + if let Some(end) = text[i + 1..].find(';') { + let body = &text[i + 1..i + 1 + end]; + if let Some((a, b)) = body.split_once(':') { + let pick = match selector { + b'l' => { + if (last_value - 1.0).abs() < 1e-9 { + a + } else { + b + } + } + _ => a, // $g: the male form until a caster-gender input exists + }; + out.push_str(pick); + i = i + 1 + end + 1; + continue; + } + } + } + // The token letter + its optional 1-based slot digit. + let Some(&letter_b) = bytes.get(i) else { + out.push('$'); + continue; + }; + let letter = letter_b as char; + if !letter.is_ascii_alphabetic() { + out.push_str(&text[start..i + 1]); + i += 1; + continue; + } + i += 1; + let slot = if i < bytes.len() && bytes[i].is_ascii_digit() { + let s = (bytes[i] - b'1') as usize; + i += 1; + s.min(2) + } else { + 0 + }; + let target: &SpellDisplay = match ref_spell { + Some(id) => match (ctx.lookup)(id) { + Some(s) => s, + None => { + out.push_str(&text[start..i]); + continue; + } + }, + None => spell, + }; + match token_value(letter, slot, target, ctx, scale) { + Some((sub, val)) => { + last_value = val; + out.push_str(&sub); + } + None => out.push_str(&text[start..i]), // unknown token: keep raw (fold-back flag) + } + } + out +} + +fn utf8_len(b: u8) -> usize { + match b { + 0x00..=0x7F => 1, + 0xC0..=0xDF => 2, + 0xE0..=0xEF => 3, + _ => 4, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spells::SpellDisplay; + + fn ctx<'a>( + durations: &'a SpellDurationCatalog, + radii: &'a SpellRadiusCatalog, + lookup: &'a dyn Fn(u32) -> Option<&'a SpellDisplay>, + ) -> TokenContext<'a> { + TokenContext { + home_area: None, + durations, + radii, + lookup, + } + } + + fn none_lookup<'a>(_: u32) -> Option<&'a SpellDisplay> { + None + } + + /// The byte formula: base 13, dice 1, sides 9 → "14 to 22" (Fireball rank 1's shape); a + /// diceless effect prints one value. + #[test] + fn s_token_bounds() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mut d = SpellDisplay { + effect_base_points: [13, 24, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [9, 0, 0], + ..Default::default() + }; + let c = ctx(&durations, &radii, &none_lookup); + assert_eq!( + substitute("causes $s1 Fire damage and $s2 more", &d, &c), + "causes 14 to 22 Fire damage and 24 more" + ); + // Negative base points print absolute (the client's "reduces by N" phrasing). + d.effect_base_points = [-31, 0, 0]; + d.effect_base_dice = [1, 0, 0]; + d.effect_die_sides = [1, 0, 0]; + assert_eq!( + substitute("reduces armor by $s1", &d, &c), + "reduces armor by 30" + ); + } + + /// $o totals per-tick over the duration; $d prints the duration; $t the period; the $/N + /// scale divides; $l picks plural by the last value. + #[test] + fn overtime_duration_period_scale_plural() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_for_tests(1, 18_000); + let radii = SpellRadiusCatalog::default(); + let d = SpellDisplay { + duration_index: 1, + effect_base_points: [2, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + effect_amplitude: [3000, 0, 0], + ..Default::default() + }; + let c = ctx(&durations, &radii, &none_lookup); + assert_eq!( + substitute("Deals $o1 damage over $d, every $t1 sec.", &d, &c), + "Deals 18 damage over 18 sec, every 3 sec." + ); + assert_eq!( + substitute("Restores $/2;s1 health: $l point:points;.", &d, &c), + // 3/2 rounds to 2 (min==max==3 scaled by 0.5 → 2, rounded); plural picks "points" + "Restores 2 health: points.".to_string() + ); + } + + /// Cross-spell refs resolve through the lookup; unknown tokens pass through visibly. + #[test] + fn cross_spell_and_unknown_tokens() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let other = SpellDisplay { + effect_base_points: [99, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + ..Default::default() + }; + let lookup = |id: u32| -> Option<&SpellDisplay> { (id == 1234).then_some(&other) }; + let d = SpellDisplay::default(); + let c = TokenContext { + home_area: Some("Goldshire"), + durations: &durations, + radii: &radii, + lookup: &lookup, + }; + assert_eq!( + substitute("as strong as $1234s1 hits", &d, &c), + "as strong as 100 hits" + ); + assert_eq!(substitute("stacks $u times", &d, &c), "stacks $u times"); + // $z — the home-bind area (the hearthstone's "Returns you to $z."); raw when unfed. + assert_eq!( + substitute("Returns you to $z.", &d, &c), + "Returns you to Goldshire." + ); + let unbound = TokenContext { + home_area: None, + durations: &durations, + radii: &radii, + lookup: &lookup, + }; + assert_eq!( + substitute("Returns you to $z.", &d, &unbound), + "Returns you to $z." + ); + } +} diff --git a/crates/benilla-formats/src/stationery.rs b/crates/benilla-formats/src/stationery.rs new file mode 100644 index 00000000..3b496f7e --- /dev/null +++ b/crates/benilla-formats/src/stationery.rs @@ -0,0 +1,73 @@ +//! `Stationery.dbc` — the mail-window's letter-backdrop lookup (decision 0544). +//! +//! A mail carries a `stationery` id on the wire (`SMSG_MAIL_LIST_RESULT`); the reference client +//! resolves that id to a texture *basename* through this table and paints the open-letter backdrop +//! from `Interface\Stationery\1` (left half) + `2` (right half) — see +//! `MailFrame.lua`'s `OpenMail_Update` (`STATIONERY_PATH..texture.."1"/"2"`). +//! +//! The 5875 schema was read byte-level from the real `patch.MPQ` file (VERIFIED at decision time): +//! WDBC header `record_count = 5`, `field_count = 4`, `record_size = 16`, string block 62 bytes — +//! four 4-byte fields, the third a string ref. Only fields 0 (`ID`) and 2 (`Texture`) are consumed +//! here; fields 1 and 3 are unsigned columns whose *meaning* is unconfirmed (field 1 reads like a +//! linked item id, field 3 a small enum) and which the reader never needs — declared as filler so +//! the field indices align. The verified rows: `1/41 → STATIONERYTEST`, `61 → GMSTATIONERY`, +//! `62 → AUCTIONSTATIONERY`, `64 → STATIONERY_VAL` (both target BLPs exist in the archive; +//! MPQ path lookup is case-insensitive, so the uppercase DBC string resolves the mixed-case file). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{parse, str_at, u32_at}; + +const STATIONERY: &str = "DBFilesClient\\Stationery.dbc"; + +/// `MAIL_STATIONERY_DEFAULT` — vmangos stores every player mail with this id (the client's +/// stationery choice is discarded server-side, decision 0544). Its verified texture basename is the +/// [`StationeryCatalog::DEFAULT_TEXTURE`] fallback. +pub const STATIONERY_DEFAULT: u32 = 41; + +/// `Stationery.dbc`: stationery id → texture basename (the `Interface\Stationery\N` stem). +pub struct StationeryCatalog { + by_id: HashMap, +} + +impl StationeryCatalog { + /// The verified basename of the default stationery (id 41) — the fallback when a mail's id is + /// missing from the table or the DBC failed to load. VERIFIED against the real file's string + /// block (id 41's actual `Texture` value), NOT an assumed constant. + pub const DEFAULT_TEXTURE: &'static str = "STATIONERYTEST"; + + /// The texture basename for a stationery id, falling back to [`Self::DEFAULT_TEXTURE`] for any + /// id the table doesn't carry (unknown/AH/creature stationery still renders a valid backdrop). + pub fn texture(&self, id: u32) -> &str { + self.by_id + .get(&id) + .map(String::as_str) + .unwrap_or(Self::DEFAULT_TEXTURE) + } +} + +/// Load `Stationery.dbc` into an id → basename map (see the module doc for the verified schema). +pub fn load_stationery_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(STATIONERY) + .context("reading Stationery.dbc")?; + let mut schema = Schema::new("Stationery"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + // Field 1: an unsigned column (reads like a linked item id) — unconsumed filler. + schema.add_field(SchemaField::new("Item", FieldType::UInt32)); + schema.add_field(SchemaField::new("Texture", FieldType::String)); + // Field 3: a small unsigned enum — unconsumed filler. + schema.add_field(SchemaField::new("Flags", FieldType::UInt32)); + let set = parse(&bytes, schema, "Stationery.dbc")?; + let mut by_id = HashMap::new(); + for r in set.records() { + if let (Some(id), Some(tex)) = (u32_at(r, 0), str_at(&set, r, 2)) { + by_id.insert(id, tex); + } + } + Ok(StationeryCatalog { by_id }) +} diff --git a/crates/benilla-formats/src/talents.rs b/crates/benilla-formats/src/talents.rs new file mode 100644 index 00000000..2a349e42 --- /dev/null +++ b/crates/benilla-formats/src/talents.rs @@ -0,0 +1,329 @@ +//! `Talent.dbc` + `TalentTab.dbc` loader — the vanilla talent trees' data plane (decision 0304): +//! each class's three tabs, and each tab's talents with grid position, rank spells, and +//! prerequisites. The window ([`benilla`]'s ui_talent) renders these; the learn click sends +//! `CMSG_LEARN_TALENT` with a row id from here. +//! +//! Layout — VERIFIED against the **vmangos server source** (`DBCStructure.h`'s +//! `TalentEntry`/`TalentTabEntry` + `DBCfmt.h`'s format strings), the same standing as +//! [`crate::skill_lines`]: vmangos parses these tables off the same build-5875 client data. +//! +//! `Talent.dbc` — `TalentEntryfmt = "niiiiiiiixxxxixxixxxi"` (21 fields, 84 B/record): +//! `id`(0, indexed) · **`tabId` = column 1** (a `TalentTab.dbc` id) · **`row` = column 2** +//! (tier, 0-based) · **`col` = column 3** (0-based) · **`rankSpell[5]` = columns 4–8** (the +//! spell taught per rank; 0 = the talent has fewer ranks; columns 9–12 are the enum's unused +//! rank slots) · **`prereqTalent` = column 13** (14–15 unused prereq slots) · **`prereqRank` = +//! column 16** (0-based; 17–18 unused) · **flags = column 19** (bit 0 = the Lua API's +//! `isExceptional` — the client's `TalentRec+0x4c` bit0 read, wow-re `ui/scratch/talent-api.md`) +//! · **`requiredSpell` = column 20**. +//! +//! `TalentTab.dbc` — `TalentTabEntryfmt = "nxxxxxxxxxxxiix"` (15 fields, 60 B/record): +//! `id`(0, indexed) · the 8-locale `name_lang` block (1..8, enUS first ⇒ **NameEnUs = column +//! 1**) + its flags word (9) · `spellIcon`(10, unused by the 1.12 window) · **`raceMask` = +//! column 11** (511 = all races this build) · **`classMask` = column 12** · `orderIndex`(13 — +//! **never read by the client**: the shipped data ties the mage's Arcane/Fire at 0, raw-dump +//! verified) · **`backgroundFile` = column 14** (a string: the +//! `Interface\TalentFrame\-{TopLeft,TopRight,BottomLeft,BottomRight}` art base). +//! +//! **Order is the law here** (byte-verified — the 0305 fold-back, wow-re `talent-api.md`): +//! `GetTalentInfo(tab, index)` indexes the tab's talents by **native DBC row order** +//! (`TalentTabInfo[+0xC] + (index−1)*0x54`, a contiguous per-tab block — never a (tier, column) +//! re-sort), and a class's tabs come in **raw file order** among rows matching BOTH raceMask and +//! classMask (`0x4f2e50`). The shipped 1.12 data happens to author every tab's block in +//! (tier, column) order (27/27 tabs) — a coincidence the real-data test documents, not a rule +//! this reader imposes. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const TALENT: &str = "DBFilesClient\\Talent.dbc"; +const TALENT_TAB: &str = "DBFilesClient\\TalentTab.dbc"; + +const TALENT_FIELDS: usize = 21; +const COL_T_TAB: usize = 1; +const COL_T_ROW: usize = 2; +const COL_T_COL: usize = 3; +const COL_T_RANK0: usize = 4; // ..=8, five rank spells +const COL_T_PREREQ: usize = 13; +const COL_T_PREREQ_RANK: usize = 16; +const COL_T_FLAGS: usize = 19; +const COL_T_REQUIRED_SPELL: usize = 20; + +const TALENT_TAB_FIELDS: usize = 15; +const COL_TT_NAME_ENUS: usize = 1; +const COL_TT_RACE_MASK: usize = 11; +const COL_TT_CLASS_MASK: usize = 12; +const COL_TT_BACKGROUND: usize = 14; + +/// The vanilla rank ceiling (`MAX_TALENT_RANK`, vmangos `DBCStructure.h`). +pub const MAX_TALENT_RANK: usize = 5; + +/// One `Talent.dbc` row — a talent's grid seat, rank spells, and prerequisites. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Talent { + pub id: u32, + /// The owning `TalentTab.dbc` id. + pub tab: u32, + /// Tier, 0-based (the reference grid draws it 1-based). + pub row: u32, + /// Column, 0-based (grid width is 4). + pub col: u32, + /// The spell taught at each rank; a talent with `n` ranks fills exactly the first `n`. + pub ranks: [u32; MAX_TALENT_RANK], + /// Prerequisite talent id (0 = none) + the rank (0-based) it must be learned to. In the + /// shipped 1.12 data every prerequisite lives in the same tab (asserted by the real-data + /// test) — the reference frame only ever draws same-tab arrows. + pub prereq_talent: u32, + pub prereq_rank: u32, + /// A spell that must be known regardless of talents (0 = none). + pub required_spell: u32, + /// Flags bit 0 — the Lua API's `isExceptional` (an activatable-ability talent; the + /// client's tooltip builder branches on it). + pub exceptional: bool, +} + +impl Talent { + /// How many ranks this talent actually has (the count of populated rank spells). + pub fn max_rank(&self) -> u32 { + self.ranks.iter().take_while(|&&s| s != 0).count() as u32 + } +} + +/// One `TalentTab.dbc` row — a class page's identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TalentTabInfo { + pub id: u32, + pub name: String, + /// `raceMask`/`classMask` — bit `1 << (race/class − 1)`; the client admits a tab only when + /// BOTH match (511 = every race in the shipped data). + pub race_mask: u32, + pub class_mask: u32, + /// The `Interface\TalentFrame\-…` art base ("MageFire", …). + pub background: String, +} + +/// The joined talent data plane: tabs by class, talents by tab. +pub struct TalentCatalog { + tabs: Vec, + /// tab id → its talents in **native DBC row order** — the byte-verified enumeration + /// `GetTalentInfo` indexes by (module doc; never re-sorted). + by_tab: HashMap>, +} + +impl TalentCatalog { + /// A race+class's tabs (unit bytes, 1-based) in **raw file order** — the byte-verified tab + /// law (module doc: `0x4f2e50` filters on BOTH masks and never reads `orderIndex`; file + /// order alone reproduces the canonical Arcane/Fire/Frost page order). + pub fn tabs_for_class(&self, race: u8, class: u8) -> Vec<&TalentTabInfo> { + if !(1..=32).contains(&class) || !(1..=32).contains(&race) { + return Vec::new(); + } + let race_bit = 1u32 << (race - 1); + let class_bit = 1u32 << (class - 1); + self.tabs + .iter() + .filter(|t| t.class_mask & class_bit != 0 && t.race_mask & race_bit != 0) + .collect() + } + + /// A tab's talents in native DBC row order — the display/index order (byte-verified, see + /// [`TalentCatalog::by_tab`]). + pub fn talents_in_tab(&self, tab_id: u32) -> &[Talent] { + self.by_tab.get(&tab_id).map(Vec::as_slice).unwrap_or(&[]) + } + + /// One talent by id (the prereq-arrow resolve). + pub fn talent(&self, tab_id: u32, talent_id: u32) -> Option<&Talent> { + self.talents_in_tab(tab_id) + .iter() + .find(|t| t.id == talent_id) + } + + pub fn len(&self) -> usize { + self.by_tab.values().map(Vec::len).sum() + } + + pub fn is_empty(&self) -> bool { + self.by_tab.is_empty() + } +} + +fn talent_schema() -> Schema { + let mut s = Schema::new("Talent"); + for i in 0..TALENT_FIELDS { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + s +} + +fn talent_tab_schema() -> Schema { + let mut s = Schema::new("TalentTab"); + for i in 0..TALENT_TAB_FIELDS { + if i == COL_TT_NAME_ENUS { + s.add_field(SchemaField::new("NameEnUs", FieldType::String)); + } else if i == COL_TT_BACKGROUND { + s.add_field(SchemaField::new("BackgroundFile", FieldType::String)); + } else { + s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); + } + } + s +} + +/// Load the talent data plane off the patch chain. +pub fn load_talent_catalog(chain: &mut Chain) -> Result { + let tab_bytes = chain + .read_file(TALENT_TAB) + .context("reading TalentTab.dbc")?; + let tab_set = parse(&tab_bytes, talent_tab_schema(), "TalentTab.dbc")?; + let mut tabs = Vec::new(); + for r in tab_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + tabs.push(TalentTabInfo { + id, + name: str_at(&tab_set, r, COL_TT_NAME_ENUS).unwrap_or_default(), + race_mask: u32_at(r, COL_TT_RACE_MASK).unwrap_or(0), + class_mask: u32_at(r, COL_TT_CLASS_MASK).unwrap_or(0), + background: str_at(&tab_set, r, COL_TT_BACKGROUND).unwrap_or_default(), + }); + } + + let t_bytes = chain.read_file(TALENT).context("reading Talent.dbc")?; + let t_set = parse(&t_bytes, talent_schema(), "Talent.dbc")?; + let mut by_tab: HashMap> = HashMap::new(); + for r in t_set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let mut ranks = [0u32; MAX_TALENT_RANK]; + for (i, rank) in ranks.iter_mut().enumerate() { + *rank = u32_at(r, COL_T_RANK0 + i).unwrap_or(0); + } + let t = Talent { + id, + tab: u32_at(r, COL_T_TAB).unwrap_or(0), + row: u32_at(r, COL_T_ROW).unwrap_or(0), + col: u32_at(r, COL_T_COL).unwrap_or(0), + ranks, + prereq_talent: u32_at(r, COL_T_PREREQ).unwrap_or(0), + prereq_rank: u32_at(r, COL_T_PREREQ_RANK).unwrap_or(0), + required_spell: u32_at(r, COL_T_REQUIRED_SPELL).unwrap_or(0), + exceptional: u32_at(r, COL_T_FLAGS).unwrap_or(0) & 1 != 0, + }; + // Native row order preserved — the enumeration law (module doc); never sorted. + by_tab.entry(t.tab).or_default().push(t); + } + + Ok(TalentCatalog { tabs, by_tab }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real build-5875 tables, structurally (skips without client data): the mage's three + /// canonical pages in order with their art bases, and the whole table's grid/rank/prereq + /// invariants — every talent seats inside the 4-wide/≤8-tier grid, rank arrays are + /// prefix-shaped, and every prerequisite resolves within its own tab (the reference frame + /// only draws same-tab arrows, so cross-tab data would falsify the renderer's assumption). + #[test] + fn real_talent_tables_hold_the_grid_and_prereq_invariants() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_talent_catalog(&mut chain).expect("load Talent/TalentTab"); + assert!( + cat.len() > 300, + "the 1.12 talent table has hundreds of rows, got {}", + cat.len() + ); + + // The mage pages (class 8): Arcane, Fire, Frost in raw file order (the byte-verified + // tab law — orderIndex is dead data), with their art bases. + const HUMAN: u8 = 1; + const MAGE: u8 = 8; + let tabs = cat.tabs_for_class(HUMAN, MAGE); + assert_eq!( + tabs.iter().map(|t| t.name.as_str()).collect::>(), + vec!["Arcane", "Fire", "Frost"] + ); + assert_eq!( + tabs.iter() + .map(|t| t.background.as_str()) + .collect::>(), + vec!["MageArcane", "MageFire", "MageFrost"] + ); + // Every tab admits all races this build (raceMask 511) — the both-masks filter is + // exercised structurally: a race bit outside 511 admits nothing. + assert!( + cat.tabs_for_class(10, MAGE).is_empty(), + "race bit 10 is outside 511" + ); + // Every class (unit class bytes 1..=9, no class 10 in vanilla, druid = 11) has pages. + for class in [1u8, 2, 3, 4, 5, 7, 8, 9, 11] { + let n = cat.tabs_for_class(HUMAN, class).len(); + assert_eq!(n, 3, "class {class} has three talent pages, got {n}"); + } + + for tabs in cat.tabs_for_class(HUMAN, MAGE) { + let talents = cat.talents_in_tab(tabs.id); + assert!(!talents.is_empty()); + // The shipped data authors every tab's NATIVE row order in (row, col) order — the + // 27/27 coincidence the module doc records (the reader imposes no sort; if a future + // patch chain broke this, the client (and we) would show the authored order). + let seats: Vec<(u32, u32)> = talents.iter().map(|t| (t.row, t.col)).collect(); + let mut sorted = seats.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!( + seats, sorted, + "the shipped data's (row, col)-ordered authoring" + ); + } + + // Whole-table invariants. + let all_ids: std::collections::HashSet = + cat.by_tab.values().flatten().map(|t| t.id).collect(); + for t in cat.by_tab.values().flatten() { + assert!( + t.col < 4, + "talent {} col {} inside the 4-wide grid", + t.id, + t.col + ); + assert!(t.row < 8, "talent {} row {} inside 8 tiers", t.id, t.row); + let n = t.max_rank() as usize; + assert!(n >= 1, "talent {} has at least one rank", t.id); + assert!( + t.ranks[n..].iter().all(|&s| s == 0), + "talent {} rank array is prefix-shaped", + t.id + ); + if t.prereq_talent != 0 { + assert!( + all_ids.contains(&t.prereq_talent), + "talent {} prereq {} resolves", + t.id, + t.prereq_talent + ); + assert!( + cat.talent(t.tab, t.prereq_talent).is_some(), + "talent {} prereq {} lives in the same tab", + t.id, + t.prereq_talent + ); + assert!( + (t.prereq_rank as usize) < MAX_TALENT_RANK, + "talent {} prereq rank {} is 0-based sane", + t.id, + t.prereq_rank + ); + } + } + } +} diff --git a/crates/benilla-formats/src/taxi.rs b/crates/benilla-formats/src/taxi.rs new file mode 100644 index 00000000..e3267549 --- /dev/null +++ b/crates/benilla-formats/src/taxi.rs @@ -0,0 +1,189 @@ +//! `TaxiPathNode.dbc` — a taxi/transport path's ordered waypoints (map + world position, a +//! station-stop flag, and a stop delay). Every flight path AND every `MO_TRANSPORT` +//! (boat/zeppelin, decision 0438) is one `TaxiPath.dbc` id whose nodes live here, keyed by +//! `TaxiPath.dbc`'s id (this table's `PathID` column) — `TaxiPath.dbc` itself only carries the +//! `(fromNode, toNode, cost)` triple for the flight-master UI and is not adapted separately yet. +//! +//! **9 fields (verified this session, raw-parsed against build 5875):** +//! `ID(0), PathID(1), NodeIndex(2), MapID(3), LocX(4), LocY(5), LocZ(6), Flags(7), Delay(8)` — +//! matching vmangos's own `TaxiPathNodeEntry` column comments (`DBCStructure.h:696-707`, +//! `m_ID/m_PathID/m_NodeIndex/m_ContinentID/m_LocX/m_LocY/m_LocZ/m_flags/m_delay`) field-for-field. +//! A path's nodes are **not** confined to one continent: path 302 (Orgrimmar–Undercity) has 36 +//! nodes spanning `MapID` `{1, 0}` (Kalimdor, Azeroth) — the map-change itself is where the +//! transport timetable builder (`crate::transports`) inserts a teleport keyframe, never a spline +//! segment. `Flags == 2` marks a station stop (vmangos `KeyFrame::IsStopFrame`, exact equality, +//! not a bitmask test) and pairs with a nonzero `Delay` (seconds) at that node: path 302 stops +//! twice, 60 s each; path 285 (27 nodes) stops at node indexes 4 and 20. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; +use crate::Chain; + +const TAXI_PATH_NODE: &str = "DBFilesClient\\TaxiPathNode.dbc"; + +/// One `TaxiPathNode.dbc` row — a single waypoint on a taxi/transport path. +#[derive(Clone, Copy, Debug)] +pub struct TaxiPathNode { + /// The row's own id (`ID`) — a global waypoint id, not meaningful outside this table. + pub id: u32, + /// `TaxiPath.dbc` id this node belongs to (the catalog's key). + pub path_id: u32, + /// Ordinal position within the path, `0`-based — the catalog sorts each path's nodes by + /// this field, so the stored `Vec` order already matches path traversal order. + pub node_index: u32, + /// The map this node's `pos` is expressed in (`Map.dbc` id). + pub map_id: u32, + /// World position `(x, y, z)` on `map_id`. + pub pos: [f32; 3], + /// Raw `actionFlag`. Bit `1` (`flags & 2`) marks a station stop — the client tests it as a + /// bitmask (`0x5f4e37`, wow-re §5 2026-07-17; vmangos's `IsStopFrame` uses `== 2`, identical + /// on the live data); bit `0` (`flags & 1`) marks a map-change/teleport trigger in the + /// transport timetable builder. + pub flags: u32, + /// Stop delay in whole seconds — only meaningful when `flags == 2`; `0` otherwise. + pub delay: u32, +} + +/// `TaxiPathNode.dbc` rows grouped by `PathID`, each path's `Vec` sorted by `NodeIndex`. +pub struct TaxiPathNodes { + paths: HashMap>, +} + +impl TaxiPathNodes { + /// A path's nodes, in `NodeIndex` order, or `None` if the path id is unknown. + pub fn path(&self, path_id: u32) -> Option<&[TaxiPathNode]> { + self.paths.get(&path_id).map(Vec::as_slice) + } + + /// Every path, in no particular order. + pub fn paths(&self) -> impl Iterator { + self.paths.iter().map(|(&id, nodes)| (id, nodes.as_slice())) + } + + /// Number of distinct paths (not the total node count). + pub fn len(&self) -> usize { + self.paths.len() + } + + pub fn is_empty(&self) -> bool { + self.paths.is_empty() + } +} + +/// 9 fields per the module doc. +fn schema() -> Schema { + let mut s = Schema::new("TaxiPathNode"); + for name in ["ID", "PathID", "NodeIndex", "MapID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for name in ["LocX", "LocY", "LocZ"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + for name in ["Flags", "Delay"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `TaxiPathNode.dbc` off the patch chain into a [`TaxiPathNodes`], each path's `Vec` +/// sorted by `NodeIndex`. +pub fn load_taxi_path_nodes(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(TAXI_PATH_NODE) + .context("reading TaxiPathNode.dbc")?; + let rs = parse(&bytes, schema(), "TaxiPathNode")?; + let mut paths: HashMap> = HashMap::new(); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let Some(path_id) = u32_at(r, 1) else { + continue; + }; + let (Some(x), Some(y), Some(z)) = (f32_at(r, 4), f32_at(r, 5), f32_at(r, 6)) else { + continue; + }; + let node = TaxiPathNode { + id, + path_id, + node_index: u32_at(r, 2).unwrap_or(0), + map_id: u32_at(r, 3).unwrap_or(0), + pos: [x, y, z], + flags: u32_at(r, 7).unwrap_or(0), + delay: u32_at(r, 8).unwrap_or(0), + }; + paths.entry(path_id).or_default().push(node); + } + for nodes in paths.values_mut() { + nodes.sort_by_key(|n| n.node_index); + } + Ok(TaxiPathNodes { paths }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table proves the layout and the two pinned paths from the module doc: path + /// 302 (Orgrimmar–Undercity, 36 nodes spanning maps `{0,1}`, two `Flags==2` stops at 60 s + /// each) and path 285 (27 nodes, stops at indexes 4 and 20). Skips without client data. + #[test] + fn real_taxi_path_node_layout_sanity() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode"); + assert!( + cat.len() > 100, + "many taxi/transport paths load: {}", + cat.len() + ); + + let path302 = cat.path(302).expect("path 302 exists"); + assert_eq!(path302.len(), 36, "path 302 has 36 nodes"); + // sorted by node_index + for w in path302.windows(2) { + assert!( + w[0].node_index < w[1].node_index, + "nodes sorted by node_index: {:?} then {:?}", + w[0].node_index, + w[1].node_index + ); + } + let maps: std::collections::BTreeSet = path302.iter().map(|n| n.map_id).collect(); + assert_eq!( + maps, + std::collections::BTreeSet::from([0, 1]), + "path 302 spans maps {{0,1}}" + ); + let mut flag_histogram: HashMap = HashMap::new(); + for n in path302 { + *flag_histogram.entry(n.flags).or_insert(0) += 1; + } + assert_eq!(flag_histogram.get(&0).copied(), Some(34)); + assert_eq!(flag_histogram.get(&2).copied(), Some(2)); + let flag2_delays: Vec = path302 + .iter() + .filter(|n| n.flags == 2) + .map(|n| n.delay) + .collect(); + assert_eq!(flag2_delays, vec![60, 60], "both flag-2 nodes delay 60s"); + + let path285 = cat.path(285).expect("path 285 exists"); + assert_eq!(path285.len(), 27, "path 285 has 27 nodes"); + let stop_indexes: Vec = path285 + .iter() + .filter(|n| n.flags == 2) + .map(|n| n.node_index) + .collect(); + assert_eq!(stop_indexes, vec![4, 20], "path 285 stops at indexes 4, 20"); + + let path292 = cat.path(292).expect("path 292 exists"); + assert!(!path292.is_empty(), "path 292 is non-empty"); + } +} diff --git a/crates/benilla-formats/src/taxi_nodes.rs b/crates/benilla-formats/src/taxi_nodes.rs new file mode 100644 index 00000000..9481472c --- /dev/null +++ b/crates/benilla-formats/src/taxi_nodes.rs @@ -0,0 +1,154 @@ +//! `TaxiNodes.dbc` — every flight-master node: its map + world position, display name, and the +//! per-team taxi-mount creature template it rides (decision 0484 phase 1). Loaded whole at +//! startup, id-keyed — the taxi map's node list and the "which node am I nearest" resolve +//! through this catalog. `TaxiPath.dbc` (`crate::taxi_path`) carries the direct-hop fare table +//! between node pairs; `TaxiPathNode.dbc` (`crate::taxi`) carries a path's actual waypoints — +//! three different tables, three different jobs. +//! +//! **16 fields (verified this session, raw-parsed against build 5875, matching vmangos's own +//! `TaxiNodesEntry`, `DBCStructure.h:789-799`):** `ID(0), MapID(1), X(2), Y(3), Z(4)`, the +//! 9-dword localized-name block (`Name_lang` 8 locale strings, columns 5-12, + a flags dword at +//! 13 — the same loc-string shape `AreaPOI`/`AreaTable` use), then `MountCreatureID[2]` at +//! columns 14-15 — **horde first, alliance second** (vmangos's own field comment: +//! "horde[14]-alliance[15]"). Both are `CreatureTemplate` **entries**, not display ids — the +//! taxi mount's actual look resolves through the normal creature-template chain, same as any +//! other NPC (NOT a `CreatureDisplayInfo` id directly). +//! +//! Verified against the live table: id 2 is "Stormwind, Elwynn" on map 0, with `mount_horde = 0` +//! / `mount_alliance = 541` — a human-city node offers no Horde service and a nonzero Alliance +//! one, exactly as expected. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const TAXI_NODES: &str = "DBFilesClient\\TaxiNodes.dbc"; + +/// One `TaxiNodes.dbc` row — a flight-master node. +#[derive(Clone, Debug)] +pub struct TaxiNode { + /// The row's own id — what `SMSG_SHOWTAXINODES`'s known-mask and `CMSG_ACTIVATETAXI`'s + /// node fields name. + pub id: u32, + /// The map this node's `pos` is expressed in (`Map.dbc` id). + pub map_id: u32, + /// World position `(x, y, z)` on `map_id`. + pub pos: [f32; 3], + /// The enUS display name (locale slot 0) — the taxi map's node label. + pub name: String, + /// `CreatureTemplate` entry of the taxi mount a Horde rider takes off from this node, `0` = + /// no Horde service here (e.g. an Alliance city node). + pub mount_horde: u32, + /// `CreatureTemplate` entry of the taxi mount an Alliance rider takes off from this node, + /// `0` = no Alliance service here. + pub mount_alliance: u32, +} + +/// `TaxiNodes.dbc` rows keyed by `ID`. +pub struct TaxiNodes { + rows: HashMap, +} + +impl TaxiNodes { + pub fn get(&self, id: u32) -> Option<&TaxiNode> { + self.rows.get(&id) + } + + /// Every row, in no particular order — the taxi map's node list iterates this, filtered by + /// the known-mask and continent. + pub fn rows(&self) -> impl Iterator { + self.rows.values() + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// 16 fields per the module doc. +fn schema() -> Schema { + let mut s = Schema::new("TaxiNodes"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("MapID", FieldType::UInt32)); + for name in ["X", "Y", "Z"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + s.add_field(SchemaField::new("Name", FieldType::String)); // enUS (locale 0) + s.add_field(SchemaField::new_array( + "NameOtherLocales", + FieldType::String, + 7, + )); + s.add_field(SchemaField::new("NameFlags", FieldType::UInt32)); + s.add_field(SchemaField::new("MountIdHorde", FieldType::UInt32)); + s.add_field(SchemaField::new("MountIdAlliance", FieldType::UInt32)); + s +} + +/// Read `TaxiNodes.dbc` off the patch chain into a [`TaxiNodes`] catalog. +pub fn load_taxi_nodes(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(TAXI_NODES) + .context("reading TaxiNodes.dbc")?; + let rs = parse(&bytes, schema(), "TaxiNodes")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let (Some(x), Some(y), Some(z)) = (f32_at(r, 2), f32_at(r, 3), f32_at(r, 4)) else { + continue; + }; + let node = TaxiNode { + id, + map_id: u32_at(r, 1).unwrap_or(0), + pos: [x, y, z], + name: str_at(&rs, r, 5).unwrap_or_default(), + mount_horde: u32_at(r, 14).unwrap_or(0), + mount_alliance: u32_at(r, 15).unwrap_or(0), + }; + rows.insert(id, node); + } + Ok(TaxiNodes { rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table proves the layout and the pinned Stormwind row: 85 nodes total, id 2 + /// is "Stormwind, Elwynn" on map 0 (Eastern Kingdoms), with a nonzero Alliance mount (541) + /// and no Horde mount (0) — a human-city node offers Alliance-only service. Values dumped + /// from the real DBC this session (`dbc_to_csv` + a raw byte cross-check), not guessed. + /// Skips without client data. + #[test] + fn real_taxi_nodes_layout_sanity() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_nodes(&mut chain).expect("load TaxiNodes"); + assert_eq!(cat.len(), 85, "1.12 ships 85 taxi nodes"); + + let stormwind = cat.get(2).expect("node 2 exists"); + assert_eq!(stormwind.name, "Stormwind, Elwynn"); + assert_eq!(stormwind.map_id, 0, "Eastern Kingdoms"); + assert_eq!(stormwind.mount_horde, 0, "no Horde service from Stormwind"); + assert_eq!( + stormwind.mount_alliance, 541, + "Stormwind's Alliance gryphon mount template" + ); + + // Sentinel Hill (Westfall), the other end of the pinned TaxiPath test's hop. + let sentinel_hill = cat.get(4).expect("node 4 exists"); + assert_eq!(sentinel_hill.name, "Sentinel Hill, Westfall"); + } +} diff --git a/crates/benilla-formats/src/taxi_path.rs b/crates/benilla-formats/src/taxi_path.rs new file mode 100644 index 00000000..8b1bf4b2 --- /dev/null +++ b/crates/benilla-formats/src/taxi_path.rs @@ -0,0 +1,124 @@ +//! `TaxiPath.dbc` — the direct-hop fare table between two flight-master nodes: `(from, to, cost)` +//! triples the flight-master UI walks to price a route (decision 0484 phase 1). Distinct from +//! `TaxiPathNode.dbc` (`crate::taxi`), which carries a path's actual waypoints — this table is +//! the coarse "does a direct hop from A to B exist, and what does it cost" lookup phase 2's route +//! computation needs; a multi-hop trip chains several of these rows. +//! +//! **4 fields (verified this session, matching vmangos's own `TaxiPathEntry`, +//! `DBCStructure.h:688-694`):** `ID(0), FromTaxiNode(1), ToTaxiNode(2), Price(3)`. +//! +//! Verified against the live table: id 6 is the direct hop `TaxiNodes` 2 ("Stormwind, Elwynn") +//! → 4 ("Sentinel Hill, Westfall"), cost 110 copper. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +const TAXI_PATH: &str = "DBFilesClient\\TaxiPath.dbc"; + +/// One `TaxiPath.dbc` row — a direct hop between two flight-master nodes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaxiPath { + pub id: u32, + pub from: u32, + pub to: u32, + /// Fare in copper for this one hop, undiscounted — vmangos applies the reputation discount + /// server-side before charging; the wire's `TaxiPath.dbc` copy never carries a discounted + /// figure. + pub cost: u32, +} + +/// `TaxiPath.dbc` rows keyed by `ID`, plus a `(from, to)` lookup for the route computation +/// phase 2 needs. +pub struct TaxiPaths { + rows: HashMap, + by_pair: HashMap<(u32, u32), u32>, +} + +impl TaxiPaths { + pub fn get(&self, id: u32) -> Option<&TaxiPath> { + self.rows.get(&id) + } + + /// The direct hop from `from` to `to`, if `TaxiPath.dbc` carries one. The table is + /// directed — a two-way route is two separate rows, one per direction — so this only ever + /// matches the exact order asked for. + pub fn between(&self, from: u32, to: u32) -> Option<&TaxiPath> { + self.by_pair + .get(&(from, to)) + .and_then(|id| self.rows.get(id)) + } + + /// Every direct hop leaving `node`, in no particular order — a route search expands a node + /// through this (phase 2). + pub fn paths_from(&self, node: u32) -> impl Iterator { + self.rows.values().filter(move |p| p.from == node) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +/// 4 fields per the module doc. +fn schema() -> Schema { + let mut s = Schema::new("TaxiPath"); + for name in ["ID", "FromTaxiNode", "ToTaxiNode", "Cost"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `TaxiPath.dbc` off the patch chain into a [`TaxiPaths`] catalog. +pub fn load_taxi_paths(chain: &mut Chain) -> Result { + let bytes = chain.read_file(TAXI_PATH).context("reading TaxiPath.dbc")?; + let rs = parse(&bytes, schema(), "TaxiPath")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + let mut by_pair = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let Some(from) = u32_at(r, 1) else { continue }; + let Some(to) = u32_at(r, 2) else { continue }; + let cost = u32_at(r, 3).unwrap_or(0); + rows.insert(id, TaxiPath { id, from, to, cost }); + by_pair.insert((from, to), id); + } + Ok(TaxiPaths { rows, by_pair }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table proves the layout and the pinned Stormwind→Sentinel Hill hop: 287 + /// rows total, id 6 is the direct `2 → 4` hop at cost 110. Cross-checked with + /// [`crate::taxi_nodes::tests::real_taxi_nodes_layout_sanity`]'s node names. Skips without + /// client data. + #[test] + fn real_taxi_path_layout_sanity() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_paths(&mut chain).expect("load TaxiPath"); + assert_eq!(cat.len(), 287, "1.12 ships 287 taxi path hops"); + + let hop = cat.between(2, 4).expect("a direct 2 -> 4 hop exists"); + assert_eq!(hop.id, 6); + assert_eq!(hop.cost, 110); + assert!(hop.cost > 0, "a real hop always carries a nonzero fare"); + + // paths_from surfaces the same row when walking node 2's outgoing hops. + assert!(cat.paths_from(2).any(|p| p.to == 4 && p.cost == 110)); + } +} diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs new file mode 100644 index 00000000..6bd528c1 --- /dev/null +++ b/crates/benilla-formats/src/terrain.rs @@ -0,0 +1,770 @@ +//! ADT terrain → per-chunk triangle meshes (Phase 2 + Phase 6 splat). +//! +//! Output is in **raw WoW world coords** (+X north, +Y west, +Z up, yards); the renderer +//! (`benilla`) applies the WoW→Bevy transform. Each MCNK contributes one [`ChunkMesh`] +//! built as the binary's **145-vertex center-fan** (B1): 81 outer verts (9×9 corner grid) + 64 inner +//! center verts (8×8 at +½-cell horizontal in both planar axes), each cell rendered as 4 +//! triangles fanning from its center to its 4 corners. Each inner vertex carries its **own** +//! authored MCVT center height (independent sample, NOT a corner average — dropping it was +//! the "flattened hilltops" defect). Holes (B2, `MCNK+0x3C`) omit a 2×2-cell block from the +//! index buffer; the underlying verts stay (unreferenced). +//! +//! Vertex layout mirrors MCVT's on-disk **stride-17 interleave** (row r: 9 outer then 8 inner; +//! final row 9 outer only): outer `(r,c)` for r,c ∈ 0..=8 is at `r·17+c`; inner `(r,c)` for +//! r,c ∈ 0..=7 is at `r·17+9+c`. Positions, normals (MCNR `[X,Y,Z]` raw, no swap), and +//! 0..1 UVs are parallel arrays in this order — the renderer splats up to 4 layers using a +//! per-chunk RGBA alpha map (R/G/B = layers 1/2/3 opacity). + +use std::io::Cursor; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_adt::{parse_adt, CombinedAlphaMap, ParsedAdt}; +use benilla_wdt::{version::WowVersion, WdtFile, WdtReader}; + +/// Edge length (texels) of a chunk's combined alpha map (always 64×64 in vanilla). +pub const ALPHA_MAP_SIZE: u32 = 64; + +/// Edge length (texels) of a chunk's MCSH shadow map (always 64×64 in vanilla). +pub const SHADOW_MAP_SIZE: u32 = 64; + +/// Yards per ADT tile (1/64 of a map edge). +pub const TILE_SIZE: f32 = 533.333_3; +/// Yards per MCNK chunk (16×16 chunks per tile). +const CHUNK_SIZE: f32 = TILE_SIZE / 16.0; +/// Yards between adjacent outer-grid vertices (9×9 grid spans one chunk). +/// `pub(crate)` so the liquid mesher shares the exact same lattice spacing. +pub(crate) const UNIT_SIZE: f32 = CHUNK_SIZE / 8.0; + +/// World (x, y) of Stormwind, from `TaxiNodes.dbc` — a handy anchor for "Elwynn". +pub const STORMWIND_XY: (f32, f32) = (-8840.56, 489.7); + +/// One MCNK chunk as an indexed triangle mesh in **raw WoW coords**, plus texturing data. +/// +/// Vertex arrays (`positions`, `normals`, `uvs`) hold **145 entries** in MCVT's stride-17 order: +/// outer `(r,c)` at `r·17+c` (r,c ∈ 0..=8), inner `(r,c)` at `r·17+9+c` (r,c ∈ 0..=7). The +/// index buffer is the 4-triangle center-fan per cell (`(CTR,TL,BL),(CTR,BL,BR),(CTR,BR,TR), +/// (CTR,TR,TL)`), 256 tris / 768 indices for a chunk with no holes. +#[derive(Debug, Clone)] +pub struct ChunkMesh { + /// Vertex positions `[x, y, z]` in WoW yards — 145 entries (81 outer + 64 inner). See the + /// type-level doc for the stride-17 layout. + pub positions: Vec<[f32; 3]>, + /// Per-vertex normals (unit, WoW coords) decoded from MCNR, one per [`Self::positions`] entry. + /// Authored continuously across chunk borders, so using these instead of recomputing per-chunk + /// removes the lighting seams at MCNK boundaries. Empty if the chunk had no MCNR data. + pub normals: Vec<[f32; 3]>, + /// Per-vertex texture coordinates (0..1 across the chunk; outer at `(c/8, r/8)`, inner at + /// `((c+½)/8, (r+½)/8)`). Renderers scale `× tex_tiles` (=8 per the binary) for layer + /// sampling and use the raw 0..1 for alpha/shadow. + pub uvs: Vec<[f32; 2]>, + /// Triangle-list indices into `positions` (4-tri center-fan/cell, holed blocks omitted). + pub indices: Vec, + /// Low-res hole bitmap (`u16 @ MCNK+0x3C`). Bit `n = (cellY>>1)·4 + (cellX>>1)` set ⇒ that + /// 2×2 block of the 8×8 cells is missing (caves/doorways). The mesher already omits the + /// matching index ranges; downstream consumers (e.g. clutter) re-test it to skip placing + /// surface decoration over a hole. + pub holes: u16, + /// MTEX filename of the chunk's base (layer 0) texture, if the chunk has layers. + pub base_texture: Option, + /// MTEX filenames of all texture layers (1–4), layer 0 first. The renderer blends layers + /// 1..n over layer 0 using [`Self::alpha_map`]. + pub layer_textures: Vec, + /// Per-layer `effectId` (MCLY), aligned with [`Self::layer_textures`]: a `GroundEffectTexture.dbc` + /// reference selecting the ground-clutter doodads for that layer (`0xFFFFFFFF` = none). + pub layer_effect_ids: Vec, + /// Packed RGBA alpha map (`ALPHA_MAP_SIZE`²), R/G/B = opacity of layers 1/2/3 across the + /// chunk (0..1 UV). `None` when the chunk has ≤1 layer (nothing to blend). + pub alpha_map: Option>, + /// Pre-baked terrain shadow map from MCSH: one byte per texel over a `SHADOW_MAP_SIZE`² grid + /// (`255` = in shadow, `0` = lit), row-major over the chunk's 0..1 UV. This is WoW's **static** + /// terrain shadowing (baked at map-compile time); `None` when the chunk has no MCSH. + pub shadow: Option>, + /// `predominantTexture` (MCNK 0x40, `uint2[8][8]`): for each 8×8 cell, the texture **layer + /// index** (0..3) whose MCLY `effectId` selects that cell's ground-clutter doodads. Row-major, + /// `k = row*8 + col` — same frame as the vertex grid (row steps south, col east). This is the + /// client's *authoritative* per-cell doodad-layer map; verified to match the alpha layout + /// cell-for-cell (`examples/clutter_probe`). + pub pred_tex: [u8; 64], + /// `noEffectDoodad` (MCNK 0x50, `uint1[8][8]`): `true` ⇒ the client draws **no** clutter on that + /// cell (e.g. paved roads/clearings). Same `k = row*8 + col` indexing as [`Self::pred_tex`]. + /// + /// ⚠️ wow-adt 0.6.4 mislabels both grids for vanilla: it splits the 16-byte predominantTexture + /// into `pred_tex[8]` + `no_effect_doodad[8]` and calls the *real* noEffectDoodad `unknown_8bytes`. + /// We reconstruct the correct grids from those preserved bytes (see `adt_to_tile_mesh`). + pub no_effect_doodad: [bool; 64], + /// This chunk's `(IndexX, IndexY)` within the 16×16 tile grid (MCNK header). Needed to seed the + /// ground-clutter PRNG by global chunk position (`scatter_ground_doodads`), matching the client. + pub index_x: u32, + pub index_y: u32, + /// `AreaTable.dbc` id of this chunk (MCNK +0x34) — the zone/subzone under the player's feet; + /// drives zone music/ambience/reverb selection (decision 0070). + pub area_id: u32, + /// The chunk's MCLQ liquid surface (lake/river/ocean), if any — a flat water mesh built + /// alongside the terrain (see [`crate::liquid`]). `None` for dry chunks and magma/slime (Phase 3). + pub liquid: Option, +} + +impl ChunkMesh { + /// Sample the baked MCSH terrain shadow at a **raw WoW-space** position (X north, Y west): the same + /// per-texel lookup the terrain shader and clutter use, so a doodad/unit standing here inherits the + /// ground's static shade. Returns `Some(true)` = shadowed, `Some(false)` = lit, and `None` when the + /// position is outside this chunk's footprint (caller tries the next chunk). A chunk with no MCSH map + /// is fully lit. Coord math mirrors the vertex grid: NW corner is `positions[0]`, +X is north (south + /// distance = `nw_x − x`), +Y is west (east distance = `nw_y − y`), both `0..1` across the chunk. + pub fn mcsh_shadowed_at(&self, wow: [f32; 3]) -> Option { + let nw = *self.positions.first()?; + let south = (nw[0] - wow[0]) / TILE_SIZE * 16.0; + let east = (nw[1] - wow[1]) / TILE_SIZE * 16.0; + if !(0.0..=1.0).contains(&south) || !(0.0..=1.0).contains(&east) { + return None; + } + let Some(shadow) = self.shadow.as_ref() else { + return Some(false); // inside this chunk but no MCSH → lit + }; + let n = SHADOW_MAP_SIZE as usize; + let row = ((south * n as f32) as usize).min(n - 1); + let col = ((east * n as f32) as usize).min(n - 1); + Some(shadow.get(row * n + col).copied().unwrap_or(0) >= 128) + } + + /// The `GroundEffectTexture` id under a **raw WoW-space** position, `None` when the position + /// is outside this chunk (or the cell's layer carries no effect). Uses the chunk's own + /// `predominantTexture` per-cell layer map — the client's authoritative per-cell selector + /// (the same grid the ground clutter scatters by) — then that layer's MCLY `effectId`. + /// Feeds the footstep terrain-type chain (decision 0070 slice 3). + pub fn ground_effect_at(&self, wow: [f32; 3]) -> Option { + let nw = *self.positions.first()?; + let south = (nw[0] - wow[0]) / TILE_SIZE * 16.0; + let east = (nw[1] - wow[1]) / TILE_SIZE * 16.0; + if !(0.0..=1.0).contains(&south) || !(0.0..=1.0).contains(&east) { + return None; + } + let row = ((south * 8.0) as usize).min(7); + let col = ((east * 8.0) as usize).min(7); + let layer = self.pred_tex[row * 8 + col] as usize; + let effect = self.layer_effect_ids.get(layer).copied()?; + (effect != u32::MAX).then_some(effect) + } + + /// The chunk's `AreaTable` id if the **raw WoW-space** position lies inside its footprint, + /// else `None` (caller tries the next chunk). Same containment math as + /// [`Self::mcsh_shadowed_at`]. + pub fn area_at(&self, wow: [f32; 3]) -> Option { + let nw = *self.positions.first()?; + let south = (nw[0] - wow[0]) / TILE_SIZE * 16.0; + let east = (nw[1] - wow[1]) / TILE_SIZE * 16.0; + if (0.0..=1.0).contains(&south) && (0.0..=1.0).contains(&east) { + Some(self.area_id) + } else { + None + } + } + + /// The terrain surface height (WoW `z`) under a **raw WoW-space** column, by exact interpolation + /// of the covering center-fan triangle. `None` when the column lies outside this chunk's footprint + /// (caller tries the next chunk) **or falls in an MCNK hole**. + /// + /// The hole case is load-bearing, not an edge case: a hole is how a mine/cave entrance is cut out + /// of the ground, and it carries no triangle in [`Self::indices`] — so the query misses exactly + /// where the client's terrain probe misses, handing that column to the WMO interior below it. Any + /// implementation that interpolated a height across a hole would seal the entrance shut. + pub fn height_at(&self, wow: [f32; 3]) -> Option { + let nw = *self.positions.first()?; + let south = (nw[0] - wow[0]) / TILE_SIZE * 16.0; + let east = (nw[1] - wow[1]) / TILE_SIZE * 16.0; + if !(0.0..=1.0).contains(&south) || !(0.0..=1.0).contains(&east) { + return None; + } + self.indices.chunks_exact(3).find_map(|t| { + let tri = [ + *self.positions.get(t[0] as usize)?, + *self.positions.get(t[1] as usize)?, + *self.positions.get(t[2] as usize)?, + ]; + triangle_z_at(&tri, wow[0], wow[1]) + }) + } +} + +/// The `z` of triangle `tri` (WoW coords, Z up) at column `(x, y)`, or `None` when `(x, y)` falls +/// outside the triangle's XY projection (or the triangle is vertical / degenerate in XY). +/// +/// Barycentric interpolation — for a **vertical** ray this IS the exact ray-vs-triangle test: a +/// vertical face projects to a degenerate XY sliver, and a vertical ray is parallel to it anyway. +/// Shared by the WMO current-group down-ray (its Leg A walking-collision faces) and the ADT terrain +/// probe that races it, so the two legs of the client's arbitration can never drift apart. +pub fn triangle_z_at(tri: &[[f32; 3]; 3], x: f32, y: f32) -> Option { + let (a, b, c) = (tri[0], tri[1], tri[2]); + let det = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]); + if det.abs() < 1.0e-9 { + return None; // vertical / degenerate in XY + } + let l1 = ((b[1] - c[1]) * (x - c[0]) + (c[0] - b[0]) * (y - c[1])) / det; + let l2 = ((c[1] - a[1]) * (x - c[0]) + (a[0] - c[0]) * (y - c[1])) / det; + let l3 = 1.0 - l1 - l2; + if l1 < 0.0 || l2 < 0.0 || l3 < 0.0 { + return None; + } + Some(l1 * a[2] + l2 * b[2] + l3 * c[2]) +} + +/// The `AreaTable` id under a raw WoW-space position, given **one tile's** chunks; `None` when the +/// position lies outside every chunk (a different tile — same containment contract as +/// [`mcsh_shadowed_at`]). The zone/subzone driving music/ambience/reverb (decision 0070). +pub fn area_id_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option { + chunks.iter().find_map(|c| c.area_at(wow)) +} + +/// The `GroundEffectTexture` id under a raw WoW-space position, given **one tile's** chunks; +/// `None` when outside every chunk or when the containing cell's layer has no effect (footstep +/// consumers fall back to the Dirt default either way). +pub fn ground_effect_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option { + chunks.iter().find_map(|c| c.ground_effect_at(wow)) +} + +/// The terrain surface height (WoW `z`) under a raw WoW-space column, given **one tile's** chunks; +/// `None` when the column lies outside every chunk (a different tile) or falls in an MCNK hole. +/// The terrain leg of the client's down-ray arbitration (see [`ChunkMesh::height_at`]). +pub fn terrain_height_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option { + chunks.iter().find_map(|c| c.height_at(wow)) +} + +/// Is a raw WoW-space position in MCSH terrain shadow, given **one tile's** chunks? `Some(true)` when the +/// containing chunk's MCSH bit is set, `Some(false)` when the containing chunk is lit (or has no shadow +/// map), and **`None` when the position lies outside every chunk in `chunks`** — i.e. it belongs to a +/// different tile. Returning `None` rather than defaulting to lit is load-bearing: an MDDF doodad that +/// straddles a tile edge is listed in *every* tile it touches, but only the tile that CONTAINS its origin +/// can answer; the caller must take the `Some` from that tile and never let a straddle-tile miss +/// masquerade as "lit". (The real client sidesteps this with a global world→chunk MCSH lookup, `0x69b350` +/// — `models/scratch/m2-interior-doodad-base-light.md §6`.) Feeds the model lobe's `sun_scale` (the +/// faithful per-instance terrain-shade, 2.5 sunlit / 0.5 shaded — wow-re byte-verified). +pub fn mcsh_shadowed_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option { + chunks.iter().find_map(|c| c.mcsh_shadowed_at(wow)) +} + +/// A placed M2 doodad (tree/bush/prop): which model, and where, in **raw WoW world coords**. +#[derive(Debug, Clone)] +pub struct Doodad { + /// MMDX model path (`.mdx`); `load_m2_mesh` normalizes it to `.m2`. + pub model: String, + /// Position in WoW world coords (X north, Y west, Z up), converted from MDDF. + pub position: [f32; 3], + /// Euler rotation in **degrees** (X, Y, Z); Y is the heading about the up axis. + pub rotation: [f32; 3], + /// Uniform scale (MDDF stores it ×1024). + pub scale: f32, + /// MDDF uniqueId — stable per placement, identical across the ADT tiles a doodad straddles, so + /// the streamer can spawn it once and refcount it (the client's own dedup, `FUN_00694bc0`). + pub unique_id: u32, +} + +/// A placed WMO (building) — which model, and where, in **raw WoW world coords**. +#[derive(Debug, Clone)] +pub struct WmoInstance { + /// MWMO root path (`.wmo`); `load_wmo` loads its group files too. + pub model: String, + /// Position in WoW world coords (X north, Y west, Z up), converted from MODF. + pub position: [f32; 3], + /// Euler rotation in **degrees** (X, Y, Z); Y is the heading about the up axis. + pub rotation: [f32; 3], + /// MODF uniqueId — stable per placement, identical across the ADT tiles a building straddles + /// (Stormwind spans 6 tiles), so the streamer dedups it instead of spawning 6× (`FUN_00694bc0`). + pub unique_id: u32, + /// MODF doodad-set index: which *additional* WMO doodad set to show beyond the always-on global + /// set 0 (selects interior-prop variants — e.g. an inn's furnished vs empty rooms). + pub doodad_set: u16, + /// MODF name-set index — the placement's `WMOAreaTable.NameSetID` naming/audio variant. + pub name_set: u16, +} + +/// One ADT tile's terrain: its per-MCNK chunk meshes + placed doodads + buildings. +#[derive(Debug, Clone)] +pub struct TileMesh { + pub chunks: Vec, + pub doodads: Vec, + pub wmos: Vec, +} + +impl TileMesh { + /// Total vertices across all chunks. + pub fn vertex_count(&self) -> usize { + self.chunks.iter().map(|c| c.positions.len()).sum() + } +} + +/// Snap an X/Y world coordinate to the global terrain vertex lattice (`MAP_CENTER − n·UNIT_SIZE`). +/// +/// Each MCNK stores its own corner position as `f32`, which carries ~1–2 mm of per-chunk rounding. +/// So a vertex shared by two adjacent chunks (or tiles) — computed from each one's header + offset — +/// doesn't come out bit-identical, leaving the edges fractionally misaligned: hairline cracks that +/// show the sky through the terrain, and z-fighting where the coplanar edge triangles overlap (a +/// shimmering seam visible at any zoom). Real vertices lie on the lattice to <0.0005 unit, so +/// snapping (computed in `f64`) collapses each shared vertex to the *same* coordinate → watertight. +/// `pub(crate)` so the liquid mesher snaps its grid to the same lattice as the terrain it sits in. +pub(crate) fn snap_to_lattice(coord: f32) -> f32 { + let map_center = 32.0_f64 * 1600.0 / 3.0; // 64-tile map; TILE_SIZE = 1600/3 yd + let unit = (1600.0_f64 / 3.0) / 128.0; // 128 vertex-units per tile edge + let idx = ((map_center - f64::from(coord)) / unit).round(); + (map_center - idx * unit) as f32 +} + +/// Build per-chunk meshes from one vanilla (monolithic) ADT file's bytes. +pub fn adt_to_tile_mesh(adt_bytes: &[u8]) -> Result { + let mut cursor = Cursor::new(adt_bytes); + let parsed = parse_adt(&mut cursor).map_err(|e| anyhow::anyhow!("parsing ADT: {e}"))?; + // benilla-adt only produces vanilla monolithic root ADTs (no Cata+ split files). + let ParsedAdt::Root(root) = parsed; + + let mut chunks = Vec::new(); + for mcnk in &root.mcnk_chunks { + let Some(mcvt) = &mcnk.heights else { + continue; // no MCVT height data for this chunk + }; + if mcvt.heights.len() < 145 { + continue; + } + // wow-adt 0.6.4's world_position() mis-orders vanilla axes; the raw `position` field + // is already WoW [X(north), Y(west), Z(up)]. + let [wx, wy, wz] = mcnk.header.position; + + // MCNR normals parallel MCVT 1:1 (same 145-entry stride-17 grid). The disk byte order is + // already WoW `[X, Y, Z]` with no swap (qa3-adt: geometric dot 0.999 vs ~0.4 for the + // `[X, Z, Y]` interpretation). The `wow-adt` crate's `to_normalized()` is buggy and + // returns `[b0, b2, b1]`; our `[x, z, y] = …; [x, y, z]` swap below cancels the crate's + // swap, recovering the true raw `[X, Y, Z]` order. Output verified vs the mesh's own + // geometric normals (avg dot 0.99). Skipped if the chunk has no MCNR. + let mcnr = mcnk.normals.as_ref().filter(|n| n.normals.len() >= 145); + + // 145 verts in MCVT's stride-17 interleave: row r (0..8) emits 9 outer at indices + // `r·17+(0..9)` then 8 inner at `r·17+9+(0..8)`; the final outer row 8 has no inner + // companion. Outer (r,c) sits at chunk-local `(r·UNIT, c·UNIT, h)`, inner (r,c) at + // `((r+½)·UNIT, (c+½)·UNIT, h_inner)` — the **+½-cell offset is horizontal only**, and + // each inner vertex carries its OWN authored MCVT center height (the binary's B1). + let mut positions = Vec::with_capacity(145); + let mut normals = Vec::with_capacity(if mcnr.is_some() { 145 } else { 0 }); + let mut uvs = Vec::with_capacity(145); + for row in 0..9u32 { + // Outer row (9 verts at the corner grid). + for col in 0..9u32 { + let idx = (row * 17 + col) as usize; + let h = mcvt.heights[idx]; + positions.push([ + // Snap X/Y to the global lattice so shared chunk/tile edge vertices coincide + // exactly (no hairline cracks / z-fight seams); Z stays the MCVT height. + snap_to_lattice(wx - row as f32 * UNIT_SIZE), // WoW X (north): rows step south + snap_to_lattice(wy - col as f32 * UNIT_SIZE), // WoW Y (west): columns step east + wz + h, // WoW Z (up): height over base + ]); + if let Some(mcnr) = mcnr { + let [x, z, y] = mcnr.normals[idx].to_normalized(); + normals.push([x, y, z]); + } + // 0..1 per chunk; outer at integer/8. + uvs.push([col as f32 / 8.0, row as f32 / 8.0]); + } + // Inner row (8 verts at the cell centers) — skipped after the final outer row. + if row < 8 { + for col in 0..8u32 { + let idx = (row * 17 + 9 + col) as usize; + let h = mcvt.heights[idx]; + // No lattice-snap on inner: each inner vertex is per-chunk-private (the + // adjacent chunk has no twin at the same (x, y)), so the seam concern doesn't + // apply and we'd just move it off its authored position by the rounding error. + positions.push([ + wx - (row as f32 * UNIT_SIZE + UNIT_SIZE / 2.0), + wy - (col as f32 * UNIT_SIZE + UNIT_SIZE / 2.0), + wz + h, + ]); + if let Some(mcnr) = mcnr { + let [x, z, y] = mcnr.normals[idx].to_normalized(); + normals.push([x, y, z]); + } + // Inner at +½ in both axes (qb4): `((c+½)/8, (r+½)/8)`. + uvs.push([(col as f32 + 0.5) / 8.0, (row as f32 + 0.5) / 8.0]); + } + } + } + + // 4-triangle center-fan per cell, hole-aware (B1 + B2). For cell (r, c) ∈ 0..8²: + // TL = outer(r,c) @ r·17+c TR = outer(r,c+1) @ r·17+c+1 + // BL = outer(r+1,c) @ (r+1)·17+c BR = outer(r+1,c+1) @ (r+1)·17+c+1 + // CTR = inner(r,c) @ r·17+9+c + // Fan winding (CCW from above, +Z normal in WoW = front-face up in Bevy under the det-+1 + // transform; matches the existing 2-tri winding the engine already accepts). + // Holes: u16 @ MCNK+0x3C, bit `n=(r>>1)·4+(c>>1)` set ⇒ omit the 2×2 block of fans (verts + // remain — unreferenced is fine). 1.12 has no high-res holes. + let holes = mcnk.header.holes_low_res; + let mut indices = Vec::with_capacity(8 * 8 * 12); + for row in 0..8u32 { + for col in 0..8u32 { + if holes & (1u16 << ((row >> 1) * 4 + (col >> 1))) != 0 { + continue; + } + let tl = row * 17 + col; + let tr = tl + 1; + let bl = tl + 17; + let br = bl + 1; + let ctr = row * 17 + 9 + col; + indices.extend_from_slice(&[ + ctr, tl, bl, // west fan + ctr, bl, br, // south fan + ctr, br, tr, // east fan + ctr, tr, tl, // north fan + ]); + } + } + + // All layers (layer 0 first): texture name (resolved through MTEX) + its ground-effect id, + // kept together so the two parallel vecs stay index-aligned even if a layer is dropped. + let layers: Vec<(String, u32)> = mcnk + .layers + .as_ref() + .map(|mcly| { + mcly.layers + .iter() + .filter_map(|layer| { + root.textures + .get(layer.texture_id as usize) + .cloned() + .map(|t| (t, layer.effect_id)) + }) + .collect() + }) + .unwrap_or_default(); + let layer_textures: Vec = layers.iter().map(|(t, _)| t.clone()).collect(); + let layer_effect_ids: Vec = layers.iter().map(|(_, e)| *e).collect(); + let base_texture = layer_textures.first().cloned(); + + // Pack layers 1..3 alpha into one RGBA map for the splat shader. Vanilla uses 4-bit + // (small) alpha maps and the renderer convention of duplicating the last row/col + // (`fix_alpha`); big-alpha (8-bit) was introduced post-1.12. + let alpha_map = (layer_textures.len() > 1).then(|| { + CombinedAlphaMap::new( + mcnk, /* has_big_alpha */ false, /* fix_alpha */ true, + ) + .as_slice() + .to_vec() + }); + + // MCSH baked shadow → one byte per texel (255 = shadowed, 0 = lit), row-major over the + // chunk's 0..1 UV. `is_shadowed(x, y)` unpacks the 1-bit/texel, LSB-first map for us. + let shadow = mcnk.shadow.as_ref().map(|sh| { + let n = SHADOW_MAP_SIZE as usize; + let mut out = vec![0u8; n * n]; + for y in 0..n { + for x in 0..n { + if sh.is_shadowed(x, y) { + out[y * n + x] = 255; + } + } + } + out + }); + + // Authored ground-effect placement grids (see `ChunkMesh::pred_tex`). wow-adt mislabels + // these for vanilla, but the raw bytes survive: predominantTexture (0x40-0x4F) = + // `pred_tex` ++ `no_effect_doodad`; the real noEffectDoodad (0x50-0x57) = `unknown_8bytes`. + // Decode per wowdev ADT/v18: predTex = 2 bits/cell, noEffect = 1 bit/cell, both LSB-first + // row-major (`k = row*8 + col`), confirmed against the alpha map by `examples/clutter_probe`. + let h = &mcnk.header; + let mut pt16 = [0u8; 16]; + pt16[..8].copy_from_slice(&h.pred_tex); + pt16[8..].copy_from_slice(&h.no_effect_doodad); + let ned8 = h.unknown_8bytes; + let mut pred_tex = [0u8; 64]; + let mut no_effect_doodad = [false; 64]; + for k in 0..64 { + pred_tex[k] = (pt16[k / 4] >> (2 * (k % 4))) & 0x3; + no_effect_doodad[k] = (ned8[k / 8] >> (k % 8)) & 0x1 == 1; + } + + // MCLQ liquid surface (lake/river/ocean), built on the same lattice as this chunk's + // terrain. `None` for dry chunks and (for now) magma/slime. See `crate::liquid`. + let liquid = mcnk + .liquid + .as_ref() + .and_then(|mclq| crate::liquid::build_liquid_mesh(mclq, h.position)); + + chunks.push(ChunkMesh { + positions, + normals, + uvs, + indices, + holes, + base_texture, + layer_textures, + layer_effect_ids, + alpha_map, + shadow, + pred_tex, + no_effect_doodad, + index_x: h.index_x, + index_y: h.index_y, + area_id: h.area_id, + liquid, + }); + } + + if chunks.is_empty() { + anyhow::bail!("ADT produced no terrain chunks (no MCVT height data?)"); + } + + // Doodad placements. MDDF positions are in a corner-origin space; convert to WoW world coords + // (verified against the tile's MCNK position range): X = 32·TILE − mddf.z, Y = 32·TILE − + // mddf.x, Z = mddf.y. Scale is stored ×1024. `name_id` indexes the MMDX list directly. + const MAP_CENTER: f32 = 32.0 * TILE_SIZE; + let doodads = root + .doodad_placements + .iter() + .filter_map(|d| { + let model = root.models.get(d.name_id as usize)?.clone(); + Some(Doodad { + model, + position: [ + MAP_CENTER - d.position[2], + MAP_CENTER - d.position[0], + d.position[1], + ], + rotation: d.rotation, + scale: d.scale as f32 / 1024.0, + unique_id: d.unique_id, + }) + }) + .collect(); + + // WMO (building) placements — same MODF→world transform as doodads; scale is unused in vanilla. + let wmos = root + .wmo_placements + .iter() + .filter_map(|w| { + let model = root.wmos.get(w.name_id as usize)?.clone(); + Some(WmoInstance { + model, + position: [ + MAP_CENTER - w.position[2], + MAP_CENTER - w.position[0], + w.position[1], + ], + rotation: w.rotation, + unique_id: w.unique_id, + doodad_set: w.doodad_set, + name_set: w.name_set, + }) + }) + .collect(); + + Ok(TileMesh { + chunks, + doodads, + wmos, + }) +} + +/// Read a map's WDT from the chain and parse it (vanilla/Classic layout). +fn read_wdt(chain: &mut Chain, map: &str) -> Result { + let path = format!("World\\Maps\\{map}\\{map}.wdt"); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading {path}"))?; + WdtReader::new(Cursor::new(bytes), WowVersion::Classic) + .read() + .map_err(|e| anyhow::anyhow!("parsing WDT {path}: {e}")) +} + +fn tile_exists(wdt: &WdtFile, x: u32, y: u32) -> bool { + wdt.get_tile(x as usize, y as usize) + .is_some_and(|t| t.has_adt) +} + +/// Find an existing ADT tile near a world (x, y) on `map`, spiraling out if the exact tile +/// is missing. Returns `(tile_x, tile_y)` matching the `Map__.adt` naming. +pub fn find_tile_near( + chain: &mut Chain, + map: &str, + world_x: f32, + world_y: f32, +) -> Result<(u32, u32)> { + let wdt = read_wdt(chain, map)?; + let (tx, ty) = benilla_wdt::world_to_tile(world_x, world_y); + if tile_exists(&wdt, tx, ty) { + return Ok((tx, ty)); + } + for r in 1..=4i32 { + for dy in -r..=r { + for dx in -r..=r { + let (nx, ny) = (tx as i32 + dx, ty as i32 + dy); + if (0..64).contains(&nx) + && (0..64).contains(&ny) + && tile_exists(&wdt, nx as u32, ny as u32) + { + return Ok((nx as u32, ny as u32)); + } + } + } + } + anyhow::bail!("no existing ADT tile near world ({world_x}, {world_y}) on map {map}") +} + +/// A map's tile index (its WDT), parsed once and kept so a renderer can stream tiles in and out as +/// the player moves — without re-reading the WDT every frame. +pub struct MapTiles { + map: String, + wdt: WdtFile, +} + +impl MapTiles { + /// Parse the map's WDT from the chain. + pub fn load(chain: &mut Chain, map: &str) -> Result { + let wdt = read_wdt(chain, map)?; + Ok(Self { + map: map.to_string(), + wdt, + }) + } + + /// The map name (for `load_tile_mesh`). + pub fn map(&self) -> &str { + &self.map + } + + /// The tile `(x, y)` containing a world `(x, y)`. + pub fn tile_at(&self, world_x: f32, world_y: f32) -> (u32, u32) { + benilla_wdt::world_to_tile(world_x, world_y) + } + + /// Coords of every **existing** ADT tile within `radius` tiles of a world `(x, y)`, **sorted + /// nearest-first** (by squared tile-grid distance to the centre tile). The streamer loads the + /// first not-yet-loaded entry, so nearest-first ⇒ the tile you're about to see in loads before the + /// far ones — no "drove past the boundary into an unloaded tile" pop-in. + pub fn existing_in_radius(&self, world_x: f32, world_y: f32, radius: u32) -> Vec<(u32, u32)> { + let (cx, cy) = benilla_wdt::world_to_tile(world_x, world_y); + let r = radius as i32; + let mut out: Vec<(i32, (u32, u32))> = Vec::new(); + for dy in -r..=r { + for dx in -r..=r { + let (tx, ty) = (cx as i32 + dx, cy as i32 + dy); + if (0..64).contains(&tx) + && (0..64).contains(&ty) + && tile_exists(&self.wdt, tx as u32, ty as u32) + { + out.push((dx * dx + dy * dy, (tx as u32, ty as u32))); + } + } + } + out.sort_by_key(|(d, _)| *d); + out.into_iter().map(|(_, t)| t).collect() + } +} + +/// Read and mesh one ADT tile from the chain. +pub fn load_tile_mesh(chain: &mut Chain, map: &str, tile_x: u32, tile_y: u32) -> Result { + let path = format!("World\\Maps\\{map}\\{map}_{tile_x}_{tile_y}.adt"); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading {path}"))?; + adt_to_tile_mesh(&bytes) +} + +/// Load every existing ADT tile within `radius` tiles of the world (x, y) on `map`. +/// +/// Returns `((tile_x, tile_y), tile)` for each tile that exists and parses. Meshes use +/// absolute world coords, so neighboring tiles abut seamlessly. +pub fn load_tiles_around( + chain: &mut Chain, + map: &str, + world_x: f32, + world_y: f32, + radius: u32, +) -> Result> { + let wdt = read_wdt(chain, map)?; + let (cx, cy) = benilla_wdt::world_to_tile(world_x, world_y); + let r = radius as i32; + + let mut out = Vec::new(); + for dy in -r..=r { + for dx in -r..=r { + let (tx, ty) = (cx as i32 + dx, cy as i32 + dy); + if !(0..64).contains(&tx) || !(0..64).contains(&ty) { + continue; + } + let (tx, ty) = (tx as u32, ty as u32); + if !tile_exists(&wdt, tx, ty) { + continue; + } + // Skip (rather than fail the whole load on) the occasional unparseable tile. + if let Ok(mesh) = load_tile_mesh(chain, map, tx, ty) { + out.push(((tx, ty), mesh)); + } + } + } + + if out.is_empty() { + anyhow::bail!("no loadable tiles within {radius} of world ({world_x}, {world_y}) on {map}"); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A one-chunk mesh with `positions`/`indices` and every other field inert — enough to exercise + /// the footprint + hole logic of [`ChunkMesh::height_at`]. + fn chunk(positions: Vec<[f32; 3]>, indices: Vec) -> ChunkMesh { + ChunkMesh { + positions, + normals: Vec::new(), + uvs: Vec::new(), + indices, + holes: 0, + base_texture: None, + layer_textures: Vec::new(), + layer_effect_ids: Vec::new(), + alpha_map: None, + shadow: None, + pred_tex: [0; 64], + no_effect_doodad: [false; 64], + index_x: 0, + index_y: 0, + area_id: 0, + liquid: None, + } + } + + #[test] + fn height_at_interpolates_inside_and_misses_holes_and_off_footprint() { + // NW corner at positions[0]; +X is south-distance, +Y is east-distance across the chunk. Two + // quads (four tris) tented to z=10 at the shared middle edge, with the SOUTH half of the chunk + // left UN-indexed — an MCNK hole (a cave mouth), verts present but no triangle references them. + let nw = [0.0, 0.0, 0.0]; + let mid_x = nw[0] - CHUNK_SIZE * 0.5; // half a chunk south + let far_x = nw[0] - CHUNK_SIZE; // the chunk's south edge + let far_y = nw[1] - CHUNK_SIZE; // the chunk's east edge + let positions = vec![ + nw, // 0: NW + [nw[0], far_y, 0.0], // 1: NE + [mid_x, nw[1], 10.0], // 2: mid-W (ridge) + [mid_x, far_y, 10.0], // 3: mid-E (ridge) + [far_x, nw[1], 0.0], // 4: SW + [far_x, far_y, 0.0], // 5: SE + ]; + // North half only (verts 0,1,2,3) — the south half (4,5) is the hole. + let indices = vec![0, 1, 2, 1, 3, 2]; + let c = chunk(positions, indices); + + // A column in the meshed north half interpolates up the ridge. + let quarter_x = nw[0] - CHUNK_SIZE * 0.25; + let z = c + .height_at([quarter_x, nw[1] - CHUNK_SIZE * 0.5, 0.0]) + .unwrap(); + assert!((z - 5.0).abs() < 0.01, "ridge midpoint ≈ 5, got {z}"); + // A column over the un-indexed south half — the hole — has no surface: the mine mouth. + assert_eq!( + c.height_at([far_x + 1.0, nw[1] - CHUNK_SIZE * 0.5, 0.0]), + None + ); + // A column past the chunk's east edge is off the footprint entirely. + assert_eq!(c.height_at([quarter_x, far_y - 5.0, 0.0]), None); + } +} diff --git a/crates/benilla-formats/src/transport_period.rs b/crates/benilla-formats/src/transport_period.rs new file mode 100644 index 00000000..431cf3c4 --- /dev/null +++ b/crates/benilla-formats/src/transport_period.rs @@ -0,0 +1,221 @@ +//! The real client's own MO_TRANSPORT cycle-period bookkeeping (`WoW.exe` `0x5f4cc0` + its +//! arc-time solver `0x5f9120`), transcribed step-for-step from `wow-5875-re`'s byte-verified +//! findings — the 2026-07-17 §5 gold validation reproduced **all nine** live transport paths' +//! server-sniff periods bit-exact by emulating this exact recipe natively +//! (`wow-5875-re` `system/object-layer/scratch/transport-anchor-timetable.md` §TU-B(5), +//! `tests/difftest/transport_period.rs`). The wire anchor is a raw server-uptime-scale clock, so +//! the `% period` amplifies any Δms by the whole cycle count — the period must be *exact*, not +//! close (decision 0438 §3). vmangos pins its DB periods to sniffs of THIS computation, so +//! matching the client is matching the server. +//! +//! Layout facts this transcription rests on (all byte-cited in wow-re): +//! - Legs split when the row's map changes OR the previous row has `Flags & 1`; **every** row of +//! the path lands in a leg — the leg's first and last points are Catmull-Rom guard points the +//! travel never lands on (curve eval samples segment `seg` over `P[seg..seg+4]`, interpolating +//! `P[seg+1] → P[seg+2]`; `n_seg = count − 3`, built only when `count > 3` — wow-re +//! `rf52-curve-construction.md`). +//! - Per-segment arc length = 20 sub-chords of the cubic eval, **f32-narrowing accumulate**, the +//! sample parameter itself an f32 stepped by 0.05 (`0x453760`); cumulative distance = f64 sum +//! of the f32 segment lengths (`0x453300`); the cached leg total narrows to f32 (`0x4532e0`'s +//! `fstp dword`). +//! - Span times: a span runs stop→stop *including leg start/end as mid-cruise boundaries* — the +//! first span charges one ramp (the decel into the first stop), interior spans two (the full +//! trapezoid), the final span one (the accel out); a leg with no stops is pure cruise `d/v`. +//! Each span's time is rounded to ms **individually** (`round_ftol`) and accumulated as +//! integer — the per-span rounding is exactly what a whole-path float accumulation misses. +//! - `period = Σ leg durations + Σ stop delays (Delay × 1000, integer)`; stops are counted once, +//! and a stop flag on a leg's *first* row is ignored (the point list is still empty when the +//! stop check runs — `0x5f4cc0`'s `local_1c != 0` gate). + +use crate::taxi::TaxiPathNode; + +/// The client's position-basis matrix `0xb05e10` — the uniform Catmull-Rom (tension 0.5) basis, +/// rows as Horner coefficients highest-degree-first (row `i` weights control point `P[seg+i]`). +/// Decoded from the static-init immediates at `0x453fa0` (wow-re §TU-B(3)). +const CR_BASIS: [[f32; 4]; 4] = [ + [-0.5, 1.0, -0.5, 0.0], + [1.5, -2.5, 0.0, 1.0], + [-1.5, 2.0, 0.5, 0.0], + [0.5, -0.5, 0.0, 0.0], +]; + +/// `0x453620` `BasisWeight`: the cubic basis polynomial in Horner form, f64-internal, returned +/// unrounded to the evaluator. +fn basis_weight(coeff: &[f32; 4], t: f32) -> f64 { + let t = f64::from(t); + let mut w = f64::from(coeff[0]); + for &c in &coeff[1..] { + w = w * t + f64::from(c); + } + w +} + +/// `0x453580` cubic point evaluator: `out = Σ wᵢ·Pᵢ` over 4 consecutive control points, with the +/// **asymmetric** per-component narrowing the bytes show — the x product stays f64 into its add, +/// the y/z products narrow to f32 first (`fstp dword` temp), and every component's accumulator +/// re-narrows to f32 each step (wow-re `curvemath/src/spline.rs::eval_point_body`). +fn eval_point_cubic(cps: &[[f32; 3]], t: f32) -> [f32; 3] { + let mut out = [0.0f32; 3]; + for (i, p) in cps.iter().take(4).enumerate() { + let w = basis_weight(&CR_BASIS[i], t); + out[0] = (w * f64::from(p[0]) + f64::from(out[0])) as f32; + let ty = (w * f64::from(p[1])) as f32; + let tz = (w * f64::from(p[2])) as f32; + out[1] = (f64::from(ty) + f64::from(out[1])) as f32; + out[2] = (f64::from(tz) + f64::from(out[2])) as f32; + } + out +} + +/// `0x453760` arc-length integrator: 20 sub-chords at an f32 parameter stepped by 0.05, chord +/// length `sqrt((dz²+dy²)+dx²)` f64-internal, the accumulator narrowing to f32 every step. +fn seg_arc_length(cps: &[[f32; 3]]) -> f32 { + let mut prev = eval_point_cubic(cps, 0.0); + let mut acc = 0.0f32; + let mut t = f32::from_bits(0x3d4c_cccd); // 0.05 + for _ in 0..20 { + let cur = eval_point_cubic(cps, t); + let dx = f64::from(cur[0]) - f64::from(prev[0]); + let dy = f64::from(cur[1]) - f64::from(prev[1]); + let dz = f64::from(cur[2]) - f64::from(prev[2]); + let len = ((dz * dz + dy * dy) + dx * dx).sqrt(); + acc = (len + f64::from(acc)) as f32; + t = (f64::from(t) + 0.05f64) as f32; + prev = cur; + } + acc +} + +/// `0x453300` knot-sum: the cumulative distance of point index `idx` from the leg's travel start +/// = the pure-f64 sum of the first `idx − 1` per-segment f32 lengths (the guard-point shift makes +/// this exactly point `idx`'s distance — segment 0 spans `P[1] → P[2]`). +fn knot_sum(seg_len: &[f32], idx: usize) -> f64 { + let mut acc = 0.0f64; + for &k in seg_len.iter().take(idx.saturating_sub(1)) { + acc += f64::from(k); + } + acc +} + +/// `·1000` then round-half-away-from-zero then truncate — the client's `__ftol` rounding idiom +/// (wow-re `object_layer/taxi_spline.rs::round_ftol`, diffed bit-exact). +fn round_ftol(t: f64) -> i32 { + let scaled = t * 1000.0; + let adj = if scaled > 0.0 { + scaled + 0.5 + } else { + scaled - 0.5 + }; + adj.trunc() as i32 +} + +/// `0x5f9120` per-span arc time (wow-re `taxi_spline.rs::arc_time_ms`, diffed bit-exact): the +/// constant-acceleration solve over the span's distance `d = p − l`, with the client's own +/// f32/f64 mixing (`A` and `B` stored f32, the discriminant compare on the *live* f64 `B`). +/// `first` = the leg's first span (one ramp: leg start is mid-cruise); otherwise two ramps. +fn arc_time_ms(p: f64, l: f32, speed: f32, accel: f32, first: bool) -> i32 { + let d = p - f64::from(l); + let a = (f64::from(speed) / f64::from(accel)) as f32; + let b_live = 0.5 * f64::from(speed) * f64::from(a); + let b = b_live as f32; + let t: f64 = if first { + if b_live < d { + (d - f64::from(b)) / f64::from(speed) + f64::from(a) + } else { + (2.0 * d / f64::from(accel)).sqrt() + } + } else if b_live < 0.5 * d { + (d - 2.0 * f64::from(b)) / f64::from(speed) + 2.0 * f64::from(a) + } else { + 2.0 * (d / f64::from(accel)).sqrt() + }; + round_ftol(t) +} + +/// `0x5f9120` block 2, the leg's final span (`taxi_spline.rs::arc_time_ms_final`): with no stop +/// processed the whole leg is pure cruise `d/v` (no ramps — both ends are mid-cruise); after a +/// stop it's the one-ramp form (the accel out of the last stop). +fn arc_time_ms_final(p: f32, l: f32, speed: f32, accel: f32, first: bool) -> i32 { + let d = f64::from(p) - f64::from(l); + let t: f64 = if first { + d / f64::from(speed) + } else { + let a = (f64::from(speed) / f64::from(accel)) as f32; + let b_live = 0.5 * f64::from(speed) * f64::from(a); + if b_live < d { + (d - f64::from(b_live as f32)) / f64::from(speed) + f64::from(a) + } else { + (2.0 * d / f64::from(accel)).sqrt() + } + }; + round_ftol(t) +} + +/// One closed leg's contribution: `(span-time sum, Σ its stop delays)`. +fn close_leg(points: &[[f32; 3]], stops: &[(usize, i32)], speed: f32, accel: f32) -> (i32, i32) { + // BuildArcLen only runs for count > 3 (`0x4532e0`'s gate); a shorter leg keeps a zero + // seg-table and zero cached total, so every span solves over d = 0. + let (seg_len, total) = if points.len() > 3 { + let n_seg = points.len() - 3; + let seg_len: Vec = (0..n_seg).map(|s| seg_arc_length(&points[s..])).collect(); + let total = seg_len.iter().map(|&l| f64::from(l)).sum::() as f32; // fstp dword + (seg_len, total) + } else { + (Vec::new(), 0.0f32) + }; + + let mut duration = 0i32; + let mut delays = 0i32; + let mut l = 0.0f32; // the running span-start distance ([ebp+0x10], an f32 slot) + let mut processed = 0usize; + for &(pt_idx, delay_ms) in stops { + delays += delay_ms; + // A stop on the leg's last point (or the guard) has no span of its own (`0x5f9120`'s + // `count − 1 <= idx` break) — its delay still counts in the period. + if pt_idx + 1 >= points.len() { + break; + } + let p = knot_sum(&seg_len, pt_idx); + duration += arc_time_ms(p, l, speed, accel, processed == 0); + l = p as f32; // the solver's `fst dword` — the next span's start narrows to f32 + processed += 1; + } + duration += arc_time_ms_final(total, l, speed, accel, processed == 0); + (duration, delays) +} + +/// The full client period for one transport path, ms — `0x5f4cc0`'s `handler+0x3c`, bit-exact +/// (gold-gated against the nine server-sniff values by `transports.rs`' calibration test). +/// `None` for an empty path. +pub(crate) fn client_period_ms(nodes: &[TaxiPathNode], speed: f32, accel: f32) -> Option { + if nodes.is_empty() || accel <= 0.0 { + return None; + } + let mut period = 0i64; + let mut points: Vec<[f32; 3]> = Vec::new(); + let mut stops: Vec<(usize, i32)> = Vec::new(); + let mut leg_map = nodes[0].map_id; + let mut prev_teleport = false; + for node in nodes { + if node.map_id != leg_map || prev_teleport { + let (dur, delays) = close_leg(&points, &stops, speed, accel); + period += i64::from(dur) + i64::from(delays); + points.clear(); + stops.clear(); + leg_map = node.map_id; + } + // The stop check runs before the point append: a stop flag on the leg's first row is + // ignored (`0x5f4cc0`, the `local_1c != 0` gate). The client tests `Flags & 2` as a + // bitmask (`0x5f4e37`) and multiplies the delay as an integer. + if node.flags & 2 != 0 && !points.is_empty() { + stops.push((points.len(), node.delay as i32 * 1000)); + } + points.push(node.pos); + prev_teleport = node.flags & 1 != 0; + } + if !points.is_empty() { + let (dur, delays) = close_leg(&points, &stops, speed, accel); + period += i64::from(dur) + i64::from(delays); + } + u32::try_from(period).ok() +} diff --git a/crates/benilla-formats/src/transports/mod.rs b/crates/benilla-formats/src/transports/mod.rs new file mode 100644 index 00000000..d2fec9e5 --- /dev/null +++ b/crates/benilla-formats/src/transports/mod.rs @@ -0,0 +1,826 @@ +//! `MO_TRANSPORT` timetable builder + cycle sampler (decision 0438 phase 0) — pure math, no Bevy, +//! **WoW coordinates throughout** (the Bevy transform is the consumer's job). +//! +//! This transcribes the structural skeleton of vmangos's own transport-path builder and mover: +//! - `TransportMgr::GenerateWaypoints` (`/Users/sam/wre/vmangos-src/src/game/Transports/ +//! TransportMgr.cpp:105-334`) — the keyframe list, the map-change/teleport skip dance, the +//! per-leg Catmull-Rom splines, the `DistSinceStop`/`DistUntilStop` modular walks, the +//! four-regime `TimeTo` trapezoid, and the `ArriveTime`/`DepartureTime` accumulation. +//! - `ShipTransport::Update` + `CalculateSegmentPos` (`.../Transport.cpp:283-380`) — the +//! stateless "where on the path is `progress % period`" query, [`TransportTimetable::sample`]. +//! - The spline machinery (`.../Movement/spline/spline.cpp`): `s_catmullRomCoeffs` (:61-65) is +//! transcribed as closed-form blending polynomials in [`catmull_rom_weights`] / +//! [`catmull_rom_deriv_weights`] (verified by hand-expanding the `Vector4 * Matrix4` row-vector +//! product — standard Catmull-Rom basis functions, tau = 0.5); `SegLengthCatmullRom` (:159-177, +//! `STEPS_PER_SEGMENT` chord samples) is [`Leg::seg_length`]; `InitCatmullRom`'s non-cyclic +//! virtual endpoints (:238-268 — the **first** virtual point is `lerp(c0, c1, -1)`, i.e. +//! `2·c0 − c1`; the **last** is a plain **duplicate** of the final real control, not +//! extrapolated) are [`Leg::control`]. +//! +//! **The whole-path orientation spline collapses to a closed form.** `GenerateWaypoints` builds a +//! second, separate Catmull-Rom spline over the *entire* raw path (with hand-computed virtual +//! endpoints, `TransportMgr.cpp:120-127`) purely to read `InitialOrientation` off +//! `evaluate_derivative(i, t=0)` for every kept node. Expanding `s_catmullRomCoeffs`' derivative +//! weights at `t=0` gives `[-0.5, 0, 0.5, 0]` — only the two *interior* control points survive (the +//! virtual endpoints' weight is always zero at `t=0`, and are in fact never even reached within the +//! nodes this loop visits). So `InitialOrientation(i) = atan2(path[i+1].y − path[i−1].y, +//! path[i+1].x − path[i−1].x) + π`, using the **raw, unfiltered** node array — no spline object +//! needed. See `orientation_at` below. +//! +//! **Timing-mode calibration is the point of this module.** vmangos's own computed periods do +//! **not** match the real client's — the server DB-overrides them (`transports` table, +//! `TransportMgr.cpp:63-79`, "load period override from db since our algorithm is not perfect"). +//! The client's per-span arc-length time is transcribed bit-exact in `wow-5875-re` +//! (`crates/object-layer/src/taxi_spline.rs`: `arc_time_ms`, closed-form constant-acceleration +//! kinematics with `round_ftol` — ×1000, round-half-away-from-zero, truncate). [`TimeMode`] (private — +//! not public API) selects between vmangos's own f32-seconds accumulation (`Mode::Vmangos`, +//! transcribing `TransportMgr.cpp:297-325` exactly) and a client-closed-form per-span accumulation +//! (`Mode::ClientForms`); the arc-length chord-sampling density (`STEPS_PER_SEGMENT` — vmangos uses +//! 3, its own comment notes "client's value is 20") is a second calibration knob. The `tests` +//! module builds all nine live transports under every (mode × steps) combination against the +//! server's actually-effective (DB-pinned) periods and reports a table — see decision 0438 phase 0. +//! +//! **The cycle length itself is past calibration:** [`TransportTimetable::build`] pins its period +//! to the real client's own bookkeeping, transcribed in [`crate::transport_period`] and gold-gated +//! bit-exact against all nine server-sniff values (the 2026-07-17 wow-re §5 verdict). The sample +//! table between the pins stays vmangos-mode (internally consistent windows + easing); porting the +//! client's true per-leg tick evaluation is the recorded follow-on. + +use std::f32::consts::PI; + +use crate::taxi::TaxiPathNode; + +/// A transport's position/heading at one instant of its cycle — WoW coordinates, ready for the +/// Bevy consumer's own coordinate transform. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransportSample { + /// The map this `pos` is expressed in (can differ from the timetable's "home" map — a + /// transport crosses continents mid-cycle). + pub map: u32, + /// World position `(x, y, z)`. + pub pos: [f32; 3], + /// Facing, radians, normalized to `[0, 2π)` — `atan2(dir.y, dir.x) + π` + /// (`Transport.cpp:349`). + pub heading: f32, + /// `false` while parked at a station stop (or the zero-width instant at a non-stop + /// keyframe); `true` while under way. + pub moving: bool, +} + +/// A single kept keyframe — one surviving `TaxiPathNode` after the map-change/teleport skip dance +/// (`TransportMgr.cpp:128-152`), with every quantity [`TransportTimetable::sample`] needs from +/// `GenerateWaypoints`. `IsStopFrame`/`Teleport` themselves aren't stored: `sample` recovers both +/// from the derived timestamps/distances alone (a non-stop frame's `[arrive, depart)` window is +/// always zero-width; a teleport frame's `next_dist_from_prev` is always `0` — see their doc +/// comments below) rather than carrying redundant state. +#[derive(Debug, Clone)] +struct Frame { + map_id: u32, + pos: [f32; 3], + initial_orientation: f32, + dist_since_stop: f32, + dist_until_stop: f32, + /// The distance of the segment from this frame *to the next* — `0` for a teleport frame (no + /// continuous travel across a jump) and for the path's final frame. + next_dist_from_prev: f32, + time_from: f32, + time_to: f32, + departure_time: u32, + next_arrive_time: u32, + /// Which [`Leg`] (and which 0-based control-point position within it) this frame's outbound + /// spline segment is evaluated on. + leg: usize, + local_index: usize, +} + +/// A raw kept node, pre-derived-quantities — the map-change dance's output, source data for +/// [`Frame`] and both timing modes. +#[derive(Debug, Clone, Copy)] +struct RawFrame { + map_id: u32, + pos: [f32; 3], + is_stop: bool, + delay_secs: f32, + initial_orientation: f32, + teleport: bool, +} + +/// One spline leg: a maximal run of keyframes between teleport boundaries (a leg's last member is +/// always a teleport frame, or the path's final frame). Non-cyclic Catmull-Rom +/// (`InitCatmullRom`'s non-cyclic branch) over `controls`, with virtual endpoints computed +/// on-demand by [`Leg::control`] rather than stored. +#[derive(Debug, Clone)] +struct Leg { + controls: Vec<[f32; 3]>, +} + +impl Leg { + /// The control point at raw (possibly out-of-range) index `i`, extending with vmangos's + /// non-cyclic virtual endpoints: `i < 0` → `2·c0 − c1` (extrapolated); `i >= len` → a + /// **duplicate** of the last real control (not extrapolated) — `spline.cpp:262-265`. + fn control(&self, i: isize) -> [f32; 3] { + let n = self.controls.len(); + if i < 0 { + let c0 = self.controls[0]; + let c1 = if n > 1 { self.controls[1] } else { c0 }; + lerp(c0, c1, -1.0) + } else if (i as usize) >= n { + self.controls[n - 1] + } else { + self.controls[i as usize] + } + } + + /// Position on segment `k` (0-based, connecting `controls[k]` to `controls[k+1]`) at `t ∈ + /// [0, 1]` — `SplineBase::EvaluateCatmullRom`. + fn evaluate_percent(&self, k: usize, t: f32) -> [f32; 3] { + catmull_rom_eval( + self.control(k as isize - 1), + self.control(k as isize), + self.control(k as isize + 1), + self.control(k as isize + 2), + t, + ) + } + + /// Derivative on segment `k` at `t` — `SplineBase::EvaluateDerivativeCatmullRom`. + fn evaluate_derivative(&self, k: usize, t: f32) -> [f32; 3] { + catmull_rom_derivative( + self.control(k as isize - 1), + self.control(k as isize), + self.control(k as isize + 1), + self.control(k as isize + 2), + t, + ) + } + + /// Arc length of segment `k`, approximated by `steps` evenly-spaced chords — + /// `SplineBase::SegLengthCatmullRom` (`spline.cpp:159-177`), `length_type` is `double` there + /// (`Movement::Spline`), matched here. + fn seg_length(&self, k: usize, steps: u32) -> f64 { + let mut cur = self.control(k as isize); // t=0 evaluates to exactly controls[k] + let mut total = 0.0f64; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let next = self.evaluate_percent(k, t); + total += dist64(cur, next); + cur = next; + } + total + } +} + +fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] { + [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + ] +} + +fn dist64(a: [f32; 3], b: [f32; 3]) -> f64 { + let (dx, dy, dz) = ( + f64::from(a[0] - b[0]), + f64::from(a[1] - b[1]), + f64::from(a[2] - b[2]), + ); + (dx * dx + dy * dy + dz * dz).sqrt() +} + +/// The vmangos `s_catmullRomCoeffs` matrix (`spline.cpp:61-65`), expanded by hand as the `t³ t² t +/// 1` row-vector times the matrix (`C_Evaluate`'s `Vector4 weights(tvec * matr)`) into the four +/// classic Catmull-Rom (τ = 0.5) blending polynomials — verified by direct expansion, not assumed. +fn catmull_rom_weights(t: f32) -> [f32; 4] { + let (t2, t3) = (t * t, t * t * t); + [ + -0.5 * t3 + t2 - 0.5 * t, + 1.5 * t3 - 2.5 * t2 + 1.0, + -1.5 * t3 + 2.0 * t2 + 0.5 * t, + 0.5 * t3 - 0.5 * t2, + ] +} + +/// `d/dt` of [`catmull_rom_weights`] — `C_Evaluate_Derivative`'s `3t² 2t 1 0` row vector times the +/// same matrix. +fn catmull_rom_deriv_weights(t: f32) -> [f32; 4] { + let t2 = t * t; + [ + -1.5 * t2 + 2.0 * t - 0.5, + 4.5 * t2 - 5.0 * t, + -4.5 * t2 + 4.0 * t + 0.5, + 1.5 * t2 - t, + ] +} + +fn blend(p0: [f32; 3], p1: [f32; 3], p2: [f32; 3], p3: [f32; 3], w: [f32; 4]) -> [f32; 3] { + [ + p0[0] * w[0] + p1[0] * w[1] + p2[0] * w[2] + p3[0] * w[3], + p0[1] * w[0] + p1[1] * w[1] + p2[1] * w[2] + p3[1] * w[3], + p0[2] * w[0] + p1[2] * w[1] + p2[2] * w[2] + p3[2] * w[3], + ] +} + +fn catmull_rom_eval(p0: [f32; 3], p1: [f32; 3], p2: [f32; 3], p3: [f32; 3], t: f32) -> [f32; 3] { + blend(p0, p1, p2, p3, catmull_rom_weights(t)) +} + +fn catmull_rom_derivative( + p0: [f32; 3], + p1: [f32; 3], + p2: [f32; 3], + p3: [f32; 3], + t: f32, +) -> [f32; 3] { + blend(p0, p1, p2, p3, catmull_rom_deriv_weights(t)) +} + +/// Wrap to `[0, 2π)` — `Geometry::NormalizeOrientation`. +fn normalize_orientation(o: f32) -> f32 { + let tau = std::f32::consts::TAU; + let wrapped = o % tau; + if wrapped < 0.0 { + wrapped + tau + } else { + wrapped + } +} + +/// The timing-accumulation variant — see the module doc's "Timing-mode calibration". Not public +/// API: [`TransportTimetable::build`] picks one default; the calibration test in this module's +/// `#[cfg(test)]` sweeps all of them, including `Vmangos` — hence the `cfg_attr` below: outside +/// `cargo test`, `build`'s hardcoded default is the only caller, so plain `cargo build` never +/// constructs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimeMode { + /// vmangos's own `TransportMgr.cpp:297-325`: a running `f32` seconds accumulator, truncated + /// to ms once per keyframe. **The sample-table default** — windows and easing from one + /// accumulation (see [`TransportTimetable::build`]). + Vmangos, + /// The real client's constant-acceleration closed form (`wow-5875-re`'s `taxi_spline`), + /// applied once per stop-to-stop span and rounded with `round_ftol`, accumulated as integer + /// ms. Interior timestamps are distance-linear — a period-calibration device (the golden + /// sweep), NOT a sampling table; constructed only by the calibration tests. + #[cfg_attr(not(test), allow(dead_code))] + ClientForms, +} + +/// `·1000.0` then round-half-away-from-zero then truncate — `wow-5875-re`'s +/// `object_layer::taxi_spline::round_ftol` (the client's own `__ftol` rounding idiom, `fcom +/// 0.0;test ah,0x41` → `±0.5`), transcribed exactly. +fn round_ftol(t: f64) -> i32 { + let scaled = t * 1000.0; + let adj = if scaled > 0.0 { + scaled + 0.5 + } else { + scaled - 0.5 + }; + adj.trunc() as i32 +} + +/// The client's "both ramps present" closed form for a stop-to-stop span of distance `d_span` +/// (`wow-5875-re::object_layer::taxi_spline::arc_time_ms`'s `!first` branch: `if 2B < D { (D − +/// 2B)/v + 2A } else { 2·√(D/a) }`, `A = v/a` stored `f32`, `B = ½·v·A` kept live `f64` for the +/// branch compare but stored `f32` for the arithmetic — the exact f32/f64 mixing `taxi_spline.rs` +/// diffs), rounded to ms via [`round_ftol`]. +fn span_time_ms(d_span: f32, speed: f32, accel: f32) -> u32 { + if accel <= 0.0 { + return 0; + } + let d = f64::from(d_span); + let v = f64::from(speed); + let a_param = f64::from(accel); + let a: f32 = (v / a_param) as f32; // A, stored f32 (the client's own precision loss) + let b_live = 0.5 * v * f64::from(a); // B kept live (f64) — the branch compare uses this + let b: f32 = b_live as f32; // B stored f32 — the arithmetic reads this + let t: f64 = if b_live < 0.5 * d { + (d - 2.0 * f64::from(b)) / v + 2.0 * f64::from(a) + } else { + 2.0 * (d / a_param).sqrt() + }; + round_ftol(t).max(0) as u32 +} + +fn delay_ms(secs: f32) -> u32 { + (secs * 1000.0).round().max(0.0) as u32 +} + +/// Mode V: vmangos's own accumulation, transcribed exactly (`TransportMgr.cpp:297-325`) — +/// including the "add the previous frame's `TimeTo`, then subtract this frame's own `TimeTo` back +/// off when it isn't a stop" pattern (the running-estimate-plus-correction trick that gives +/// continuous-looking per-frame timestamps from a per-frame "time to the far stop" quantity). +fn accumulate_vmangos(raws: &[RawFrame], time_to: &[f32]) -> (Vec, Vec, Vec, u32) { + let n = raws.len(); + let mut arrive = vec![0u32; n]; + let mut depart = vec![0u32; n]; + let mut next_arrive = vec![0u32; n]; + + let mut cur_path_time = 0.0f32; + if raws[0].is_stop { + cur_path_time = raws[0].delay_secs; + depart[0] = (cur_path_time * 1000.0) as u32; + } + for i in 1..n { + cur_path_time += time_to[i - 1]; + if raws[i].is_stop { + arrive[i] = (cur_path_time * 1000.0) as u32; + next_arrive[i - 1] = arrive[i]; + cur_path_time += raws[i].delay_secs; + depart[i] = (cur_path_time * 1000.0) as u32; + } else { + cur_path_time -= time_to[i]; + arrive[i] = (cur_path_time * 1000.0) as u32; + next_arrive[i - 1] = arrive[i]; + depart[i] = arrive[i]; + } + } + next_arrive[n - 1] = depart[n - 1]; + let period = depart[n - 1]; + (arrive, depart, next_arrive, period) +} + +/// Mode C: per-stop-to-stop-span time from the client's closed form ([`span_time_ms`]), +/// accumulated as integer ms; delays enter as exact `delay·1000`. A span's *boundary* timestamps +/// (at its two stop frames) are exact by construction — that's what the golden gate needs. +/// Interior (non-stop) frame timestamps are apportioned by cumulative distance within the span, a +/// phase-0 simplification: only the period (a boundary quantity) is gated this round; the +/// client's true per-point interior scheme is phase-1 work (see the module doc). +/// +/// **The path's own start/end fragments (frame 0 to the first stop; the last stop to the final +/// frame) are not self-contained spans** — frame 0 and the final frame are both mid-cruise, not +/// at rest (the path is cyclic: the true journey between the *last* stop and the *first* stop +/// runs last_stop → final frame → [the period wrap, zero distance] → frame 0 → first_stop, one +/// continuous two-ramp span). Treating the two fragments as independent fresh spans (each +/// wrongly assumed to start/end at rest) double-charges roughly one full `accel_time` across the +/// period — the ~30 s-off-of-30 s-`accel_time` bug this comment is pinned against. `leading_dist` +/// / `trailing_dist` / `d_wrap` below reconstruct that one true wrap span; both fragments are +/// timed off its single [`span_time_ms`] total, at their own distance fraction of it. +fn accumulate_client_forms( + raws: &[RawFrame], + dist_from_prev: &[f32], + speed: f32, + accel: f32, +) -> (Vec, Vec, Vec, u32) { + let n = raws.len(); + let mut arrive = vec![0u32; n]; + let mut depart = vec![0u32; n]; + let mut next_arrive = vec![0u32; n]; + + let stops: Vec = raws + .iter() + .enumerate() + .filter(|(_, r)| r.is_stop) + .map(|(i, _)| i) + .collect(); + + if stops.is_empty() { + // Degenerate (no station stop anywhere on the path) — not expected for a real transport; + // a defensive fallback treating the whole cyclic path as one wrap span, no delays. + let d_total: f32 = dist_from_prev[1..].iter().sum(); + let t_total = i64::from(span_time_ms(d_total, speed, accel)); + let mut cum = 0.0f32; + for k in 1..n { + cum += dist_from_prev[k]; + let frac = if d_total > 0.0 { + f64::from(cum / d_total) + } else { + 1.0 + }; + arrive[k] = (frac * t_total as f64).round().max(0.0) as u32; + depart[k] = arrive[k]; + } + if n > 1 { + next_arrive[..n - 1].copy_from_slice(&arrive[1..n]); + } + if n > 0 { + next_arrive[n - 1] = depart[n - 1]; + } + let period = depart[n.saturating_sub(1)]; + return (arrive, depart, next_arrive, period); + } + + let first_stop = stops[0]; + let last_stop = *stops.last().unwrap(); + + let leading_dist: f32 = dist_from_prev[1..=first_stop].iter().sum(); + let trailing_dist: f32 = dist_from_prev[last_stop + 1..].iter().sum(); + let d_wrap = leading_dist + trailing_dist; + let t_wrap = i64::from(span_time_ms(d_wrap, speed, accel)); + + // Leading fragment (frame 0 is clock-zero by definition; arrive[0] stays its `vec![0; n]` + // default): every frame up to and including first_stop, at the wrap span's rate. + let mut cum = 0.0f32; + for k in 1..=first_stop { + cum += dist_from_prev[k]; + let frac = if d_wrap > 0.0 { + f64::from(cum / d_wrap) + } else { + 1.0 + }; + arrive[k] = (frac * t_wrap as f64).round().max(0.0) as u32; + } + depart[first_stop] = arrive[first_stop] + delay_ms(raws[first_stop].delay_secs); + + // Interior stop-to-stop spans, in order — each is a genuine, self-contained two-ramp span. + for w in stops.windows(2) { + let (b_prev, b_next) = (w[0], w[1]); + let depart_prev = i64::from(depart[b_prev]); + let d_span: f32 = dist_from_prev[b_prev + 1..=b_next].iter().sum(); + let span_ms = i64::from(span_time_ms(d_span, speed, accel)); + let mut cum = 0.0f32; + for k in (b_prev + 1)..=b_next { + cum += dist_from_prev[k]; + let frac = if d_span > 0.0 { + f64::from(cum / d_span) + } else { + 1.0 + }; + let arrive_k = (depart_prev + (frac * span_ms as f64).round() as i64).max(0) as u32; + arrive[k] = arrive_k; + depart[k] = arrive_k + delay_ms(raws[k].delay_secs); + } + } + + // Trailing fragment (last_stop to the final frame) — the wrap span's tail, same rate. + let depart_last = i64::from(depart[last_stop]); + let mut cum = 0.0f32; + for k in (last_stop + 1)..n { + cum += dist_from_prev[k]; + let frac = if d_wrap > 0.0 { + f64::from(cum / d_wrap) + } else { + 1.0 + }; + let arrive_k = (depart_last + (frac * t_wrap as f64).round() as i64).max(0) as u32; + arrive[k] = arrive_k; + depart[k] = arrive_k; // no stop follows last_stop by construction + } + + if n > 1 { + next_arrive[..n - 1].copy_from_slice(&arrive[1..n]); + } + next_arrive[n - 1] = depart[n - 1]; + let period = depart[n - 1]; + (arrive, depart, next_arrive, period) +} + +/// A `MO_TRANSPORT`'s full cyclic timetable, built once per `taxiPathId` — the timekeeping half of +/// decision 0438 phase 0. See the module doc for provenance. +#[derive(Debug, Clone)] +pub struct TransportTimetable { + /// The full cycle length, ms — `progress % period_ms` is the transport's position in its + /// loop (decision 0438 §3). + pub period_ms: u32, + frames: Vec, + legs: Vec, + move_speed: f32, + accel_rate: f32, + accel_time: f32, + accel_dist: f32, +} + +impl TransportTimetable { + /// Build a transport's timetable from its `TaxiPathNode` rows (sorted by `node_index`) and + /// its `gameobject_template` `data1`/`data2` (`moveSpeed`/`accelRate`, as `f32`). `None` if + /// the path is too short to have an interior (fewer than 3 nodes — the first and last are + /// always teleport-arrival cells with no travel of their own). + pub fn build(nodes: &[TaxiPathNode], move_speed: f32, accel_rate: f32) -> Option { + // Vmangos-mode accumulation for the SAMPLE table: its u32 windows and the float + // trapezoid easing derive from one accumulation, so `sample()` is internally consistent. + // ClientForms apportions interior timestamps linearly by distance (a period-calibration + // device, per its doc) — feeding those windows to the trapezoid easing made the boat + // stick at segment ends and leap when the window flipped (the director's "ship lagged + // into Booty Bay", measured at up to 15,000 yd/s spikes by the speed-profile probe). + // Arc sampling stays at the client's 20 chord steps (`spline.h:61`). + let mut tt = + Self::build_with_variant(nodes, move_speed, accel_rate, TimeMode::Vmangos, 20)?; + // The cycle LENGTH self-pins to the real client's own bookkeeping (`transport_period`, + // gold-gated bit-exact against all nine server-sniff periods — the 2026-07-17 wow-re §5 + // verdict). The wire anchor is a raw server-uptime-scale clock, so `% period` amplifies + // any Δms by the whole cycle count; vmangos pins its DB periods to sniffs of the same + // client computation, so this IS the server's period — no consumer-side table needed. + if let Some(period) = + crate::transport_period::client_period_ms(nodes, move_speed, accel_rate) + { + tt.override_period(period); + } + Some(tt) + } + + /// Whether any of the cycle's keyframes lies on `map_id` — i.e. this transport exists on + /// that map for part of its loop. The cross-map worldport (decision 0455) keeps such a + /// transport alive through the map switch (its clock is one continuous domain over the + /// whole loop; the per-frame tick already resolves which legs render on which map). + pub fn touches_map(&self, map_id: u32) -> bool { + self.frames.iter().any(|f| f.map_id == map_id) + } + + /// The calibration entry point (not public API — see the module doc). Mirrors + /// `TransportMgr::GenerateWaypoints` phase for phase; each phase is commented with its + /// vmangos source line range. + fn build_with_variant( + nodes: &[TaxiPathNode], + move_speed: f32, + accel_rate: f32, + mode: TimeMode, + arc_steps: u32, + ) -> Option { + let n_raw = nodes.len(); + if n_raw < 3 || accel_rate <= 0.0 { + return None; + } + + // Phase A: the map-change/teleport skip dance (TransportMgr.cpp:128-152). First/last raw + // nodes are teleport-arrival cells, never visited (loop bound `1..n_raw-1`). + let mut raws: Vec = Vec::new(); + let mut map_change = false; + for i in 1..n_raw - 1 { + if map_change { + map_change = false; + continue; + } + let (node, next) = (&nodes[i], &nodes[i + 1]); + if node.flags & 1 != 0 || node.map_id != next.map_id { + if let Some(last) = raws.last_mut() { + last.teleport = true; + } + map_change = true; + } else { + // The whole-path orientation spline collapses to a central difference at t=0 + // (module doc) — using the RAW, unfiltered neighbors, regardless of whether they + // themselves survive the skip dance. + let (prev, nxt) = (nodes[i - 1].pos, nodes[i + 1].pos); + let initial_orientation = + normalize_orientation((nxt[1] - prev[1]).atan2(nxt[0] - prev[0]) + PI); + raws.push(RawFrame { + map_id: node.map_id, + pos: node.pos, + // The CLIENT tests the stop flag as a bitmask (`test byte[row+0x1c], 0x2` + // @0x5f4e37 — the 2026-07-17 wow-re §5); vmangos's `IsStopFrame` uses exact + // `== 2`. Identical on the live data (flags ∈ {0, 2}) — we follow the client. + is_stop: node.flags & 2 != 0, + delay_secs: node.delay as f32, + initial_orientation, + teleport: false, + }); + } + } + if raws.is_empty() { + return None; + } + // Last to first is always "teleport", even for closed paths (GenerateWaypoints, after + // the main loop). + raws.last_mut().unwrap().teleport = true; + let n = raws.len(); + + // Phase B: legs — a new leg starts right after every teleport=true frame (a teleport + // frame is always the last member of its own leg, matching the vmangos `extra`-point + // dance without needing to replicate its raw-pointer bookkeeping — see the module doc). + let mut leg_of = vec![0usize; n]; + let mut local_index_of = vec![0usize; n]; + let mut leg_controls: Vec> = vec![Vec::new()]; + { + let mut leg = 0usize; + for j in 0..n { + if j > 0 && raws[j - 1].teleport { + leg += 1; + leg_controls.push(Vec::new()); + } + leg_of[j] = leg; + local_index_of[j] = leg_controls[leg].len(); + leg_controls[leg].push(raws[j].pos); + } + } + let legs: Vec = leg_controls + .into_iter() + .map(|controls| Leg { controls }) + .collect(); + + // Phase D: DistFromPrev / NextDistFromPrev (TransportMgr.cpp:192-234) — arc length of the + // segment ending at (resp. starting at) each frame, `0` for a leg's first member (resp. + // teleport frames and the path's last frame). + let mut dist_from_prev = vec![0.0f32; n]; + for j in 0..n { + if local_index_of[j] > 0 { + dist_from_prev[j] = + legs[leg_of[j]].seg_length(local_index_of[j] - 1, arc_steps) as f32; + } + } + let mut next_dist_from_prev = vec![0.0f32; n]; + for j in 0..n { + next_dist_from_prev[j] = if raws[j].teleport || j == n - 1 { + 0.0 + } else { + dist_from_prev[j + 1] + }; + } + + // firstStop / lastStop (TransportMgr.cpp:213-222). + let mut first_stop: Option = None; + let mut last_stop: Option = None; + for (j, r) in raws.iter().enumerate() { + if r.is_stop { + first_stop.get_or_insert(j); + last_stop = Some(j); + } + } + let (first_stop, last_stop) = (first_stop.unwrap_or(0), last_stop.unwrap_or(0)); + + // Phase E: DistSinceStop / DistUntilStop, the two modular walks (TransportMgr.cpp:237-256). + let mut dist_since_stop = vec![0.0f32; n]; + let mut tmp = 0.0f32; + for i in 0..n { + let j = (i + last_stop) % n; + tmp = if raws[j].is_stop || j == last_stop { + 0.0 + } else { + tmp + dist_from_prev[j] + }; + dist_since_stop[j] = tmp; + } + let mut dist_until_stop = vec![0.0f32; n]; + let mut tmp = 0.0f32; + for i in (0..n).rev() { + let j = (i + first_stop) % n; + tmp += dist_from_prev[(j + 1) % n]; + dist_until_stop[j] = tmp; + if raws[j].is_stop || j == first_stop { + tmp = 0.0; + } + } + + // Phase F: per-frame TimeTo, the four-regime trapezoid (TransportMgr.cpp:260-284) — pure + // kinematics, shared by both timing modes (only the Arrive/Departure accumulation, Phase + // H, is mode-dependent — see the module doc). + let accel_dist = 0.5 * move_speed * move_speed / accel_rate; + let accel_time = move_speed / accel_rate; + let mut time_to = vec![0.0f32; n]; + for j in 0..n { + let (since, until) = (dist_since_stop[j], dist_until_stop[j]); + let total = since + until; + time_to[j] = if total < 2.0 * accel_dist { + if since < until { + let segment_time = 2.0 * ((until + since) / accel_rate).sqrt(); + segment_time - (2.0 * since / accel_rate).sqrt() + } else { + (2.0 * until / accel_rate).sqrt() + } + } else if since < accel_dist { + let segment_time = (until + since) / move_speed + (move_speed / accel_rate); + segment_time - (2.0 * since / accel_rate).sqrt() + } else if until < accel_dist { + (2.0 * until / accel_rate).sqrt() + } else { + (until / move_speed) + (0.5 * move_speed / accel_rate) + }; + } + + // Phase G: TimeFrom (TransportMgr.cpp:287-295). + let mut time_from = vec![0.0f32; n]; + let mut segment_time = 0.0f32; + for i in 0..n { + let j = (i + last_stop) % n; + if raws[j].is_stop || j == last_stop { + segment_time = time_to[j]; + } + time_from[j] = segment_time - time_to[j]; + } + + // Phase H: Arrive/Departure + period — the calibrated variant. `_arrive_time` (per-frame + // arrival) isn't retained on [`Frame`]: `sample` only ever needs `departure_time` (a + // window's start) and `next_arrive_time` (its end) — see the `Frame` doc. + let (_arrive_time, departure_time, next_arrive_time, period_ms) = match mode { + TimeMode::Vmangos => accumulate_vmangos(&raws, &time_to), + TimeMode::ClientForms => { + accumulate_client_forms(&raws, &dist_from_prev, move_speed, accel_rate) + } + }; + + let frames: Vec = (0..n) + .map(|j| Frame { + map_id: raws[j].map_id, + pos: raws[j].pos, + initial_orientation: raws[j].initial_orientation, + dist_since_stop: dist_since_stop[j], + dist_until_stop: dist_until_stop[j], + next_dist_from_prev: next_dist_from_prev[j], + time_from: time_from[j], + time_to: time_to[j], + departure_time: departure_time[j], + next_arrive_time: next_arrive_time[j], + leg: leg_of[j], + local_index: local_index_of[j], + }) + .collect(); + + Some(TransportTimetable { + period_ms, + frames, + legs, + move_speed, + accel_rate, + accel_time, + accel_dist, + }) + } + + /// Pin the cycle length to the exact period — **the server's own move** + /// (`TransportMgr.cpp:63-79`: vmangos overrides its computed `pathTime` with the DB-sniffed + /// per-build client value, stretching the final keyframe's departure), mirrored here. + /// [`Self::build`] applies it with the client-transcribed period ([`crate::transport_period`], + /// bit-exact against all nine server-sniff values — the 2026-07-17 wow-re §5 verdict): the + /// wire anchor is a *raw* server-uptime-scale clock, so a Δms period error is amplified by + /// the whole `floor(progress/period)` cycle count at every `% period` — days of uptime turn + /// a 20 ms mismatch into minutes of phase error. + /// + /// A longer pin parks the transport at its final keyframe for the extra slice (exactly + /// vmangos's stretch); a shorter one truncates the final approach at wrap ([`Self::sample`] + /// clamps into the last window). Both deltas are tens of ms — invisible either way. + fn override_period(&mut self, period_ms: u32) { + if period_ms == 0 || period_ms == self.period_ms { + return; + } + if period_ms > self.period_ms { + if let Some(last) = self.frames.last_mut() { + last.departure_time = period_ms; + last.next_arrive_time = period_ms; + } + } + self.period_ms = period_ms; + } + + /// The transport's position/heading at `cycle_ms` (`progress % period_ms`) — + /// `ShipTransport::Update` + `CalculateSegmentPos` (`Transport.cpp:283-380`), done + /// statelessly (no persistent "current frame" — a fresh search every call). + pub fn sample(&self, cycle_ms: u32) -> TransportSample { + let n = self.frames.len(); + if n == 0 { + return TransportSample { + map: 0, + pos: [0.0; 3], + heading: 0.0, + moving: false, + }; + } + let cycle_ms = cycle_ms.min(self.period_ms.saturating_sub(1)); + + // The first frame whose window `[ArriveTime, NextArriveTime)` contains cycle_ms — the + // union of its stop window `[Arrive, Depart)` and its outbound travel window `[Depart, + // NextArrive)`. + let idx = self + .frames + .iter() + .position(|f| cycle_ms < f.next_arrive_time) + .unwrap_or(n - 1); + let frame = &self.frames[idx]; + + if cycle_ms < frame.departure_time || frame.next_dist_from_prev <= 0.0 { + // Waiting at a stop, the zero-width instant at a non-stop keyframe, or (defensively) + // a teleport's zero-width travel window — teleports never have a real outbound + // window (see the `next_dist_from_prev` doc on `Frame`). + return TransportSample { + map: frame.map_id, + pos: frame.pos, + heading: frame.initial_orientation, + moving: false, + }; + } + + // Moving toward the next frame — CalculateSegmentPos (Transport.cpp:353-380): distance + // from the *nearer* stop, under the same four-regime trapezoid as the build-time TimeTo. + let now = cycle_ms as f32 * 0.001; + let since_departure = now - frame.departure_time as f32 * 0.001; + let time_since_stop = frame.time_from + since_departure; + let time_until_stop = frame.time_to - since_departure; + let dist = if time_since_stop < time_until_stop { + let d = if time_since_stop < self.accel_time { + 0.5 * self.accel_rate * time_since_stop * time_since_stop + } else { + self.accel_dist + (time_since_stop - self.accel_time) * self.move_speed + }; + d - frame.dist_since_stop + } else { + let d = if time_until_stop < self.accel_time { + 0.5 * self.accel_rate * time_until_stop * time_until_stop + } else { + self.accel_dist + (time_until_stop - self.accel_time) * self.move_speed + }; + frame.dist_until_stop - d + }; + let t = (dist / frame.next_dist_from_prev).clamp(0.0, 1.0); + + let leg = &self.legs[frame.leg]; + let pos = leg.evaluate_percent(frame.local_index, t); + let dir = leg.evaluate_derivative(frame.local_index, t); + let heading = normalize_orientation(dir[1].atan2(dir[0]) + PI); + + TransportSample { + map: frame.map_id, + pos, + heading, + moving: true, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/benilla-formats/src/transports/tests.rs b/crates/benilla-formats/src/transports/tests.rs new file mode 100644 index 00000000..7003b3dc --- /dev/null +++ b/crates/benilla-formats/src/transports/tests.rs @@ -0,0 +1,273 @@ +//! The nine-golden calibration + gold gates for the transport timetable (decision 0438 phase +//! 0): the mode × chord-steps period report against the server's effective (DB-pinned) periods, +//! and the bit-exactness gate on the client-transcribed period (`crate::transport_period`) that +//! lets [`TransportTimetable::build`] self-pin. Needs the vanilla client data (skips otherwise). + +use super::*; +use crate::taxi::load_taxi_path_nodes; + +struct Golden { + entry: u32, + path_id: u32, + period_ms: u32, + move_speed: f32, + accel_rate: f32, + /// A real build sniff (vs. a `build 0` fallback row) — the task's distinguished subset. + sniffed: bool, +} + +/// The nine live transports' server-effective (DB-pinned) periods — vmangos `transports` +/// table, build-≤5875 row selected, and `gameobject_template` `data1`/`data2`, both read this +/// session (decision 0438's data-model survey). All moveSpeed=30/accelRate=1 except Naxxramas +/// (181056), moveSpeed=1/accelRate=1. +const GOLDENS: &[Golden] = &[ + Golden { + entry: 20808, + path_id: 241, + period_ms: 350818, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: true, + }, + Golden { + entry: 164871, + path_id: 302, + period_ms: 356284, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: true, + }, + Golden { + entry: 175080, + path_id: 285, + period_ms: 303463, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: false, + }, + Golden { + entry: 176231, + path_id: 292, + period_ms: 329313, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: true, + }, + Golden { + entry: 176244, + path_id: 293, + period_ms: 316251, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: false, + }, + Golden { + entry: 176310, + path_id: 295, + period_ms: 295579, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: true, + }, + Golden { + entry: 176495, + path_id: 301, + period_ms: 333044, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: false, + }, + Golden { + entry: 177233, + path_id: 303, + period_ms: 317040, + move_speed: 30.0, + accel_rate: 1.0, + sniffed: true, + }, + Golden { + entry: 181056, + path_id: 436, + period_ms: 1_208_014, + move_speed: 1.0, + accel_rate: 1.0, + sniffed: true, + }, +]; + +const MODES: &[(&str, TimeMode)] = &[("V", TimeMode::Vmangos), ("C", TimeMode::ClientForms)]; +const STEPS_VARIANTS: &[u32] = &[3, 10, 20, 100]; + +/// Builds every one of the nine live transports' timetables under every (mode × arc-length +/// chord-sampling) combination and reports computed-vs-golden period for each. This is the +/// calibration report the phase-0 gate needs, not a pass/fail bit: the goldens pin **which** +/// variant is the real client's algorithm (decision 0438 phase 0), so only loose sanity is +/// asserted here — a period > 0, and the *best* variant per path within 5% of golden. +#[test] +fn nine_period_calibration_report() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode"); + + let variant_count = MODES.len() * STEPS_VARIANTS.len(); + let mut all_nine_exact = vec![true; variant_count]; + let mut all_sniffed_exact = vec![true; variant_count]; + + eprintln!( + "{:>8} {:>5} {:>5} {:>6} {:>12} {:>12} {:>10} {:>9}", + "entry", "path", "mode", "steps", "computed_ms", "golden_ms", "delta_ms", "pct" + ); + + for g in GOLDENS { + let nodes = cat + .path(g.path_id) + .unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id)); + + let mut best_pct = f64::MAX; + let mut best_desc = String::new(); + let mut variant_idx = 0; + for &(mode_name, mode) in MODES { + for &steps in STEPS_VARIANTS { + let computed = TransportTimetable::build_with_variant( + nodes, + g.move_speed, + g.accel_rate, + mode, + steps, + ) + .map(|tt| tt.period_ms) + .unwrap_or(0); + let delta = i64::from(computed) - i64::from(g.period_ms); + let pct = delta as f64 / f64::from(g.period_ms) * 100.0; + eprintln!( + "{:>8} {:>5} {:>5} {:>6} {:>12} {:>12} {:>10} {:>8.4}%", + g.entry, g.path_id, mode_name, steps, computed, g.period_ms, delta, pct + ); + + if computed != g.period_ms { + all_nine_exact[variant_idx] = false; + if g.sniffed { + all_sniffed_exact[variant_idx] = false; + } + } + if pct.abs() < best_pct { + best_pct = pct.abs(); + best_desc = format!("{mode_name} steps={steps} computed={computed}"); + } + variant_idx += 1; + } + } + + assert!( + best_pct < 5.0, + "path {} ({}): best variant ({best_desc}) not within 5% of golden {}", + g.path_id, + g.entry, + g.period_ms + ); + } + + let mut variant_idx = 0; + let mut any_all_nine = false; + let mut any_all_sniffed = false; + for &(mode_name, _) in MODES { + for &steps in STEPS_VARIANTS { + if all_nine_exact[variant_idx] { + any_all_nine = true; + eprintln!( + "*** variant mode={mode_name} steps={steps} matches ALL NINE golden periods EXACTLY ***" + ); + } + if all_sniffed_exact[variant_idx] { + any_all_sniffed = true; + eprintln!( + "*** variant mode={mode_name} steps={steps} matches ALL SIX SNIFFED golden periods EXACTLY ***" + ); + } + variant_idx += 1; + } + } + if !any_all_nine && !any_all_sniffed { + eprintln!( + "(no variant matched any golden subset exactly this round — see the table above)" + ); + } +} + +/// The bit-exactness gate for the client-transcribed period (`transport_period`): all nine +/// live transports must reproduce their server-sniff golden **exactly** — this is what lets +/// [`TransportTimetable::build`] self-pin its cycle length instead of consulting a hardcoded +/// server table (the 2026-07-17 wow-re §5 gold validation, decision 0438 §3's exactness +/// requirement). +#[test] +fn client_period_bit_exact() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode"); + + let mut failures = Vec::new(); + for g in GOLDENS { + let nodes = cat + .path(g.path_id) + .unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id)); + let computed = crate::transport_period::client_period_ms(nodes, g.move_speed, g.accel_rate) + .unwrap_or(0); + let delta = i64::from(computed) - i64::from(g.period_ms); + eprintln!( + "path {:>4}: client_period={computed:>8} golden={:>8} delta={delta:+}", + g.path_id, g.period_ms + ); + if delta != 0 { + failures.push((g.path_id, computed, g.period_ms)); + } + } + assert!( + failures.is_empty(), + "client period diverges from the server-sniff goldens: {failures:?}" + ); +} + +/// [`TransportTimetable::touches_map`] answers exactly the raw path's map set — the cross-map +/// worldport's spare predicate (decision 0455) rests on it — and the premise itself holds: at +/// least one of the nine live transports really does cross continents mid-cycle. +#[test] +fn touches_map_matches_the_paths_map_set() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_taxi_path_nodes(&mut chain).expect("load TaxiPathNode"); + + let mut any_cross_map = false; + for g in GOLDENS { + let nodes = cat + .path(g.path_id) + .unwrap_or_else(|| panic!("path {} exists in TaxiPathNode.dbc", g.path_id)); + let tt = TransportTimetable::build(nodes, g.move_speed, g.accel_rate) + .unwrap_or_else(|| panic!("path {} builds", g.path_id)); + let maps: std::collections::HashSet = nodes.iter().map(|n| n.map_id).collect(); + any_cross_map |= maps.len() > 1; + for &m in &maps { + assert!(tt.touches_map(m), "path {} should touch map {m}", g.path_id); + } + assert!( + !tt.touches_map(9999), + "path {} touches a map no node lives on", + g.path_id + ); + } + assert!( + any_cross_map, + "no golden path crosses continents — the 0455 spare predicate has nothing to spare" + ); +} diff --git a/crates/benilla-formats/src/unit_blood.rs b/crates/benilla-formats/src/unit_blood.rs new file mode 100644 index 00000000..ba1378f5 --- /dev/null +++ b/crates/benilla-formats/src/unit_blood.rs @@ -0,0 +1,195 @@ +//! **UnitBlood.dbc / UnitBloodLevels.dbc** — the melee blood-spurt tables (decision 0137 phase 3). +//! +//! The byte-verified chain (wow-re `melee-blood-spurt.md`, `0x624530 → 0x625010`): a melee hit with +//! `HitInfo & 0x2`, nonzero damage, and victimState ∈ {1,4} resolves the victim's blood id +//! (`CreatureDisplayInfo.BloodLevel` override, else `CreatureModelData.BloodID`; ≤ 0 = bloodless) → +//! a **UnitBloodLevels** row, whose three columns are the bloodID per `violenceLevel` (0–2) — the +//! vanilla censorship table: a red-blooded creature at violence 1 bleeds *green*, at 0 nothing — +//! → a **UnitBlood** row whose first four fields are `SpellVisualEffectName` ids. Column order +//! pinned empirically from the effect names (`Combat Blood Spurt `, +//! 12/12 rows consistent): **[FrontSmall, FrontLarge, BackSmall, BackLarge]** — front/back from +//! `sign(victimForward · (attackerPos − victimPos))`, Large on the *crushing* bit `HitInfo & 0x2000` +//! (not crit — crit `0x80` belongs to the wound flinch). Fields 5–9 are the ground-splat decal +//! textures (`textures\BloodSplats\…`) — a separate mechanism, not read here (no consumer yet). + +use std::collections::HashMap; + +use crate::Chain; +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; + +const UNIT_BLOOD: &str = "DBFilesClient\\UnitBlood.dbc"; +const UNIT_BLOOD_LEVELS: &str = "DBFilesClient\\UnitBloodLevels.dbc"; + +/// The two blood tables, resolving `(blood id, violence level, front, crushing)` → the spurt's +/// `SpellVisualEffectName` id (whose path the spell-visual catalog resolves like any kit effect). +pub struct BloodCatalog { + /// UnitBloodLevels: id → the bloodID per violence level 0/1/2 (`0` = no blood at that level). + levels: HashMap, + /// UnitBlood: id → `[FrontSmall, FrontLarge, BackSmall, BackLarge]` effect ids. + rows: HashMap, +} + +impl BloodCatalog { + /// The spurt effect for a victim: `blood_id` is the creature's resolved UnitBloodLevels key + /// (≤ 0 = a bloodless model — `None`), `violence` the gore level (0–2, clamped), `front`/`large` + /// the hit geometry/weight. `None` = no spurt (bloodless, censored at this level, or absent row). + pub fn effect_id( + &self, + blood_id: i32, + violence: usize, + front: bool, + large: bool, + ) -> Option { + if blood_id <= 0 { + return None; + } + let level = self.levels.get(&(blood_id as u32))?[violence.min(2)]; + if level == 0 { + return None; + } + let row = self.rows.get(&level)?; + let idx = match (front, large) { + (true, false) => 0, + (true, true) => 1, + (false, false) => 2, + (false, true) => 3, + }; + Some(row[idx]).filter(|&id| id != 0) + } + + /// Row counts (levels, rows) for logging/diagnostics. + pub fn len(&self) -> (usize, usize) { + (self.levels.len(), self.rows.len()) + } +} + +/// UnitBloodLevels.dbc — 4 fields in build 5875: ID + the three per-violence-level bloodIDs. +fn unit_blood_levels_schema() -> Schema { + let mut s = Schema::new("UnitBloodLevels"); + for name in ["ID", "Violence0", "Violence1", "Violence2"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// UnitBlood.dbc — 10 fields in build 5875: ID, the four spurt `SpellVisualEffectName` ids, and +/// five ground-splat texture strings (unread — see the module doc). +fn unit_blood_schema() -> Schema { + let mut s = Schema::new("UnitBlood"); + for name in ["ID", "FrontSmall", "FrontLarge", "BackSmall", "BackLarge"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for i in 0..5 { + s.add_field(SchemaField::new( + format!("GroundSplat{i}"), + FieldType::String, + )); + } + s +} + +/// Load both blood tables from the patch chain into a [`BloodCatalog`]. +pub fn load_blood_catalog(chain: &mut Chain) -> Result { + let levels = { + let bytes = chain + .read_file(UNIT_BLOOD_LEVELS) + .with_context(|| format!("reading {UNIT_BLOOD_LEVELS}"))?; + let rs = parse(&bytes, unit_blood_levels_schema(), "UnitBloodLevels")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + m.insert( + id, + [ + u32_at(r, 1).unwrap_or(0), + u32_at(r, 2).unwrap_or(0), + u32_at(r, 3).unwrap_or(0), + ], + ); + } + } + m + }; + let rows = { + let bytes = chain + .read_file(UNIT_BLOOD) + .with_context(|| format!("reading {UNIT_BLOOD}"))?; + let rs = parse(&bytes, unit_blood_schema(), "UnitBlood")?; + let mut m = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + if let Some(id) = u32_at(r, 0) { + m.insert( + id, + [ + u32_at(r, 1).unwrap_or(0), + u32_at(r, 2).unwrap_or(0), + u32_at(r, 3).unwrap_or(0), + u32_at(r, 4).unwrap_or(0), + ], + ); + } + } + m + }; + Ok(BloodCatalog { levels, rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// The real 5875 tables, end to end: a red-blooded creature (blood 1 — HumanMale's) at max + /// violence spurts RED (effect 109 front-small = `Particles\BloodSpurts\BloodSpurt.mdl`, + /// 55 back-large); at violence 1 the same creature is CENSORED to green (row 2's ids); at 0 + /// nothing. Pins the empirically-derived column order against the shipped data. + #[test] + fn real_blood_tables_resolve_the_censorship_chain() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let blood = load_blood_catalog(&mut chain).expect("blood tables"); + + // Max violence: true colors, all four quadrants of row 1 (red). + assert_eq!( + blood.effect_id(1, 2, true, false), + Some(109), + "front small red" + ); + assert_eq!( + blood.effect_id(1, 2, true, true), + Some(164), + "front large red" + ); + assert_eq!( + blood.effect_id(1, 2, false, false), + Some(534), + "back small red" + ); + assert_eq!( + blood.effect_id(1, 2, false, true), + Some(55), + "back large red" + ); + // Violence 1: red is censored to green (UnitBloodLevels row 1 → bloodID 2). + assert_eq!( + blood.effect_id(1, 1, true, false), + Some(183), + "censored green" + ); + // Violence 0: no blood at all; bloodless models (≤0) never spurt. + assert_eq!(blood.effect_id(1, 0, true, false), None); + assert_eq!(blood.effect_id(-1, 2, true, false), None); + assert_eq!(blood.effect_id(0, 2, true, false), None); + } +} diff --git a/crates/benilla-formats/src/value_track.rs b/crates/benilla-formats/src/value_track.rs new file mode 100644 index 00000000..705ce888 --- /dev/null +++ b/crates/benilla-formats/src/value_track.rs @@ -0,0 +1,454 @@ +//! The keyed **M2Track sampler kernel** shared by the cosmetic-record parsers (`particles`, +//! `ribbons`): `(timestamp ms, value)` key lists read off the vanilla 28-byte track, rebased from +//! the global timeline onto the first sequence's band, sampled on a 0-based clip clock. +//! +//! **Track timestamps are the vanilla global timeline.** A sequence-timeline track +//! (`gseq == 0xffff`) keys inside each sequence's absolute `[start, end]` band (the same law as +//! bone keys, `models/anim.rs`) — an effect model whose seq 0 spans `[1000, 2600]` authors its +//! impact burst at 1000 ms, *the first instant of the clip*. Keyed tracks are therefore +//! **rebased to the first sequence's band** at parse ([`rebase_keys_to_band`]), so a runtime +//! clock of seconds-since-spawn samples them directly. First-sequence only: the effect/doodad +//! load-arm plays exactly that sequence; multi-sequence gating (a creature emitter keyed per gait +//! band) has no driving model yet and stays a named seam. +//! +//! History: the particle emission params were once `value[0]`-baked, which silenced every keyed +//! burst (`BloodSpurt.m2` rates its starflash `0 → 20 → 0`; decisions 0137/0140/0141). The ribbon +//! look tracks repeated the exact trap — `HolySmite_Low_Chest.m2` keys its slash ribbons' height +//! `0 → 0.167 → 0`, so the value[0] bake read a permanent zero and Smite's impact slash never +//! drew. One kernel, both lanes, so the next keyed track can't be silently constant-folded. + +fn le_u16(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +fn le_u32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +/// A value the keyed-track sampler can hold and lerp — `f32` scalars (emission rate, ribbon +/// alpha/heights) and `[f32; 3]` triples (the ribbon colour track's C3Vector). +pub trait TrackValue: Copy { + /// The empty-track fallback (defensive — the parsers always fall back to a constant key). + const ZERO: Self; + fn lerp(a: Self, b: Self, t: f32) -> Self; +} + +impl TrackValue for f32 { + const ZERO: Self = 0.0; + fn lerp(a: Self, b: Self, t: f32) -> Self { + a + (b - a) * t + } +} + +impl TrackValue for [f32; 3] { + const ZERO: Self = [0.0; 3]; + fn lerp(a: Self, b: Self, t: f32) -> Self { + std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) + } +} + +/// A keyed M2Track, reduced to `(timestamp ms, value)` pairs — enough to sample the **burst** +/// emitters the one-shot combat effects are authored with (0137/0140 fold-back: `BloodSpurt.m2`'s +/// starflash + glowball emitters key their rate `0 → 200 → 0` over the first 133 ms; a `value[0]` +/// bake reads 0 and they never emit at all). The ribbon look tracks (colour/alpha/heights) ride +/// the same kernel — `HolySmite_Low_Chest.m2` keys its slash ribbons' height `0 → 0.167 → 0`, the +/// same value[0]-reads-0 trap. Keys are sampled with linear interpolation, **end-clamped** — no +/// loop wrap: every looping ambient prop in the shipped corpus authors a constant (single-key) +/// value, so a wrap clock has no driving model yet; a persistent looping model with a keyed track +/// would hold its last key (the documented seam). +#[derive(Debug, Clone, Default)] +pub struct ValueTrack { + /// `(timestamp ms, value)` keys, in file order (ascending timestamps). + pub keys: Vec<(u32, V)>, + /// The M2Track's interpolation word (`+0x00`): 0 = STEP (`values[k0]`), else linear. The + /// corpus splits cleanly on it — burst emitters author 0 (the full count arms AT the key), + /// continuous ramps author 1 (the pour envelope lerps). [`Self::sampled_ms`] dispatches on it. + pub interp: u16, +} + +impl ValueTrack { + /// A single-key constant track (the parse fallback shape). + pub(crate) fn constant(v: V) -> Self { + Self { + keys: vec![(0, v)], + interp: 0, + } + } + + /// The first key's value — the old `value[0]` load-time bake, kept for the constant-track + /// parameters and diagnostics. + pub fn first(&self) -> V { + self.keys.first().map_or(V::ZERO, |&(_, v)| v) + } + + /// STEP-sample at `ms` since clip start: the nearest-previous key's value — the first key's + /// before the first, the last **held** past the end. This is the interp-0 leg of the + /// reference sampler ([`Self::sampled_ms`] dispatches here) — a Feint plume keyed + /// `{0:0, 67:30}` interp 0 is *silent* until 67 ms, then 30, exactly what arms its burst + /// count. (The old "the rate sampler never lerps" reading died with + /// `part-emission-rate-animated.md`: interp≠0 tracks lerp, and the corpus authors its + /// continuous ramps that way.) + pub fn step_ms(&self, ms: f32) -> V { + let mut v = self.keys.first().map_or(V::ZERO, |&(_, v)| v); + for &(t, val) in &self.keys { + if (t as f32) <= ms { + v = val; + } else { + break; + } + } + v + } + + /// Sample as the reference's per-frame track sampler does (`0x71af20` → `0x713d50`, wow-re + /// `part-emission-rate-animated.md` §2/§4, VERIFIED + emulated): STEP (`values[k0]`) when the + /// track's [`Self::interp`] word is 0, else LINEAR between the bracketing keys — **held** at + /// the last key past the end, and extrapolated **backward** (negative fraction) below the + /// first key (the sampler does not clamp there). Raw file values — a rate track may go + /// negative (`BloodSpurt.m2` emitter 0 ends at −100, and the back-extrapolation can dip + /// below the first key); the consumer floors at 0 exactly like the `SetEmissionRate` guard. + pub fn sampled_ms(&self, ms: f32) -> V { + if self.interp == 0 { + return self.step_ms(ms); + } + let n = self.keys.len(); + if n <= 1 { + return self.first(); + } + // The bracketing pair: the segment whose start is the last key at/before `ms` — the + // FIRST segment below the first key (negative fraction), a hold on the last key. + let k = self + .keys + .iter() + .rposition(|&(t, _)| (t as f32) <= ms) + .unwrap_or(0); + if k + 1 == n { + return self.keys[n - 1].1; + } + let (t0, v0) = self.keys[k]; + let (t1, v1) = self.keys[k + 1]; + let span = (t1.saturating_sub(t0)).max(1) as f32; + V::lerp(v0, v1, (ms - t0 as f32) / span) + } + + /// Sample at `ms` since clip start: linear between neighbouring keys, clamped to the first/last + /// key outside the keyed span. Raw file values — a rate track may go negative at its tail + /// (`BloodSpurt.m2` emitter 0 ends at −100); the consumer floors at 0. + pub fn sample_ms(&self, ms: f32) -> V { + let Some(&(t0, v0)) = self.keys.first() else { + return V::ZERO; + }; + if ms <= t0 as f32 { + return v0; + } + for w in self.keys.windows(2) { + let ((ta, va), (tb, vb)) = (w[0], w[1]); + if ms < tb as f32 { + let span = (tb - ta).max(1) as f32; + return V::lerp(va, vb, (ms - ta as f32) / span); + } + } + self.keys.last().map_or(V::ZERO, |&(_, v)| v) + } +} + +impl ValueTrack { + /// The track's peak value — the "can this ever contribute" gate (a burst emitter's + /// `value[0]` is 0; its peak is not — same for a slash ribbon's height). + pub fn peak(&self) -> f32 { + self.keys.iter().fold(f32::MIN, |m, &(_, v)| m.max(v)) + } +} + +/// An **ON/OFF gate** M2Track (`M2Track`, step-interpolated): the emission gate on particle +/// (`+0x1dc`) and ribbon (`+0xc0` visibility) records. This is how one-shot effect models +/// choreograph their emitters inside the clip — `Fire_Cast_Hand.m2` enables its hand-flash +/// emitters for exactly the first 200 ms of its 1.0 s clip; `MoltenBlast_Impact_Chest.m2` +/// staggers six emitters across 200–570 ms windows. A cleared gate stops NEW emission only; live +/// particles finish their lifespan (the same law as the ribbon `block+0xbc` gate, wow-re +/// `ribbon-emitter-spec.md` §6.3). +#[derive(Debug, Clone)] +pub struct OnOffTrack { + /// `(timestamp ms, on)` keys, ascending — rebased to the first sequence's band like + /// [`ValueTrack`]. Empty never occurs from the parsers (the fallback is a single always-on + /// key). + pub keys: Vec<(u32, bool)>, +} + +impl Default for OnOffTrack { + fn default() -> Self { + Self::always_on() + } +} + +impl OnOffTrack { + /// The parse-fallback shape: a single key at t=0, on. + pub(crate) fn always_on() -> Self { + Self { + keys: vec![(0, true)], + } + } + + /// Step-sample at `ms` since clip start: the last key at or before `ms` (M2 step + /// interpolation — nearest-previous), the first key's value before it, `true` for a keyless + /// track (never emitted by the parse). + pub fn on_at(&self, ms: f32) -> bool { + let mut on = self.keys.first().is_none_or(|&(_, v)| v); + for &(t, v) in &self.keys { + if (t as f32) <= ms { + on = v; + } else { + break; + } + } + on + } +} + +/// Rebase one keyed track from the vanilla global timeline onto a sequence's `[start, end]` band +/// (clip-relative ms): in-band keys shift by `−start`; of the keys at/before `start` only the +/// last survives, collapsed to `t = 0` (it IS the value the band starts on, per the +/// nearest-previous sampling law); of the keys at/after `end` only the first survives, clamped +/// to `t = end − start` (the value the band ends on). Matches the reference sampling a track +/// with the sequence clock, expressed on a 0-based clip clock. +pub(crate) fn rebase_keys_to_band(keys: &mut Vec<(u32, V)>, start: u32, end: u32) { + if keys.is_empty() || (start == 0 && keys.last().is_some_and(|&(t, _)| t <= end)) { + return; // already 0-based in-band — the common ambient-prop shape + } + let mut out: Vec<(u32, V)> = Vec::with_capacity(keys.len()); + for &(t, v) in keys.iter() { + if t <= start { + // Collapse everything at/before the band start to the t=0 key (last one wins). + match out.first_mut() { + Some(first) if first.0 == 0 => *first = (0, v), + _ => out.insert(0, (0, v)), + } + } else if t < end { + out.push((t - start, v)); + } else { + out.push((end - start, v)); + break; // first key at/past the end closes the band + } + } + *keys = out; +} + +/// The **first sequence's absolute time band** (sequences @ `0x1c/0x20`, stride `0x44`, start @ +/// +4 / end @ +8 — the same walk as `models/anim.rs`): the window keyed tracks are rebased onto. +/// A sequence-less model keeps raw timestamps (band = whole timeline). +pub(crate) fn seq0_band(bytes: &[u8]) -> (u32, u32) { + let (n_seq, o_seq) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); + if n_seq > 0 && o_seq + 0x44 <= bytes.len() { + (le_u32(bytes, o_seq + 4), le_u32(bytes, o_seq + 8)) + } else { + (0, u32::MAX) + } +} + +/// The emitter clip clock's wrap width: `Some(first sequence's span ms)` when that sequence +/// LOOPS (flags bit0 clear — the verified polarity, `models/anim.rs`), else `None`. The client +/// samples emitter tracks on `m2_animate`'s sequence time, which wraps at a looping sequence's +/// end — windowed rate/enabled tracks re-fire every pass; a clamped sequence holds instead. +pub(crate) fn seq0_wrap_ms(bytes: &[u8]) -> Option { + let (n_seq, o_seq) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize); + if n_seq == 0 || o_seq + 0x44 > bytes.len() { + return None; + } + let span = le_u32(bytes, o_seq + 8).saturating_sub(le_u32(bytes, o_seq + 4)); + let looping = le_u32(bytes, o_seq + 0x10) & 1 == 0; + (looping && span > 0).then_some(span as f32) +} + +/// A vanilla M2Track's key sub-arrays: `(gseq, n, timestamps offset, values offset)` — `None` if +/// the track is out of range or keyless. +fn track_arrays(b: &[u8], track: usize) -> Option<(u16, usize, usize, usize)> { + if track + 0x1c > b.len() { + return None; + } + let gseq = le_u16(b, track + 0x02); + let tn = le_u32(b, track + 0x0c) as usize; + let tofs = le_u32(b, track + 0x10) as usize; + let vn = le_u32(b, track + 0x14) as usize; + let vofs = le_u32(b, track + 0x18) as usize; + let n = tn.min(vn); + (n > 0 && tofs + n * 4 <= b.len()).then_some((gseq, n, tofs, vofs)) +} + +/// Read a vanilla M2Track's full `(timestamp ms, value)` key list: timestamps `{count @ +0x0c, +/// offset @ +0x10}` zipped with values `{count @ +0x14, offset @ +0x18}` (paired up to the +/// shorter count — equal in well-formed files), each value `elem` bytes decoded by `read`. +/// Sequence-timeline keys are rebased to `band` (module doc; a global-sequence-tagged track keeps +/// its own free clock and is left as-is). Empty/out-of-range → a constant track at `default`. +pub(crate) fn track_keys_with( + b: &[u8], + track: usize, + default: V, + band: (u32, u32), + elem: usize, + read: impl Fn(&[u8], usize) -> V, +) -> ValueTrack { + let Some((gseq, n, tofs, vofs)) = track_arrays(b, track) else { + return ValueTrack::constant(default); + }; + if vofs + n * elem > b.len() { + return ValueTrack::constant(default); + } + let mut keys: Vec<(u32, V)> = (0..n) + .map(|i| (le_u32(b, tofs + i * 4), read(b, vofs + i * elem))) + .collect(); + if gseq == 0xffff { + rebase_keys_to_band(&mut keys, band.0, band.1); + } + ValueTrack { + keys, + interp: le_u16(b, track), + } +} + +/// Read an ON/OFF gate M2Track (u8 values) — see [`OnOffTrack`]. Same band rebase as +/// [`track_keys_with`]; a keyless/out-of-range track is always-on (the loader's default, +/// `0x70f80e: mov byte[...+0xbc], 1`). +pub(crate) fn track_enabled(b: &[u8], track: usize, band: (u32, u32)) -> OnOffTrack { + let Some((gseq, n, tofs, vofs)) = track_arrays(b, track) else { + return OnOffTrack::always_on(); + }; + if vofs + n > b.len() { + return OnOffTrack::always_on(); + } + let mut keys: Vec<(u32, bool)> = (0..n) + .map(|i| (le_u32(b, tofs + i * 4), b[vofs + i] != 0)) + .collect(); + if gseq == 0xffff { + rebase_keys_to_band(&mut keys, band.0, band.1); + } + OnOffTrack { keys } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sampling law: linear between keys, end-clamped, raw values (negatives pass through — + /// the consumer floors). + #[test] + fn value_track_samples_linear_and_clamps() { + let t = ValueTrack { + keys: vec![(0, 0.0), (33, 0.0), (67, 100.0), (100, 200.0), (133, 0.0)], + interp: 1, + }; + assert_eq!(t.sample_ms(-5.0), 0.0); // clamp before first key + assert_eq!(t.sample_ms(0.0), 0.0); + assert!((t.sample_ms(50.0) - 50.0).abs() < 1.0); // rising edge, linear + assert_eq!(t.sample_ms(100.0), 200.0); // the burst peak + assert!((t.sample_ms(116.5) - 100.0).abs() < 1.0); // falling edge + assert_eq!(t.sample_ms(500.0), 0.0); // clamp past last key + assert_eq!(t.peak(), 200.0); + assert_eq!(t.first(), 0.0); + let c = ValueTrack::constant(7.5); + assert_eq!(c.sample_ms(0.0), 7.5); + assert_eq!(c.sample_ms(9999.0), 7.5); + assert_eq!(ValueTrack::::default().sample_ms(10.0), 0.0); + } + + /// The step law: nearest-previous key, first value before the first, held past the last — + /// the emission-rate sampler (`values[k0]`, no lerp). + #[test] + fn step_sampling_holds_nearest_previous() { + let t = ValueTrack { + keys: vec![(0, 0.0), (67, 30.0)], + interp: 0, + }; + assert_eq!(t.step_ms(0.0), 0.0); + assert_eq!( + t.step_ms(66.0), + 0.0, + "step, not lerp — silent until the key" + ); + assert_eq!(t.step_ms(67.0), 30.0); + assert_eq!(t.step_ms(1500.0), 30.0, "held past the last key"); + assert_eq!(ValueTrack::::default().step_ms(10.0), 0.0); + } + + /// The reference sampler law ([`ValueTrack::sampled_ms`]): interp 0 steps, interp 1 lerps + /// with hold-last and BACKWARD extrapolation below the first key (no clamp there — the + /// `0x713d50` negative-fraction edge; the rate consumer's `> 0` floor absorbs the dip). + #[test] + fn sampled_ms_dispatches_on_the_interp_word() { + let step = ValueTrack { + keys: vec![(0, 0.0), (67, 30.0)], + interp: 0, + }; + assert_eq!(step.sampled_ms(66.0), 0.0, "interp 0: silent until the key"); + assert_eq!(step.sampled_ms(67.0), 30.0); + let ramp = ValueTrack { + keys: vec![(100, 10.0), (200, 110.0), (300, 0.0)], + interp: 1, + }; + assert!( + (ramp.sampled_ms(150.0) - 60.0).abs() < 1e-4, + "mid-ramp lerp" + ); + assert_eq!(ramp.sampled_ms(200.0), 110.0); + assert_eq!(ramp.sampled_ms(999.0), 0.0, "held past the last key"); + assert!( + (ramp.sampled_ms(0.0) - (-90.0)).abs() < 1e-4, + "below the first key: backward extrapolation along the first segment, not a clamp" + ); + let single = ValueTrack { + keys: vec![(0, 40.0)], + interp: 1, + }; + assert_eq!(single.sampled_ms(500.0), 40.0, "single key holds"); + } + + /// The vec3 instantiation (the ribbon colour track): per-channel lerp, same clamp law. + #[test] + fn vec3_track_lerps_per_channel() { + let t = ValueTrack { + keys: vec![(0, [1.0, 0.0, 0.0]), (100, [0.0, 1.0, 0.5])], + interp: 1, + }; + assert_eq!(t.sample_ms(0.0), [1.0, 0.0, 0.0]); + let mid = t.sample_ms(50.0); + assert!((mid[0] - 0.5).abs() < 1e-6); + assert!((mid[1] - 0.5).abs() < 1e-6); + assert!((mid[2] - 0.25).abs() < 1e-6); + assert_eq!(t.sample_ms(500.0), [0.0, 1.0, 0.5]); + } + + /// The band rebase: keys before the band collapse to their last value at t=0, in-band keys + /// shift, keys past the end clamp to the band length, and an already-0-based track is + /// untouched. + #[test] + fn band_rebase_collapses_and_shifts() { + // MoltenBlast_Impact_Chest em0's shape: seq band [1000, 2600], burst keyed at its start. + let mut keys = vec![(1000u32, 0.0f32), (1133, 60.0)]; + rebase_keys_to_band(&mut keys, 1000, 2600); + assert_eq!(keys, vec![(0, 0.0), (133, 60.0)]); + // A constant single key at t=0 with a late band (Fire_Cast_Hand's rate): hold survives. + let mut keys = vec![(0u32, 21.4f32)]; + rebase_keys_to_band(&mut keys, 3333, 4333); + assert_eq!(keys, vec![(0, 21.4)]); + // Multiple pre-band keys: only the last one (the band-start value) survives, at t=0. + let mut keys = vec![(0u32, 1.0f32), (500, 2.0), (1200, 3.0), (3000, 4.0)]; + rebase_keys_to_band(&mut keys, 1000, 2000); + assert_eq!(keys, vec![(0, 2.0), (200, 3.0), (1000, 4.0)]); + // 0-based in-band track (BloodSpurt): identity. + let mut keys = vec![(0u32, 100.0f32), (500, -100.0), (667, -100.0)]; + rebase_keys_to_band(&mut keys, 0, 667); + assert_eq!(keys, vec![(0, 100.0), (500, -100.0), (667, -100.0)]); + } + + /// Step sampling on the enabled gate: nearest-previous key, first value before the first key. + #[test] + fn enabled_track_steps() { + let t = OnOffTrack { + keys: vec![(0, false), (133, true), (567, false)], + }; + assert!(!t.on_at(0.0)); + assert!(!t.on_at(100.0), "step, not lerp — off until the 133 ms key"); + assert!(t.on_at(133.0)); + assert!(t.on_at(500.0)); + assert!(!t.on_at(600.0)); + assert!(OnOffTrack::always_on().on_at(0.0)); + } +} diff --git a/crates/benilla-formats/src/wdl.rs b/crates/benilla-formats/src/wdl.rs new file mode 100644 index 00000000..76a0cf2e --- /dev/null +++ b/crates/benilla-formats/src/wdl.rs @@ -0,0 +1,368 @@ +//! WDL (low-detail distant terrain) loader + coarse mesher. +//! +//! One `.wdl` per map holds the **whole 64×64-tile world** at a single height sample per MCNK — the +//! horizon geometry the reference draws BEYOND the streamed high-detail tiles (out to `horizonfarclip`), +//! unlit + untextured + vertex-white + fogged into the haze. RE'd from apitrace WoW.8 + the real file. +//! +//! Layout — VERIFIED against `World\Maps\Azeroth\Azeroth.wdl` (770730 B, 2026-05-30): +//! `MVER` (version **18**) · `MAOF` (4096 `u32` tile offsets, `[tile_y][tile_x]`) · per present tile +//! `MARE` (1090 B = 17×17 outer + 16×16 inner `int16` absolute heights, yards). No `MWMO`/`MWID`/ +//! `MODF`, no `MAHO` in vanilla Azeroth (low-detail WMOs + holes are out of scope for v1). +//! +//! The mesh is in **raw WoW world coords** (X north, Y west, Z up, yards) like [`crate::terrain`], with +//! the same per-cell 4-tri **center-fan** — but at `CHUNK_SIZE` (one MCNK) spacing instead of +//! `UNIT_SIZE`, so one WDL tile = a single MCNK's topology scaled ×16 (545 verts / 3072 indices, the +//! exact draw the apitrace shows). The renderer applies the WoW→Bevy transform. + +use std::io::{Cursor, Read, Seek, SeekFrom}; + +use crate::Chain; +use anyhow::{bail, Context, Result}; + +use crate::terrain::TILE_SIZE; + +/// One MCNK's worth of horizon detail: 16 cells per tile edge, so the outer grid is 17×17 corners and +/// the inner grid is 16×16 cell centers — identical to an MCNK but at tile scale. +const OUTER_EDGE: usize = 17; +const INNER_EDGE: usize = 16; +const OUTER_N: usize = OUTER_EDGE * OUTER_EDGE; // 289 +const INNER_N: usize = INNER_EDGE * INNER_EDGE; // 256 +/// MARE body = (289 + 256) `int16` = 1090 bytes (VERIFIED). +const MARE_BYTES: usize = (OUTER_N + INNER_N) * 2; +/// One MCNK edge in yards (the WDL outer-grid spacing). `TILE_SIZE / 16`. +const CHUNK_SIZE: f32 = TILE_SIZE / 16.0; +/// World coord of tile (0,0)'s origin (max-X, max-Y) corner — the 64-tile map is centred on the origin. +const MAP_OFFSET: f64 = 32.0 * (TILE_SIZE as f64); + +/// A map's parsed WDL: the present tiles' low-detail heightmaps, indexed by MAOF position. +pub struct WdlFile { + /// 64×64, indexed `[tile_y * 64 + tile_x]` (the on-disk MAOF order); `None` where the map has no + /// tile. `tile_x`/`tile_y` match `Map__.adt` and `benilla_wdt::world_to_tile`. + tiles: Vec>, +} + +/// One tile's low-detail heightmap: 17×17 outer (corner) then 16×16 inner (cell-center) `int16` +/// heights, in yards (absolute world Z). Both grids are row-major. +struct MareTile { + outer: [i16; OUTER_N], + inner: [i16; INNER_N], +} + +/// A coarse WDL tile mesh in raw WoW world coords: positions + a 4-tri-per-cell center-fan index +/// buffer. No normals (unlit) and no UVs (untextured) — the reference draws this vertex-white + fogged. +pub struct WdlTileMesh { + /// 545 verts: 289 outer (`[r*17+c]`) then 256 inner (`[289 + r*16+c]`). + pub positions: Vec<[f32; 3]>, + /// 3072 indices = 16×16 cells × 4 triangles. + pub indices: Vec, +} + +/// MAOF/tiles index for tile `(tile_x, tile_y)`. Row-major over `tile_y` (the on-disk `[y][x]` order), +/// matching `benilla_wdt`'s tile addressing so the WDL present-set lines up with the WDT's. +fn tile_index(tile_x: u32, tile_y: u32) -> usize { + tile_y as usize * 64 + tile_x as usize +} + +impl WdlFile { + /// Read and parse the map's `.wdl` off the patch chain (`World\Maps\\.wdl`). + pub fn load(chain: &mut Chain, map: &str) -> Result { + let path = format!("World\\Maps\\{map}\\{map}.wdl"); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading {path}"))?; + parse_wdl(&bytes).with_context(|| format!("parsing {path}")) + } + + /// Number of present (non-empty) tiles. + pub fn present_count(&self) -> usize { + self.tiles.iter().filter(|t| t.is_some()).count() + } + + /// Is tile `(tile_x, tile_y)` present in this WDL? + pub fn is_present(&self, tile_x: u32, tile_y: u32) -> bool { + tile_x < 64 && tile_y < 64 && self.tiles[tile_index(tile_x, tile_y)].is_some() + } + + /// Iterate every present tile's `(tile_x, tile_y)`. + pub fn present_tiles(&self) -> impl Iterator + '_ { + (0..4096u32).filter_map(move |i| self.tiles[i as usize].as_ref().map(|_| (i % 64, i / 64))) + } + + /// Present WDL tiles forming a Chebyshev **ring** around a world `(x, y)`: every present tile + /// within `radius` tiles, **excluding** those within `skip_inner` tiles (the detailed-ADT ring the + /// renderer already draws — WDL must not double-draw under it; see terrain.md WDL "depth/blend"). + pub fn tiles_in_ring( + &self, + world_x: f32, + world_y: f32, + radius: u32, + skip_inner: u32, + ) -> Vec<(u32, u32)> { + let (cx, cy) = benilla_wdt::world_to_tile(world_x, world_y); + let (r, inner) = (radius as i32, skip_inner as i32); + let mut out = Vec::new(); + for dy in -r..=r { + for dx in -r..=r { + if dx.abs() <= inner && dy.abs() <= inner { + continue; // the detailed ring — drawn at full detail, so skip WDL here + } + let (tx, ty) = (cx as i32 + dx, cy as i32 + dy); + if (0..64).contains(&tx) + && (0..64).contains(&ty) + && self.is_present(tx as u32, ty as u32) + { + out.push((tx as u32, ty as u32)); + } + } + } + out + } + + /// Build the coarse center-fan mesh for tile `(tile_x, tile_y)`, or `None` if it's absent. + /// + /// Vertex X/Y are derived from the **global** lattice index (`tile·16 + local`) computed in `f64`, + /// so a shared edge between adjacent WDL tiles is bit-identical → watertight, no hairline seam. + pub fn tile_mesh(&self, tile_x: u32, tile_y: u32) -> Option { + if tile_x >= 64 || tile_y >= 64 { + return None; + } + let tile = self.tiles[tile_index(tile_x, tile_y)].as_ref()?; + let cs = CHUNK_SIZE as f64; + // Lattice position for a global row/col index (X north decreases with row, Y west with col). + let lat = |global: f64| (MAP_OFFSET - global * cs) as f32; + + let mut positions = Vec::with_capacity(OUTER_N + INNER_N); + // Outer 17×17 corners. + for r in 0..OUTER_EDGE { + for c in 0..OUTER_EDGE { + positions.push([ + lat((tile_y as usize * 16 + r) as f64), + lat((tile_x as usize * 16 + c) as f64), + f32::from(tile.outer[r * OUTER_EDGE + c]), + ]); + } + } + // Inner 16×16 cell centers (+½ cell in both planar axes). + for r in 0..INNER_EDGE { + for c in 0..INNER_EDGE { + positions.push([ + lat(tile_y as usize as f64 * 16.0 + r as f64 + 0.5), + lat(tile_x as usize as f64 * 16.0 + c as f64 + 0.5), + f32::from(tile.inner[r * INNER_EDGE + c]), + ]); + } + } + + // 4-triangle center-fan per cell, mirroring terrain.rs's winding (CCW from above → front-face + // up under the det-+1 WoW→Bevy transform). For cell (r, c) ∈ 0..16²: + // TL=outer(r,c) TR=outer(r,c+1) BL=outer(r+1,c) BR=outer(r+1,c+1) CTR=inner(r,c) + let inner_base = OUTER_N as u32; + let mut indices = Vec::with_capacity(INNER_EDGE * INNER_EDGE * 12); + for r in 0..INNER_EDGE as u32 { + for c in 0..INNER_EDGE as u32 { + let tl = r * OUTER_EDGE as u32 + c; + let tr = tl + 1; + let bl = tl + OUTER_EDGE as u32; + let br = bl + 1; + let ctr = inner_base + r * INNER_EDGE as u32 + c; + indices.extend_from_slice(&[ + ctr, tl, bl, // west fan + ctr, bl, br, // south fan + ctr, br, tr, // east fan + ctr, tr, tl, // north fan + ]); + } + } + Some(WdlTileMesh { positions, indices }) + } + + /// The drawn WDL surface height (absolute WoW `z`) under world `(x, y)` — interpolated over the + /// SAME 4-triangle center-fan [`Self::tile_mesh`] builds, so a query agrees exactly with the + /// rendered horizon silhouette (`height_at` at a mesh vertex's `x, y` returns that vertex's + /// `z`). `None` where the map has no WDL tile. The far leg of the lens-flare occlusion march + /// (benilla `sun::follow`), beyond the detailed-terrain ring. + pub fn height_at(&self, world_x: f32, world_y: f32) -> Option { + let cs = CHUNK_SIZE as f64; + // Global lattice coords — the inverse of `tile_mesh`'s `lat`: row grows along −X (north → + // south), col along −Y. 64 tiles × 16 cells = 1024 cells per axis. + let gr = (MAP_OFFSET - f64::from(world_x)) / cs; + let gc = (MAP_OFFSET - f64::from(world_y)) / cs; + if !(0.0..1024.0).contains(&gr) || !(0.0..1024.0).contains(&gc) { + return None; + } + let (cell_r, cell_c) = (gr as usize, gc as usize); + let tile = self.tiles[tile_index((cell_c / 16) as u32, (cell_r / 16) as u32)].as_ref()?; + let (r, c) = (cell_r % 16, cell_c % 16); + // The cell's corner + center heights (outer 17×17, inner 16×16 — same addressing as the mesh). + let h = |rr: usize, cc: usize| f64::from(tile.outer[rr * OUTER_EDGE + cc]); + let (tl, tr) = (h(r, c), h(r, c + 1)); + let (bl, br) = (h(r + 1, c), h(r + 1, c + 1)); + let ctr = f64::from(tile.inner[r * INNER_EDGE + c]); + // Fractional position inside the cell: `v` down the rows (toward BL), `u` across the cols + // (toward TR). The center-fan splits the cell into 4 triangles meeting at (0.5, 0.5); pick + // the fan by which quadrant-diagonal region holds (u, v), then interpolate linearly along + // the outer edge and blend toward the center — the exact plane of that fan triangle. + let (v, u) = (gr - cell_r as f64, gc - cell_c as f64); + let (a, b, t, s) = if v <= u && v <= 1.0 - u { + (tl, tr, u, v) // north fan (CTR, TR, TL): edge TL→TR at v = 0 + } else if v >= u && v >= 1.0 - u { + (bl, br, u, 1.0 - v) // south fan: edge BL→BR at v = 1 + } else if u < v { + (tl, bl, v, u) // west fan: edge TL→BL at u = 0 + } else { + (tr, br, v, 1.0 - u) // east fan: edge TR→BR at u = 1 + }; + // On the fan's plane: at edge distance `s` ∈ [0, 0.5] between the outer edge (s = 0) and the + // center (s = 0.5), the edge lerp `a→b` at parameter `t` blends toward CTR. The edge-parallel + // coordinate compresses toward the apex: lerp param `(t − s) / (1 − 2s)` spans the shrinking + // cross-section (degenerate only exactly at the apex, where the height is CTR). + let height = if s >= 0.5 { + ctr + } else { + let edge = a + (b - a) * ((t - s) / (1.0 - 2.0 * s)); + edge + (ctr - edge) * (s * 2.0) + }; + Some(height as f32) + } +} + +/// Read one IFF chunk header: 4-byte magic (stored reversed on disk) + `u32` LE size. Returns the +/// de-reversed magic (e.g. `b"MARE"`) and the body size. +fn read_chunk_header(cur: &mut Cursor<&[u8]>) -> Result<([u8; 4], u32)> { + let mut magic = [0u8; 4]; + cur.read_exact(&mut magic)?; + magic.reverse(); // on-disk is little-endian fourCC, i.e. reversed + let mut size = [0u8; 4]; + cur.read_exact(&mut size)?; + Ok((magic, u32::from_le_bytes(size))) +} + +fn parse_wdl(bytes: &[u8]) -> Result { + let mut cur = Cursor::new(bytes); + + // MVER (version 18 in vanilla). + let (magic, size) = read_chunk_header(&mut cur)?; + if &magic != b"MVER" { + bail!("expected MVER first, got {:?}", magic); + } + let mut ver = [0u8; 4]; + cur.read_exact(&mut ver)?; + let version = u32::from_le_bytes(ver); + if version != 18 { + bail!("unexpected WDL version {version} (expected 18)"); + } + cur.seek(SeekFrom::Current(size as i64 - 4))?; // skip any MVER tail (none in vanilla) + + // Skip any optional low-detail-WMO chunks (MWMO/MWID/MODF — absent in vanilla Azeroth) until MAOF. + let offsets = loop { + let (magic, size) = read_chunk_header(&mut cur)?; + if &magic == b"MAOF" { + if size as usize != 4096 * 4 { + bail!("MAOF is {size} B, expected {} (4096 u32)", 4096 * 4); + } + let mut raw = vec![0u8; size as usize]; + cur.read_exact(&mut raw)?; + let mut offs = vec![0u32; 4096]; + for (i, o) in offs.iter_mut().enumerate() { + *o = u32::from_le_bytes(raw[i * 4..i * 4 + 4].try_into().unwrap()); + } + break offs; + } + cur.seek(SeekFrom::Current(size as i64))?; + }; + + // Each non-zero MAOF entry points at that tile's MARE chunk header. + let mut tiles: Vec> = (0..4096).map(|_| None).collect(); + for (i, &off) in offsets.iter().enumerate() { + if off == 0 { + continue; + } + cur.seek(SeekFrom::Start(off as u64))?; + let (magic, size) = read_chunk_header(&mut cur)?; + if &magic != b"MARE" { + bail!("MAOF[{i}] offset {off} points at {magic:?}, expected MARE"); + } + if (size as usize) < MARE_BYTES { + bail!("MARE[{i}] is {size} B, expected ≥ {MARE_BYTES}"); + } + let mut body = vec![0u8; MARE_BYTES]; + cur.read_exact(&mut body)?; + let mut outer = [0i16; OUTER_N]; + let mut inner = [0i16; INNER_N]; + for (j, h) in outer.iter_mut().enumerate() { + *h = i16::from_le_bytes([body[j * 2], body[j * 2 + 1]]); + } + let base = OUTER_N * 2; + for (j, h) in inner.iter_mut().enumerate() { + *h = i16::from_le_bytes([body[base + j * 2], body[base + j * 2 + 1]]); + } + tiles[i] = Some(MareTile { outer, inner }); + } + + Ok(WdlFile { tiles }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tile_index_is_row_major_over_y() { + // The apitrace WDL tile was (tile_x=34, tile_y=48) -> 48*64+34. + assert_eq!(tile_index(34, 48), 48 * 64 + 34); + assert_eq!(tile_index(0, 0), 0); + assert_eq!(tile_index(63, 63), 4095); + } + + #[test] + fn mare_geometry_constants() { + assert_eq!(OUTER_N, 289); + assert_eq!(INNER_N, 256); + assert_eq!(MARE_BYTES, 1090); + assert_eq!(OUTER_N + INNER_N, 545); // verts per tile + // 16×16 cells × 4 tris × 3 indices = 3072 (the apitrace draw count). + assert_eq!(INNER_EDGE * INNER_EDGE * 12, 3072); + // One WDL cell spans one MCNK. + assert!((CHUNK_SIZE - TILE_SIZE / 16.0).abs() < 1e-3); + } + + /// `height_at` must agree with the RENDERED surface — sample it at every `tile_mesh` vertex + /// position (outer corners + inner fan centers) of a synthetic bumpy tile and require the + /// vertex's own height back. Skips the tile's south/east boundary lattice lines (those verts + /// belong to the absent neighbour tile in this synthetic file; real MARE data duplicates + /// shared edges). + #[test] + fn height_at_matches_the_drawn_mesh_exactly() { + let mut tiles: Vec> = (0..4096).map(|_| None).collect(); + let mut outer = [0i16; OUTER_N]; + let mut inner = [0i16; INNER_N]; + for (i, o) in outer.iter_mut().enumerate() { + *o = ((i * 37) % 251) as i16 - 100; + } + for (i, o) in inner.iter_mut().enumerate() { + *o = ((i * 53) % 211) as i16 - 60; + } + let (tx, ty) = (30u32, 41u32); + tiles[tile_index(tx, ty)] = Some(MareTile { outer, inner }); + let wdl = WdlFile { tiles }; + let mesh = wdl.tile_mesh(tx, ty).unwrap(); + for (i, p) in mesh.positions.iter().enumerate() { + if i < OUTER_N { + let (r, c) = (i / OUTER_EDGE, i % OUTER_EDGE); + if r == OUTER_EDGE - 1 || c == OUTER_EDGE - 1 { + continue; // neighbour-tile lattice line + } + } + let h = wdl.height_at(p[0], p[1]).unwrap(); + assert!( + (h - p[2]).abs() < 0.01, + "vertex {i} at ({}, {}): height_at {h} vs mesh {}", + p[0], + p[1], + p[2] + ); + } + // Unauthored tile → None (never "ground at 0"). + assert_eq!(wdl.height_at(0.0, 0.0), None); + } +} diff --git a/crates/benilla-formats/src/weapon_impact.rs b/crates/benilla-formats/src/weapon_impact.rs new file mode 100644 index 00000000..d8499262 --- /dev/null +++ b/crates/benilla-formats/src/weapon_impact.rs @@ -0,0 +1,141 @@ +//! `WeaponImpactSounds.dbc` — the melee impact kit table (decision 0075). +//! +//! Layout — VERIFIED against build 5875 (header + full row dump with SoundEntries name joins, +//! 2026-07-03): **30 × 23 × 92 B**: `ID(0), WeaponSubClassID(1), Metal(2), +//! ImpactSound[10](3..12), CritImpactSound[10](13..22)`. Column 2 is the weapon's own material +//! variant (subclasses appear twice — e.g. `Mace1H_*` at 0 vs `Mace1HMetal_*` at 1 — and it +//! selects the parry family: metal weapons carry `*ParryMetal*` kits). The ten slots, pinned by +//! the kit names on rows 1–3 (`Axe1H_ArmorFlesh`, `Shield Metal Impact`, +//! `1hParryMetalHitMetal`, `Axe1H_HitStone`, `Ethereal_1H`…): +//! +//! | slot | target | +//! |---|---| +//! | 0 | flesh (`*_ArmorFlesh`) | +//! | 1 | chain armor | +//! | 2 | plate armor | +//! | 3 | shield, metal (block) | +//! | 4 | shield, wood (block) | +//! | 5 | parry vs a metal weapon | +//! | 6 | parry vs a wood weapon | +//! | 7 | wood creature (`CreatureImpactType` 2) | +//! | 8 | stone creature (`CreatureImpactType` 1) | +//! | 9 | ethereal creature (`CreatureImpactType` 3) | +//! +//! The runtime mirror is the client's `AUIMPACTSOUNDARRAY` (wow-re sound node: 10 per-row slots, +//! well-known kits like `Combat_Miss_1H` cached separately at `0x4575b0`). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, u32_at}; +use crate::Chain; + +/// The impact-slot indices (module docs — pinned by the 5875 kit names). +pub mod impact_slot { + pub const FLESH: usize = 0; + pub const CHAIN: usize = 1; + pub const PLATE: usize = 2; + pub const SHIELD_METAL: usize = 3; + pub const SHIELD_WOOD: usize = 4; + pub const PARRY_METAL: usize = 5; + pub const PARRY_WOOD: usize = 6; + pub const WOOD: usize = 7; + pub const STONE: usize = 8; + pub const ETHEREAL: usize = 9; +} + +/// One weapon row: the normal and critical impact kits by target slot. +pub struct WeaponImpactRow { + pub impact: [u32; 10], + pub crit: [u32; 10], +} + +/// `(WeaponSubClassID, metal)` → the impact kits. The item-weapon subclass ids (0 Axe1H, +/// 1 Axe2H, … 7 Sword1H, 13 Fist/unarmed, 15 Dagger) all carry rows. +pub struct WeaponImpactCatalog { + rows: HashMap<(u32, bool), WeaponImpactRow>, +} + +impl WeaponImpactCatalog { + /// The row for a weapon subclass, preferring the given material variant and falling back to + /// the other (a few subclasses have only one). `None` when the subclass has no row at all + /// (wands, thrown — no melee impact). + pub fn get(&self, subclass: u32, metal: bool) -> Option<&WeaponImpactRow> { + self.rows + .get(&(subclass, metal)) + .or_else(|| self.rows.get(&(subclass, !metal))) + } + + pub fn len(&self) -> usize { + self.rows.len() + } + + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +fn schema() -> Schema { + let mut s = Schema::new("WeaponImpactSounds"); + for i in 0..23 { + s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32)); + } + s +} + +/// Read `WeaponImpactSounds.dbc` off the patch chain. +pub fn load_weapon_impact_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\WeaponImpactSounds.dbc") + .context("reading WeaponImpactSounds.dbc")?; + let rs = parse(&bytes, schema(), "WeaponImpactSounds")?; + let mut rows = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let (Some(subclass), Some(metal)) = (u32_at(r, 1), u32_at(r, 2)) else { + continue; + }; + let g = |i: usize| u32_at(r, i).unwrap_or(0); + let mut impact = [0u32; 10]; + let mut crit = [0u32; 10]; + for s in 0..10 { + impact[s] = g(3 + s); + crit[s] = g(13 + s); + } + rows.insert((subclass, metal != 0), WeaponImpactRow { impact, crit }); + } + Ok(WeaponImpactCatalog { rows }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table decodes to the byte-verified row 1 (Axe1H metal: flesh 171 / crit + /// 172, stone 3206, parry-metal 1002) and the unarmed row; the material fallback resolves a + /// single-variant subclass from either flag. Skips without client data. + #[test] + fn real_weapon_impacts_decode() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_weapon_impact_catalog(&mut chain).expect("load weapon impacts"); + assert_eq!(cat.len(), 30); + + let axe = cat.get(0, true).expect("Axe1H metal"); + assert_eq!(axe.impact[impact_slot::FLESH], 171); + assert_eq!(axe.crit[impact_slot::FLESH], 172); + assert_eq!(axe.impact[impact_slot::STONE], 3206); + assert_eq!(axe.impact[impact_slot::PARRY_METAL], 1002); + + let unarmed = cat.get(13, true).expect("Fist/unarmed"); + assert_ne!(unarmed.impact[impact_slot::FLESH], 0); + + // Fallback: bows (2) only have a non-metal row; asking for metal still resolves. + assert!(cat.get(2, true).is_some()); + } +} diff --git a/crates/benilla-formats/src/wmo_area.rs b/crates/benilla-formats/src/wmo_area.rs new file mode 100644 index 00000000..0e75a734 --- /dev/null +++ b/crates/benilla-formats/src/wmo_area.rs @@ -0,0 +1,244 @@ +//! `WMOAreaTable.dbc` — the per-building (and per-group) audio identity: what a WMO interior +//! sounds like (decision 0075). This is where 1.12's interior soundscape actually lives: the +//! Northshire Abbey monk chant is this table's whole-WMO row → `ZoneIntroMusicTable` 221, and +//! ~4 000 group rows carry interior reverb/ambience (the terrain `AreaTable` path covers only +//! open ground). +//! +//! Layout — VERIFIED against build 5875 (header + row/string decodes, 2026-07-03): +//! **21115 × 20 × 80 B**: `ID(0), WMOID(1), NameSetID(2), WMOGroupID(3, −1 = whole-WMO default), +//! SoundProviderPref(4), SoundProviderPrefUnderwater(5), AmbienceID(6), ZoneMusic(7), +//! IntroSound(8), Flags(9), … AreaName enUS(11), … locale-mask(19)` (col 10 is another locale +//! slot, empty in this data; enUS at 11 pinned by clean NUL-preceded string starts — +//! "Lion's Pride Inn", "Cathedral of Light"). +//! +//! Join keys, byte-verified against the real files: `WMOID` = the root's `MOHD.wmoID` +//! (NSabbey.wmo → 59), `WMOGroupID` = each group file's `MOGP.uniqueID` @ 0x38 (Chapel_000 → +//! 478), `NameSetID` = the ADT placement's `MODF.nameSet` (one abbey model serves Northshire / +//! Tyr's Hand by name set). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +/// One WMO area row — the audio fields (all 0 = none/inherit). +#[derive(Clone)] +pub struct WmoArea { + /// The DBC row ID (col 0) — the client's indoor **dedup key** (`[0x86860c]`, + /// wow-re `zonetext-indoor-bit.md` (d)): while the player stays in a WMO area with the same + /// row id, the zone-text updater is skipped entirely. + pub id: u32, + /// `SoundProviderPreferences` FKs, `[dry, underwater]`. + pub sound_provider: [u32; 2], + /// `SoundAmbience.dbc` FK. + pub ambience: u32, + /// `ZoneMusic.dbc` FK. + pub zone_music: u32, + /// `ZoneIntroMusicTable.dbc` FK — the entry fanfare (the abbey's monk chant). + pub intro_sound: u32, + /// `AreaTable.dbc` FK — the WORLD area this WMO group counts as (0 = none). The client's + /// GetAreaID resolver (wow-re 0x670250) reads this when the down-ray keeps the WMO over the + /// terrain chunk, so an indoor city (Ironforge/Undercity) reports its own area, not the + /// enclosing zone's. Feeds the minimap/world-map zone and interior sound. + pub area_table_id: u32, + /// The interior's display name (enUS; empty for unnamed rows). + pub name: String, +} + +/// The table keyed for the runtime lookup: exact group rows + whole-WMO defaults. +pub struct WmoAreaCatalog { + /// `(WMOID, NameSetID, WMOGroupID)` → row (group-specific). + groups: HashMap<(u32, u32, u32), WmoArea>, + /// `(WMOID, NameSetID)` → the `WMOGroupID == −1` whole-WMO row. + defaults: HashMap<(u32, u32), WmoArea>, +} + +impl WmoAreaCatalog { + /// The hit group's OWN row, **exact key** — the zone-text chain's **query B** (`0x69d8f0` → + /// bsearch `0x7ccd30`, wow-re `zonetext-indoor-bit.md` (d)/(d-ii): a single exact + /// `(WMOID, NameSetID, WMOGroupID)` search, **no NameSetID retry, no default fallback, no + /// overlay**): its name re-populates the subzone slot (an unnamed/missing row leaves the + /// slot as the override set it). + pub fn group_row(&self, wmo_id: u32, name_set: u32, group_id: u32) -> Option<&WmoArea> { + self.groups.get(&(wmo_id, name_set, group_id)) + } + + /// The whole-WMO `WMOGroupID == −1` row, **exact key** (same single-search law) — the + /// zone-text chain's **query A** (`0x69d830` → `0x7ccde0`, `push 0xffffffff`): its name + /// overrides the ZONE slot only when non-empty and different from the current subzone (the + /// inn splash; skipped at the abbey, whose default name equals its yard subzone). An + /// EXISTING-but-unnamed row falls back to an AreaTable name (the caller's job — it needs the + /// area catalog); a MISSING row never overrides. + pub fn default_row(&self, wmo_id: u32, name_set: u32) -> Option<&WmoArea> { + self.defaults.get(&(wmo_id, name_set)) + } + + /// Resolve a WMO interior's audio identity as a **field-level overlay**: the group row's + /// nonzero fields over the whole-WMO (−1) default's. Data demands this — the abbey's chant + /// (`IntroSound` 221), music (204) and ambience (26) all live on its −1 row while the group + /// rows the player actually stands in ("Main Hall" 1934…) are near-empty; a row-level pick + /// returned the zeros and silenced the abbey (director-caught, 2026-07-03). Name-set + /// fallback to 0 when the placement's set has no rows (INTERIM — the client's name-set miss + /// behavior is unrecorded; set-0 rows exist for every audio-carrying WMO). + pub fn resolve(&self, wmo_id: u32, name_set: u32, group_id: u32) -> Option { + let (group, default) = [name_set, 0] + .iter() + .map(|&ns| { + ( + self.groups.get(&(wmo_id, ns, group_id)), + self.defaults.get(&(wmo_id, ns)), + ) + }) + .find(|(g, d)| g.is_some() || d.is_some())?; + let base = default.cloned().unwrap_or_else(|| WmoArea { + id: 0, + sound_provider: [0, 0], + ambience: 0, + zone_music: 0, + intro_sound: 0, + area_table_id: 0, + name: String::new(), + }); + let Some(g) = group else { return Some(base) }; + let nz = |v: u32, b: u32| if v != 0 { v } else { b }; + Some(WmoArea { + id: g.id, + sound_provider: [ + nz(g.sound_provider[0], base.sound_provider[0]), + nz(g.sound_provider[1], base.sound_provider[1]), + ], + ambience: nz(g.ambience, base.ambience), + zone_music: nz(g.zone_music, base.zone_music), + intro_sound: nz(g.intro_sound, base.intro_sound), + area_table_id: nz(g.area_table_id, base.area_table_id), + name: if g.name.is_empty() { + base.name + } else { + g.name.clone() + }, + }) + } + + pub fn len(&self) -> usize { + self.groups.len() + self.defaults.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn schema() -> Schema { + let mut s = Schema::new("WMOAreaTable"); + for name in [ + "ID", + "WMOID", + "NameSetID", + "WMOGroupID", + "SoundProviderPref", + "SoundProviderPrefUnderwater", + "AmbienceID", + "ZoneMusic", + "IntroSound", + "Flags", + "AreaTableID", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new("AreaName", FieldType::String)); + for i in 12..20 { + s.add_field(SchemaField::new(format!("_pad{i}"), FieldType::UInt32)); + } + s +} + +/// Read `WMOAreaTable.dbc` off the patch chain. +pub fn load_wmo_area_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file("DBFilesClient\\WMOAreaTable.dbc") + .context("reading WMOAreaTable.dbc")?; + let rs = parse(&bytes, schema(), "WMOAreaTable")?; + let mut groups = HashMap::new(); + let mut defaults = HashMap::new(); + for r in rs.records() { + let wmo_id = u32_at(r, 1).unwrap_or(0); + let name_set = u32_at(r, 2).unwrap_or(0); + let group = u32_at(r, 3).unwrap_or(0); + let area = WmoArea { + id: u32_at(r, 0).unwrap_or(0), + sound_provider: [u32_at(r, 4).unwrap_or(0), u32_at(r, 5).unwrap_or(0)], + ambience: u32_at(r, 6).unwrap_or(0), + zone_music: u32_at(r, 7).unwrap_or(0), + intro_sound: u32_at(r, 8).unwrap_or(0), + area_table_id: u32_at(r, 10).unwrap_or(0), + name: str_at(&rs, r, 11).unwrap_or_default(), + }; + if group == u32::MAX { + defaults.insert((wmo_id, name_set), area); + } else { + groups.insert((wmo_id, name_set, group), area); + } + } + Ok(WmoAreaCatalog { groups, defaults }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table resolves the two byte-verified anchors: the abbey's whole-WMO monk + /// chant (WMO 59 → intro 221 "Sacred01") and the chapel's interior group row (WMO 656 group + /// 478 → CAVE reverb 75 + ambience 50). Skips without client data. + #[test] + fn real_wmo_area_resolves_abbey_and_chapel() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_wmo_area_catalog(&mut chain).expect("load WMOAreaTable"); + // 21115 rows, 21105 distinct (wmo, nameset, group) keys — 10 exact-duplicate keys are + // editor debris in the 5875 data (e.g. WMO 556 nameset 1 rows appear twice). + assert_eq!(cat.len(), 21105, "all distinct rows load"); + + // NSabbey (59): any interior group falls back to the whole-WMO row = the monk chant. + let abbey = cat.resolve(59, 0, 9999).expect("abbey default row"); + assert_eq!(abbey.intro_sound, 221, "Sacred01 intro"); + + // AreaTableID (field 10) — the WORLD area a WMO group counts as. The abbey model is reused; + // its nameset-0 "Main Hall" (group 1934) sits at Alexston Farmstead (area 219), proving the + // column parses real AreaTable ids (not the all-zero Flags at field 9). This is what lets an + // indoor city report its own area — a raw scan finds 186 group rows → Ironforge (1537) and + // 219 → Undercity (1497). + assert_eq!( + cat.resolve(59, 0, 1934).unwrap().area_table_id, + 219, + "the group's AreaTableID resolves (field 10, not the zeroed field 9)" + ); + + // The overlay (the director-caught silence): standing in the abbey's "Main Hall" + // (group 1934, a near-empty row) must still hear the whole-WMO identity. + let hall = cat.resolve(59, 0, 1934).expect("Main Hall"); + assert_eq!(hall.intro_sound, 221, "the chant reaches the hall"); + assert_eq!(hall.zone_music, 204); + assert_eq!(hall.ambience, 26); + assert_eq!(hall.name, "Main Hall", "the group's own name wins"); + + // Chapel (656) interior group 478: CAVE reverb + interior ambience overlaying the + // whole-WMO row's underwater pref; no music anywhere on this WMO. + let chapel = cat.resolve(656, 0, 478).expect("chapel group row"); + assert_eq!(chapel.sound_provider, [75, 11]); + assert_eq!(chapel.ambience, 50); + assert_eq!(chapel.zone_music, 0); + + // A named row reads through the enUS column. + let inn = cat + .resolve(53, 2, 9999) + .or_else(|| cat.resolve(53, 0, 9999)); + assert!(inn.is_some(), "the Goldshire inn WMO has rows"); + } +} diff --git a/crates/benilla-formats/src/world_map_area.rs b/crates/benilla-formats/src/world_map_area.rs new file mode 100644 index 00000000..18a0b47b --- /dev/null +++ b/crates/benilla-formats/src/world_map_area.rs @@ -0,0 +1,195 @@ +//! `WorldMapArea.dbc` — the world-map projection basis: for each map-UI "area" (a continent as a +//! whole, or one zone/instance's own map), the art folder under `Interface\WorldMap\\` and +//! the world-coordinate rect that folder's texture covers (decision 0203's `map_proj`, phase 2, +//! reads this rect to lerp between world space and normalized map UV space). +//! +//! Layout — VERIFIED against build 5875 (header + full 51-row decode, cross-checked against +//! vmangos's `WorldMapAreaEntry` (`src/game/Database/DBCStructure.h:737`), 2026-07-07): **51 × 8 × +//! 32 B**: `ID(0), MapID(1), AreaID(2, 0 = the continent-wide row), AreaName(3, string — the +//! `Interface\WorldMap\\` folder), LocLeft(4), LocRight(5), LocTop(6), LocBottom(7)` (4× +//! `f32`). vmangos's own struct skips `ID` (commented out, unused server-side) and names the loc +//! quad `y1/y2/x1/x2`; every other field lines up 1:1. Byte-exact on the two rows that matter for +//! phase 2's continent basis: id 14 → `(mapId 0, areaId 0, "Azeroth", [16000.0, -19199.9, 7466.6, +//! -16000.0])`, id 13 → `(mapId 1, areaId 0, "Kalimdor", ...)`; a zone row, id 4 → `(mapId 1, +//! areaId 14, "Durotar", [-1962.5, -7250.0, 1808.3, -1716.7])`, whose `AreaName` is confirmed (this +//! session) to be a real `Interface\WorldMap\Durotar\Durotar1.blp` art folder in the chain. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const WORLD_MAP_AREA: &str = "DBFilesClient\\WorldMapArea.dbc"; + +/// One `WorldMapArea.dbc` row: a map-UI "area" (continent or zone) and the world rect its +/// `Interface\WorldMap\\` art covers. +#[derive(Clone, Debug)] +pub struct WorldMapArea { + /// `Map.dbc` id this row belongs to. + pub map_id: u32, + /// `AreaTable.dbc` id for a zone row; `0` for the continent-wide row (Azeroth/Kalimdor/…). + pub area_id: u32, + /// The `Interface\WorldMap\\` art folder (also the client-visible internal name). + pub name: String, + /// World-coordinate rect this area's map art covers (vmangos: `y1/y2/x1/x2` — WoW's world X is + /// "top/bottom", world Y is "left/right"; see the module doc for the verified sample values). + pub loc_left: f32, + pub loc_right: f32, + pub loc_top: f32, + pub loc_bottom: f32, +} + +/// `WorldMapArea.dbc` rows keyed by `ID` — the id `WorldMapOverlay.worldMapAreaId` joins against. +/// File order is preserved ([`WorldMapAreaCatalog::iter`]): the client's continent index IS the +/// on-disk order of the areaId==0 rows (Kalimdor before Azeroth in 5875 — wow-re Q1(d) verdict, +/// the builder `0x4a5d00` walks rows in file order). +pub struct WorldMapAreaCatalog { + by_id: HashMap, + /// Row ids in on-disk record order. + file_order: Vec, +} + +impl WorldMapAreaCatalog { + /// The row for `id` (the `WorldMapArea.dbc` primary key), or `None`. + pub fn get(&self, id: u32) -> Option<&WorldMapArea> { + self.by_id.get(&id) + } + + /// The continent-wide row (`area_id == 0`) for `map_id` — the "world" projection basis + /// (decision 0203, `map_proj`'s continent mode). Exactly one such row per continent in 5875 + /// (Azeroth id 14, Kalimdor id 13). + pub fn continent(&self, map_id: u32) -> Option<(u32, &WorldMapArea)> { + self.by_id + .iter() + .find(|(_, a)| a.map_id == map_id && a.area_id == 0) + .map(|(&id, a)| (id, a)) + } + + /// All rows as `(id, row)`, in **file order** — the order the client's continent index is + /// built in (see the struct doc). Zone consumers re-sort by display name anyway. + pub fn iter(&self) -> impl Iterator { + self.file_order + .iter() + .filter_map(|id| self.by_id.get(id).map(|a| (*id, a))) + } + + pub fn len(&self) -> usize { + self.by_id.len() + } + + pub fn is_empty(&self) -> bool { + self.by_id.is_empty() + } +} + +/// 8 fields: `ID, MapID, AreaID, AreaName, LocLeft, LocRight, LocTop, LocBottom`. +fn schema() -> Schema { + let mut s = Schema::new("WorldMapArea"); + for name in ["ID", "MapID", "AreaID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new("AreaName", FieldType::String)); + for name in ["LocLeft", "LocRight", "LocTop", "LocBottom"] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + s +} + +/// Read `WorldMapArea.dbc` off the patch chain into a [`WorldMapAreaCatalog`]. +pub fn load_world_map_area_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(WORLD_MAP_AREA) + .context("reading WorldMapArea.dbc")?; + let rs = parse(&bytes, schema(), "WorldMapArea")?; + let mut by_id = HashMap::with_capacity(rs.records().len()); + let mut file_order = Vec::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(id) = u32_at(r, 0) else { continue }; + let ( + Some(map_id), + Some(area_id), + Some(name), + Some(loc_left), + Some(loc_right), + Some(loc_top), + Some(loc_bottom), + ) = ( + u32_at(r, 1), + u32_at(r, 2), + str_at(&rs, r, 3), + f32_at(r, 4), + f32_at(r, 5), + f32_at(r, 6), + f32_at(r, 7), + ) + else { + continue; + }; + file_order.push(id); + by_id.insert( + id, + WorldMapArea { + map_id, + area_id, + name, + loc_left, + loc_right, + loc_top, + loc_bottom, + }, + ); + } + Ok(WorldMapAreaCatalog { by_id, file_order }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: the continent rows (Azeroth/Kalimdor) plus Durotar's verified rect, + /// and the zone row's `name` really is a `Interface\WorldMap\\` art folder in the chain + /// (proving `AreaName` is the art folder, not just a label). Skips without client data. + #[test] + fn real_world_map_area_has_continents_and_durotar_and_art_folder() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_world_map_area_catalog(&mut chain).expect("load WorldMapArea"); + assert_eq!(cat.len(), 51, "all 51 rows load"); + + let azeroth = cat.get(14).expect("Azeroth continent row (id 14)"); + assert_eq!((azeroth.map_id, azeroth.area_id), (0, 0)); + assert_eq!(azeroth.name, "Azeroth"); + + let kalimdor = cat.get(13).expect("Kalimdor continent row (id 13)"); + assert_eq!((kalimdor.map_id, kalimdor.area_id), (1, 0)); + assert_eq!(kalimdor.name, "Kalimdor"); + + // `continent()` resolves the same two rows by map_id alone. + let (azeroth_id, _) = cat.continent(0).expect("Azeroth via continent(0)"); + assert_eq!(azeroth_id, 14); + let (kalimdor_id, _) = cat.continent(1).expect("Kalimdor via continent(1)"); + assert_eq!(kalimdor_id, 13); + + let durotar = cat.get(4).expect("Durotar row (id 4)"); + assert_eq!((durotar.map_id, durotar.area_id), (1, 14)); + assert_eq!(durotar.name, "Durotar"); + assert!((durotar.loc_left - (-1962.5)).abs() < 0.1); + assert!((durotar.loc_right - (-7250.0)).abs() < 0.1); + assert!((durotar.loc_top - 1808.3).abs() < 0.1); + assert!((durotar.loc_bottom - (-1716.7)).abs() < 0.1); + + // The zone row's name really is the `Interface\WorldMap\\` art folder. + let art = format!("Interface\\WorldMap\\{0}\\{0}1.blp", durotar.name); + assert!( + chain.contains(&art), + "Durotar's WorldMapArea name resolves to its own art folder: {art}" + ); + } +} diff --git a/crates/benilla-formats/src/world_map_continent.rs b/crates/benilla-formats/src/world_map_continent.rs new file mode 100644 index 00000000..cd721829 --- /dev/null +++ b/crates/benilla-formats/src/world_map_continent.rs @@ -0,0 +1,194 @@ +//! `WorldMapContinent.dbc` — one row per continent (Azeroth, Kalimdor) framing the continent-level +//! map art: which ADT tile-grid span it covers, its placement offset/scale in the shared world-map +//! coordinate space, and the taxi-map's own bounding rect (decision 0203 phase 2's continent view). +//! +//! **Client-only: vmangos carries no struct for this table** (never loaded server-side). Layout +//! **VERIFIED against the 5875 binary** (wow-re Q2 verdict, 2026-07-07, recorded in +//! `system/ui/scratch/geometry.md` "Worldmap data model"): **2 × 13 × 52 B** — +//! `ID(0), MapID(1)`; fields 2-5 = ADT tile bounds (ints; the world-map builder `0x4a5d00` +//! converts them to each continent's normalized-UV sheet rect — the world-level click AABB; +//! `map_proj::continent_sheet_rect` transcribes the kernel); fields 6/7 = the per-axis +//! world-sheet offsets and field 8 = the scale, exactly the values `0x4a72b0`/`0x4a7360` +//! world-mode read at record `+0x18/+0x1c/+0x20` (5875: EK `{14.5, −7.0, 0.75}`, Kalimdor +//! `{−19.0, −0.32249799, 0.75}`); fields 9-12 = the taxi-map world rect, **unused** by all four +//! projection functions. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, u32_at}; +use crate::Chain; + +const WORLD_MAP_CONTINENT: &str = "DBFilesClient\\WorldMapContinent.dbc"; + +/// One `WorldMapContinent.dbc` row (all fields decomp-verified — see the module doc). +#[derive(Clone, Debug)] +pub struct WorldMapContinent { + /// `Map.dbc` id (`0` = Azeroth, `1` = Kalimdor in 5875). + pub map_id: u32, + /// ADT tile-grid column bounds — the `0x4a5d00` sheet-rect kernel's inputs (see module doc). + pub left_boundary: u32, + pub right_boundary: u32, + /// ADT tile-grid row bounds. + pub top_boundary: u32, + pub bottom_boundary: u32, + /// The continent's world-sheet offsets — the `f` terms of the world-mode projections + /// (`0x4a7360`: `u = offset_x/62.625 + 0.5 − wy·k·scale`), x → the u axis, y → v. + pub offset_x: f32, + pub offset_y: f32, + /// The world-sheet scale the projections multiply by (`0.75` on both 5875 rows — there is + /// no separate world-level zoom variable). + pub scale: f32, + /// The taxi-map's own bounding rect in world units — carried for the future taxi layer; + /// unused by the map projections. + pub taxi_min: (f32, f32), + pub taxi_max: (f32, f32), +} + +/// `WorldMapContinent.dbc` rows keyed by `MapID`. +pub struct WorldMapContinentCatalog { + by_map_id: HashMap, +} + +impl WorldMapContinentCatalog { + pub fn get(&self, map_id: u32) -> Option<&WorldMapContinent> { + self.by_map_id.get(&map_id) + } + + pub fn len(&self) -> usize { + self.by_map_id.len() + } + + pub fn is_empty(&self) -> bool { + self.by_map_id.is_empty() + } +} + +/// 13 fields: `ID, MapID, LeftBoundary, RightBoundary, TopBoundary, BottomBoundary, OffsetX, +/// OffsetY, Scale, TaxiMinX, TaxiMinY, TaxiMaxX, TaxiMaxY` (see the module doc for confidence). +fn schema() -> Schema { + let mut s = Schema::new("WorldMapContinent"); + for name in [ + "ID", + "MapID", + "LeftBoundary", + "RightBoundary", + "TopBoundary", + "BottomBoundary", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + for name in [ + "OffsetX", "OffsetY", "Scale", "TaxiMinX", "TaxiMinY", "TaxiMaxX", "TaxiMaxY", + ] { + s.add_field(SchemaField::new(name, FieldType::Float32)); + } + s +} + +/// Read `WorldMapContinent.dbc` off the patch chain into a [`WorldMapContinentCatalog`]. +pub fn load_world_map_continent_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(WORLD_MAP_CONTINENT) + .context("reading WorldMapContinent.dbc")?; + let rs = parse(&bytes, schema(), "WorldMapContinent")?; + let mut by_map_id = HashMap::with_capacity(rs.records().len()); + for r in rs.records() { + let Some(map_id) = u32_at(r, 1) else { continue }; + let ( + Some(left_boundary), + Some(right_boundary), + Some(top_boundary), + Some(bottom_boundary), + Some(offset_x), + Some(offset_y), + Some(scale), + Some(taxi_min_x), + Some(taxi_min_y), + Some(taxi_max_x), + Some(taxi_max_y), + ) = ( + u32_at(r, 2), + u32_at(r, 3), + u32_at(r, 4), + u32_at(r, 5), + f32_at(r, 6), + f32_at(r, 7), + f32_at(r, 8), + f32_at(r, 9), + f32_at(r, 10), + f32_at(r, 11), + f32_at(r, 12), + ) + else { + continue; + }; + by_map_id.insert( + map_id, + WorldMapContinent { + map_id, + left_boundary, + right_boundary, + top_boundary, + bottom_boundary, + offset_x, + offset_y, + scale, + taxi_min: (taxi_min_x, taxi_min_y), + taxi_max: (taxi_max_x, taxi_max_y), + }, + ); + } + Ok(WorldMapContinentCatalog { by_map_id }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: exactly the two verified rows (Azeroth mapId 0, Kalimdor mapId 1), + /// byte-exact on every field. Skips without client data. + #[test] + fn real_world_map_continent_has_azeroth_and_kalimdor() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_world_map_continent_catalog(&mut chain).expect("load WorldMapContinent"); + assert_eq!(cat.len(), 2); + + let azeroth = cat.get(0).expect("Azeroth (mapId 0)"); + assert_eq!( + ( + azeroth.left_boundary, + azeroth.right_boundary, + azeroth.top_boundary, + azeroth.bottom_boundary + ), + (23, 47, 15, 61) + ); + assert!((azeroth.offset_x - 14.5).abs() < 0.01); + assert!((azeroth.offset_y - (-7.0)).abs() < 0.01); + assert!((azeroth.scale - 0.75).abs() < 0.001); + assert!((azeroth.taxi_min.0 - (-15980.0)).abs() < 0.1); + assert!((azeroth.taxi_min.1 - (-11880.0)).abs() < 0.1); + assert!((azeroth.taxi_max.0 - 5817.0).abs() < 0.1); + assert!((azeroth.taxi_max.1 - 9917.0).abs() < 0.1); + + let kalimdor = cat.get(1).expect("Kalimdor (mapId 1)"); + assert_eq!( + ( + kalimdor.left_boundary, + kalimdor.right_boundary, + kalimdor.top_boundary, + kalimdor.bottom_boundary + ), + (23, 48, 9, 52) + ); + assert!((kalimdor.scale - 0.75).abs() < 0.001); + } +} diff --git a/crates/benilla-formats/src/world_map_overlay.rs b/crates/benilla-formats/src/world_map_overlay.rs new file mode 100644 index 00000000..ee6b98fe --- /dev/null +++ b/crates/benilla-formats/src/world_map_overlay.rs @@ -0,0 +1,171 @@ +//! `WorldMapOverlay.dbc` — the per-area zoomed-in overlay art: for a `WorldMapArea` row, up to four +//! `AreaTable` sub-regions plus the texture (and its placement/hit-rect) that draws over the base +//! map when a sub-region is discovered or its highlight is hovered (decision 0203 phase 3's +//! discovery overlays + phase 2's `map_proj` overlay hit-test). +//! +//! Layout — VERIFIED against build 5875 (header + full 526-row decode, cross-checked against +//! vmangos's *commented-out* `WorldMapOverlayEntry` (`src/game/Database/DBCStructure.h:752` — the +//! struct only keeps `ID`/`areatableID[4]`, the rest is server-unused and left as index comments) +//! and independently against a community reference struct for the same build, 2026-07-07: **526 × +//! 17 × 68 B**: `ID(0), WorldMapAreaID(1), AreaID[4](2-5), MapPointX(6), MapPointY(7), +//! TextureName(8, string), TextureWidth(9), TextureHeight(10), OffsetX(11), OffsetY(12), +//! HitRectTop(13), HitRectLeft(14), HitRectBottom(15), HitRectRight(16)`. Sanity-checked over all +//! 526 rows: `HitRectTop < HitRectBottom` holds for 525/526, `HitRectLeft < HitRectRight` holds for +//! all 526, and `TextureWidth`/`TextureHeight` track the hit-rect span's rough magnitude — all +//! consistent with the field identities above. `MapPointX`/`MapPointY` are `0` on **every** row in +//! this data (matches vmangos's own `// to implement [?]` note on the struct: authored but unused +//! in 1.12). + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{parse, str_at, u32_at}; +use crate::Chain; + +const WORLD_MAP_OVERLAY: &str = "DBFilesClient\\WorldMapOverlay.dbc"; + +/// One `WorldMapOverlay.dbc` row: a sub-region's overlay texture + placement. +#[derive(Clone, Debug)] +pub struct WorldMapOverlay { + /// `WorldMapArea.dbc` id this overlay draws over. + pub world_map_area_id: u32, + /// Up to 4 `AreaTable.dbc` ids this overlay's highlight covers (`0` = unused slot). + pub area_id: [u32; 4], + /// Always `0` in 5875 — an authored-but-unused map-point (see the module doc). + pub map_point_x: u32, + pub map_point_y: u32, + /// The overlay texture's base name (no path/extension) under + /// `Interface\WorldMap\\`. + pub texture_name: String, + pub texture_width: u32, + pub texture_height: u32, + pub offset_x: u32, + pub offset_y: u32, + pub hit_rect_top: u32, + pub hit_rect_left: u32, + pub hit_rect_bottom: u32, + pub hit_rect_right: u32, +} + +/// `WorldMapOverlay.dbc` rows grouped by `WorldMapAreaID` — the join key against +/// [`crate::WorldMapAreaCatalog`]. +pub struct WorldMapOverlayCatalog { + by_area: HashMap>, +} + +impl WorldMapOverlayCatalog { + /// The overlays for `world_map_area_id`, or an empty slice if none. + pub fn for_area(&self, world_map_area_id: u32) -> &[WorldMapOverlay] { + self.by_area + .get(&world_map_area_id) + .map(Vec::as_slice) + .unwrap_or(&[]) + } + + pub fn len(&self) -> usize { + self.by_area.values().map(Vec::len).sum() + } + + pub fn is_empty(&self) -> bool { + self.by_area.is_empty() + } +} + +/// 17 fields per the module doc. +fn schema() -> Schema { + let mut s = Schema::new("WorldMapOverlay"); + for name in ["ID", "WorldMapAreaID"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new_array("AreaID", FieldType::UInt32, 4)); + for name in ["MapPointX", "MapPointY"] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s.add_field(SchemaField::new("TextureName", FieldType::String)); + for name in [ + "TextureWidth", + "TextureHeight", + "OffsetX", + "OffsetY", + "HitRectTop", + "HitRectLeft", + "HitRectBottom", + "HitRectRight", + ] { + s.add_field(SchemaField::new(name, FieldType::UInt32)); + } + s +} + +/// Read `WorldMapOverlay.dbc` off the patch chain into a [`WorldMapOverlayCatalog`]. +pub fn load_world_map_overlay_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(WORLD_MAP_OVERLAY) + .context("reading WorldMapOverlay.dbc")?; + let rs = parse(&bytes, schema(), "WorldMapOverlay")?; + let mut by_area: HashMap> = HashMap::new(); + for r in rs.records() { + let Some(world_map_area_id) = u32_at(r, 1) else { + continue; + }; + let area_id = [ + u32_at(r, 2).unwrap_or(0), + u32_at(r, 3).unwrap_or(0), + u32_at(r, 4).unwrap_or(0), + u32_at(r, 5).unwrap_or(0), + ]; + let overlay = WorldMapOverlay { + world_map_area_id, + area_id, + map_point_x: u32_at(r, 6).unwrap_or(0), + map_point_y: u32_at(r, 7).unwrap_or(0), + texture_name: str_at(&rs, r, 8).unwrap_or_default(), + texture_width: u32_at(r, 9).unwrap_or(0), + texture_height: u32_at(r, 10).unwrap_or(0), + offset_x: u32_at(r, 11).unwrap_or(0), + offset_y: u32_at(r, 12).unwrap_or(0), + hit_rect_top: u32_at(r, 13).unwrap_or(0), + hit_rect_left: u32_at(r, 14).unwrap_or(0), + hit_rect_bottom: u32_at(r, 15).unwrap_or(0), + hit_rect_right: u32_at(r, 16).unwrap_or(0), + }; + by_area.entry(world_map_area_id).or_default().push(overlay); + } + Ok(WorldMapOverlayCatalog { by_area }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real 5875 table: Teldrassil's `WorldMapArea` id (41) carries overlays with non-empty + /// texture names (e.g. the "Bane Thillhollow"/"Darnassus" sub-zone highlights). Skips without + /// client data. + #[test] + fn real_world_map_overlay_has_nonempty_textures_for_teldrassil() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_world_map_overlay_catalog(&mut chain).expect("load WorldMapOverlay"); + assert_eq!(cat.len(), 526, "all 526 rows load"); + + let teldrassil = cat.for_area(41); + assert!( + !teldrassil.is_empty(), + "Teldrassil (WorldMapArea 41) has overlay rows" + ); + assert!( + teldrassil.iter().all(|o| !o.texture_name.is_empty()), + "every overlay row names a texture" + ); + assert!( + teldrassil.iter().any(|o| o.texture_name == "DARNASSUS"), + "the Darnassus sub-zone highlight is present" + ); + } +} diff --git a/crates/benilla-formats/src/zone_map.rs b/crates/benilla-formats/src/zone_map.rs new file mode 100644 index 00000000..825a1c1d --- /dev/null +++ b/crates/benilla-formats/src/zone_map.rs @@ -0,0 +1,66 @@ +//! `Interface\WorldMap\.zmp` — the continent area bitmap: a 128×128 grid of +//! `AreaTable.dbc` row ids (16384 little-endian u32, row-major, exactly 65536 bytes) covering the +//! whole 64×64-ADT world at half-ADT granularity. The client's world-map hover/click resolves a +//! cursor cell to a zone through it (`0x4a6ec0`; the loader `0x4a5d00` remaps cells to +//! WorldMapArea ids at load — benilla's remap lives with the catalog build, `ui_world_map`). +//! +//! Mechanism VERIFIED by wow-re (Q1 §5 cross-check, 2026-07-07, recorded in +//! `system/ui/scratch/geometry.md` "Worldmap data model"): path format string @0x845374 +//! (`Interface\WorldMap\%s.zmp`, `%s` = the continent WorldMapArea row's AreaName), 0x10000 bytes +//! read raw into the record. Corroborated against the shipped file this session: Azeroth.zmp's +//! cell at the recorded index law for Goldshire's world position holds 12 = Elwynn Forest. +//! 5875 ships `Azeroth.zmp` + `Kalimdor.zmp` (and a dead `Expansion01.zmp`). + +use anyhow::{bail, Context, Result}; + +use crate::chain::Chain; + +/// Cells per grid edge (half-ADT: 128 cells over 64 tiles). +pub const ZONE_MAP_EDGE: usize = 128; + +/// Load `Interface\WorldMap\.zmp` — 16384 raw `AreaTable.dbc` ids, row-major. +/// A continent without a shipped bitmap is an error (the client leaves its grid zeroed; callers +/// decide whether that's tolerable). +pub fn load_zone_map(chain: &mut Chain, area_name: &str) -> Result> { + let path = format!("Interface\\WorldMap\\{area_name}.zmp"); + let bytes = chain + .read_file(&path) + .with_context(|| format!("reading {path}"))?; + if bytes.len() != ZONE_MAP_EDGE * ZONE_MAP_EDGE * 4 { + bail!( + "{path}: expected {} bytes (128×128 u32), got {}", + ZONE_MAP_EDGE * ZONE_MAP_EDGE * 4, + bytes.len() + ); + } + Ok(bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real Azeroth bitmap: the Goldshire cell (index by the byte law: col from wy=60, row + /// from wx=−9450 ⇒ cell 12735) holds Elwynn Forest's AreaTable id — the end-to-end pin of + /// path, layout, and index law against shipped data. Skips without client data. + #[test] + fn real_azeroth_zone_map_goldshire_cell() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let grid = load_zone_map(&mut chain, "Azeroth").expect("load Azeroth.zmp"); + assert_eq!(grid.len(), 16384); + assert_eq!(grid[12735], 12, "the Goldshire cell is Elwynn Forest"); + let nonzero = grid.iter().filter(|&&v| v != 0).count(); + assert!( + (2000..6000).contains(&nonzero), + "land cells in a plausible band, got {nonzero}" + ); + } +} diff --git a/crates/benilla-formats/tests/billboards.rs b/crates/benilla-formats/tests/billboards.rs new file mode 100644 index 00000000..b563b9df --- /dev/null +++ b/crates/benilla-formats/tests/billboards.rs @@ -0,0 +1,86 @@ +//! Regression: an M2 batch that packs several billboard glow cards on different bones must be split +//! into one submesh per billboard bone, each centred on its own bone pivot — so the renderer rotates +//! each card about its own candle (faces the camera in place) instead of swinging the whole cluster +//! about a single pivot. See decision 0028. Skips (passes) when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{load_m2_mesh, open_chain, RenderSubmesh}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// The geometric centre of a submesh's vertices (model space). +fn geom_center(s: &RenderSubmesh) -> [f32; 3] { + let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &s.positions { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + [ + (lo[0] + hi[0]) * 0.5, + (lo[1] + hi[1]) * 0.5, + (lo[2] + hi[2]) * 0.5, + ] +} + +#[test] +fn candelabra_glow_cards_split_per_bone() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + // CandelabraTallWall01 has five candles; all five glow cards share one additive glow-texture batch, + // each quad skinned to its own per-candle billboard bone. + let subs = load_m2_mesh( + &mut chain, + "World\\Generic\\PassiveDoodads\\Lights\\CandelabraTallWall01.m2", + ) + .expect("load CandelabraTallWall01.m2"); + + let cards: Vec<&RenderSubmesh> = subs.iter().filter(|s| s.billboard.is_some()).collect(); + assert_eq!( + cards.len(), + 5, + "the one glow batch must split into five per-candle billboard cards, got {}", + cards.len() + ); + + let mut pivots: Vec<[f32; 3]> = Vec::new(); + for c in &cards { + let bb = c.billboard.as_ref().unwrap(); + // Each split card is a single quad (4 verts / 2 tris) … + assert_eq!(c.positions.len(), 4, "a glow card is one quad (4 verts)"); + assert_eq!(c.indices.len(), 6, "a glow card is one quad (2 tris)"); + // … centred on its OWN bone pivot, so it faces the camera and pulses in place (no swing/slide). + let ctr = geom_center(c); + let d = [ + ctr[0] - bb.pivot[0], + ctr[1] - bb.pivot[1], + ctr[2] - bb.pivot[2], + ]; + let off = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + assert!( + off < 0.05, + "card geometry must sit on its pivot (off={off:.3} yd, pivot={:?})", + bb.pivot + ); + pivots.push(bb.pivot); + } + + // The five pivots are five distinct candle positions (no two cards collapsed onto one bone). + for (i, a) in pivots.iter().enumerate() { + for b in &pivots[i + 1..] { + let spread = (a[1] - b[1]).abs() + (a[2] - b[2]).abs(); + assert!( + spread > 0.05, + "two cards collapsed onto the same pivot: {a:?} ~ {b:?}" + ); + } + } +} diff --git a/crates/benilla-formats/tests/blob_shadow_box.rs b/crates/benilla-formats/tests/blob_shadow_box.rs new file mode 100644 index 00000000..d4c34aff --- /dev/null +++ b/crates/benilla-formats/tests/blob_shadow_box.rs @@ -0,0 +1,59 @@ +//! Pins the unit blob shadow's box source against real build-5875 assets: the shadow sizes from +//! the **current animation's** `M2Sequence` CAaBox (wow-re `unit-blob-shadow.md`, `0x711a20` — the +//! same sequence-record box the pick volume and the ring's Stand-footprint read), so +//! `ModelAnimation::bounds_min/max` must round-trip the raw record. The Stand-box horizontal +//! extents of the four reference-traced creatures are already pinned by +//! `selection_ring_radius.rs` *through* the nested-sqrt footprint; this asserts the **box itself** +//! (the shadow consumes it raw: clamp ±5, scale, AA-bound — no compression). Skips when the +//! gitignored client data isn't present. + +use benilla_formats::{open_chain, parse_m2_animations}; + +#[test] +fn stand_box_extents_match_reference() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + + // (model, Stand-box horizontal extents (dx, dy) — the wow-re selection-ring RE's measured + // table, read straight off the real sequence records). + let cases = [ + ("Creature\\Chicken\\Chicken.m2", 0.532_f32, 0.382_f32), + ("Character\\Human\\Male\\HumanMale.m2", 0.913, 1.080), + ]; + for (path, dx, dy) in cases { + let bytes = chain.read_file(path).expect(path); + let anims = parse_m2_animations(&bytes); + // Stand (anim id 0)'s head variation — the box the idle shadow projects. + let stand = anims + .iter() + .find(|a| a.anim_id == 0) + .unwrap_or_else(|| panic!("{path}: no Stand sequence")); + let (bmin, bmax) = (stand.bounds_min, stand.bounds_max); + assert!( + (bmax[0] - bmin[0] - dx).abs() < 0.01, + "{path}: Stand box dx {:.3} != reference {dx}", + bmax[0] - bmin[0] + ); + assert!( + (bmax[1] - bmin[1] - dy).abs() < 0.01, + "{path}: Stand box dy {:.3} != reference {dy}", + bmax[1] - bmin[1] + ); + // The box is a real volume: a degenerate Z extent would zero the shadow's vertical reach. + assert!( + bmax[2] > bmin[2], + "{path}: Stand box has no vertical extent" + ); + // And the centre derives from the same corners (the sphere fields already shipped). + for i in 0..3 { + assert!( + ((bmin[i] + bmax[i]) * 0.5 - stand.bounds_center[i]).abs() < 1e-4, + "{path}: bounds_center[{i}] must be the box midpoint" + ); + } + } +} diff --git a/crates/benilla-formats/tests/blp_alpha.rs b/crates/benilla-formats/tests/blp_alpha.rs new file mode 100644 index 00000000..1ee3a83a --- /dev/null +++ b/crates/benilla-formats/tests/blp_alpha.rs @@ -0,0 +1,33 @@ +//! Regression: a BLP2 texture with an unknown `alpha_type` byte must still decode. Real 1.12-era +//! particle/effect art (e.g. `particles/dust1.blp` — palettized, `alpha_bits == 0`, but a stale +//! `alpha_type == 2`) used to abort the whole decode in `wow_blp`; our fork falls back to no-alpha. +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{blp_to_rgba, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn decodes_blp2_unknown_alpha_type() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("particles\\dust1.blp") + .expect("read particles/dust1.blp"); + + let (w, h, rgba) = blp_to_rgba(&bytes).expect("dust1.blp must decode despite alpha_type == 2"); + assert!(w > 0 && h > 0, "decoded dimensions {w}x{h}"); + assert_eq!( + rgba.len(), + (w * h * 4) as usize, + "RGBA8 buffer matches dimensions" + ); +} diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs new file mode 100644 index 00000000..f8116f39 --- /dev/null +++ b/crates/benilla-formats/tests/chain.rs @@ -0,0 +1,963 @@ +//! Integration test against the real client at `/WoW/Data` (gitignored). +//! +//! Skips (passes) when the client isn't present, so CI without assets stays green. +//! Document: place a legally-owned 1.12.1 client's `Data` dir at the repo root `WoW/`. + +use std::path::PathBuf; + +use benilla_formats::open_chain; + +fn vanilla_data_dir() -> PathBuf { + // crates/benilla-formats -> repo root -> WoW/Data + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn reads_spell_dbc_from_vanilla_chain() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Spell.dbc lives in patch.MPQ; reading by name must resolve through the chain. + let bytes = chain + .read_file("DBFilesClient/Spell.dbc") + .expect("read DBFilesClient/Spell.dbc"); + + assert_eq!(&bytes[..4], b"WDBC", "DBC files start with the WDBC magic"); + assert!( + bytes.len() > 1_000_000, + "Spell.dbc should be sizable, got {} bytes", + bytes.len() + ); +} + +#[test] +fn resolves_and_reads_across_archive_types() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + // One `Chain` (the `&self` AssetReader backend and the `&mut` streaming loaders are now the same + // type) must resolve and read across archive kinds — DBC (dbc/patch.MPQ), a UI BLP, and a + // base-archive M2 with no per-archive listfile (resolved by name hash). Non-empty reads pin + // cross-archive resolution and the whole-file (no-PTCH) invariant for these files. + let chain = open_chain(&data).expect("open vanilla patch chain"); + + for path in [ + "DBFilesClient/Spell.dbc", + "DBFilesClient/TaxiNodes.dbc", + "Interface/Icons/Spell_Holy_ArcaneIntellect.blp", + // Physical .m2 in a listfile-less base archive (references use .mdx; the .mdx→.m2 remap is a + // loader concern, so the reader gets the on-disk path) — proves base-archive resolution. + "Creature\\Kobold\\Kobold.m2", + ] { + assert!(chain.contains(path), "chain should resolve {path}"); + let bytes = chain + .read(path) + .unwrap_or_else(|e| panic!("chain read {path}: {e:#}")); + assert!(!bytes.is_empty(), "{path} read empty"); + } +} + +#[test] +fn culls_constant_zero_alpha_m2_batches() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // OrgrimmarFloatingEmbers' only render batch is an 84-yd box with a transparency-weight track of + // 0.0 — a particle-emitter anchor the real client never draws (it culls batches at alpha ≤ 0, + // verified). It must be culled, leaving the model with no render geometry (its 3 emitters remain). + let embers = benilla_formats::load_m2_mesh( + &mut chain, + "world/kalimdor/orgrimmar/passivedoodads/orgrimmarbonfire/orgrimmarfloatingembers.m2", + ) + .expect("mesh embers"); + assert!( + embers.is_empty(), + "the weight-0 emitter box must be culled, got {} batches", + embers.len() + ); + + // Control — a normal lamppost keeps every batch (body + glow cards), proving the zero-alpha cull + // doesn't over-cull visible geometry. + let lamppost = benilla_formats::load_m2_mesh( + &mut chain, + "world/azeroth/elwynn/passivedoodads/lamppost/lamppost.m2", + ) + .expect("mesh lamppost"); + // Five batches: body + glass + two billboard cards (the spherical additive glow and the cylindrical + // top-flame) split out from their mixed batch onto their own bones, so each faces the camera about + // its own pivot (decision 0028). The point here is the alpha-cull leaves every visible batch intact. + assert_eq!(lamppost.len(), 5, "a normal prop keeps all its batches"); +} + +#[test] +fn suppresses_white1_invisible_trap_placeholder() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // The `SpellObject_InvisibleTrap` placeholder (GameObject displayId 1287 — Fire-Festival fury zones, + // rallying-cry triggers, …): a flat opaque quad textured with the engine utility-white WHITE1.BLP. + // The real client draws it at per-instance alpha ≈0 (invisible); benilla recognises the placeholder + // and drops its geometry (decision 0030). It must reduce to zero render batches. + let trap = benilla_formats::load_m2_mesh( + &mut chain, + "world/generic/passivedoodads/traps/spellobject_invisibletrap.m2", + ) + .expect("mesh trap"); + assert!( + trap.is_empty(), + "the WHITE1 invisible-trap placeholder must be suppressed, got {} batches", + trap.len() + ); + + // Control — a *visible* flat decal that shares the trap's degenerate render box but carries a real + // texture + AlphaTest (an orc bedroll). It must NOT be suppressed: the fingerprint is opaque+WHITE1, + // not flatness, so genuine ground decals are kept. + let mat = benilla_formats::load_m2_mesh( + &mut chain, + "world/azeroth/burningsteppes/passivedoodads/orcsleepmats/orcsleepmat01.m2", + ) + .expect("mesh orc sleep mat"); + assert!( + !mat.is_empty(), + "a visible flat decal (real texture, AlphaTest) must be kept, not suppressed" + ); +} + +#[test] +fn decodes_a_blp_icon_to_png() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("Interface/Icons/Spell_Holy_ArcaneIntellect.blp") + .expect("read spell icon BLP"); + + let out = std::env::temp_dir().join("benilla_formats_test_icon.png"); + let (w, h) = benilla_formats::blp_to_png(&bytes, &out).expect("decode BLP -> PNG"); + + assert_eq!((w, h), (64, 64), "spell icons are 64x64"); + let png = std::fs::read(&out).expect("read written PNG"); + assert_eq!(&png[..8], &[137, 80, 78, 71, 13, 10, 26, 10], "PNG magic"); + let _ = std::fs::remove_file(&out); +} + +#[test] +fn dumps_taxinodes_to_csv() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("DBFilesClient/TaxiNodes.dbc") + .expect("read TaxiNodes.dbc"); + + let out = std::env::temp_dir().join("benilla_formats_test_taxi.csv"); + let (records, fields) = + benilla_formats::dbc_to_csv(&bytes, "TaxiNodes.dbc", &out).expect("dbc->csv"); + + assert_eq!((records, fields), (85, 16), "vanilla TaxiNodes.dbc shape"); + let csv = std::fs::read_to_string(&out).expect("read csv"); + assert!( + csv.lines() + .next() + .unwrap() + .starts_with("ID,MapID,X,Y,Z,Name"), + "schema-derived header" + ); + assert!(csv.contains("Stormwind"), "expected a known flight node"); + assert_eq!(csv.lines().count(), 86, "85 records + 1 header row"); + let _ = std::fs::remove_file(&out); +} + +#[test] +fn meshes_an_elwynn_terrain_tile() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) + .expect("find an Elwynn tile"); + let tile = + benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); + + assert!(!tile.chunks.is_empty(), "tile should have terrain chunks"); + assert!( + tile.vertex_count() <= 256 * 145, + "<= 256 chunks * (81 outer + 64 inner) = 145 verts/chunk (center-fan)" + ); + // Hilltop sanity (B1): every chunk should carry the 64 inner center verts, and at least one + // tile-wide cell must have its center vertex deviate by > 1 yd from the corner-mean — that's + // the authored hilltop bump we used to flatten. Catches a regression to the old 81-vert mesher. + let mut hilltop_dev = 0.0f32; + for c in &tile.chunks { + assert_eq!(c.positions.len(), 145, "chunk = 81 outer + 64 inner verts"); + for r in 0..8usize { + for col in 0..8usize { + let tl = c.positions[r * 17 + col][2]; + let tr = c.positions[r * 17 + col + 1][2]; + let bl = c.positions[(r + 1) * 17 + col][2]; + let br = c.positions[(r + 1) * 17 + col + 1][2]; + let ctr = c.positions[r * 17 + 9 + col][2]; + let mean = (tl + tr + bl + br) * 0.25; + hilltop_dev = hilltop_dev.max((ctr - mean).abs()); + } + } + } + assert!( + hilltop_dev > 1.0, + "expected ≥1 cell on this Elwynn tile with center−corner deviation > 1 yd \ + (proves we read the authored MCVT inner heights, not just outer); got max {hilltop_dev:.2}", + ); + + let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]); + for chunk in &tile.chunks { + assert_eq!(chunk.indices.len() % 3, 0, "triangle list per chunk"); + assert_eq!(chunk.positions.len(), chunk.uvs.len(), "one UV per vertex"); + assert!( + chunk + .indices + .iter() + .all(|&i| (i as usize) < chunk.positions.len()), + "chunk indices reference valid vertices" + ); + for p in &chunk.positions { + for i in 0..3 { + mn[i] = mn[i].min(p[i]); + mx[i] = mx[i].max(p[i]); + } + } + } + + // Bounds sanity — guards against the MCNK axis-scramble bug. + // One tile spans ~533 yds in X and Y; tile 31_48's world X is negative; Z holds plausible + // terrain heights (a leaked horizontal axis would blow the Z span way past this). + let span = |i: usize| mx[i] - mn[i]; + assert!( + (500.0..540.0).contains(&span(0)) && (500.0..540.0).contains(&span(1)), + "X/Y should each span ~one tile (533 yds); got X={:.1}, Y={:.1}", + span(0), + span(1) + ); + assert!( + mx[0] < 0.0, + "tile 31_48 world X is negative; got max X={:.1}", + mx[0] + ); + assert!( + span(2) < 600.0, + "Z should be terrain heights; got span {:.1}", + span(2) + ); + + // Texturing data: some chunk should reference a base-layer .blp from MTEX. + assert!( + tile.chunks.iter().any(|c| c + .base_texture + .as_deref() + .is_some_and(|t| t.to_ascii_lowercase().ends_with(".blp"))), + "expected a base-layer .blp texture on some chunk" + ); + + // Multi-layer splat data (Phase 6): Elwynn blends grass/dirt/road, so some chunk should have + // >1 layer and a non-trivial packed alpha map. + let multilayer = tile + .chunks + .iter() + .find(|c| c.layer_textures.len() > 1) + .expect("some chunk should have multiple texture layers"); + let alpha = multilayer + .alpha_map + .as_ref() + .expect("a multi-layer chunk carries a packed alpha map"); + let size = benilla_formats::ALPHA_MAP_SIZE as usize; + assert_eq!(alpha.len(), size * size * 4, "alpha map is RGBA size²"); + assert!( + alpha + .chunks_exact(4) + .any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0), + "a multi-layer chunk's alpha map should have some non-zero blend weight" + ); + assert!( + multilayer.layer_textures.len() <= 4, + "at most 4 terrain layers per chunk" + ); +} + +#[test] +fn terrain_chunks_carry_unit_upward_mcnr_normals() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) + .expect("find an Elwynn tile"); + let tile = + benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); + + let (mut checked, mut up) = (0usize, 0usize); + for chunk in &tile.chunks { + if chunk.normals.is_empty() { + continue; + } + assert_eq!( + chunk.normals.len(), + chunk.positions.len(), + "one MCNR normal per vertex" + ); + for n in &chunk.normals { + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + assert!( + (0.9..=1.1).contains(&len), + "decoded normal should be ~unit, got len {len:.3} for {n:?}" + ); + checked += 1; + // WoW Z is up; ground normals point outward/up. If the X/Z/Y decode were wrong, "up" + // would land in a different component and this would collapse — so it pins the axis. + if n[2] > 0.5 { + up += 1; + } + } + } + assert!(checked > 0, "Elwynn tile should carry MCNR normals"); + assert!( + up * 100 / checked >= 70, + "most terrain normals should point up (WoW +Z); only {up}/{checked} did" + ); +} + +#[test] +fn terrain_is_watertight_at_chunk_seams() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) + .expect("find an Elwynn tile"); + let tile = + benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); + + // Bucket every vertex by quantized XY (0.25-yd buckets only group truly-coincident verts, since + // the grid spacing is ~4.17 yd). A vertex shared by adjacent chunks must come out *bit-identical* + // in X/Y after lattice snapping — otherwise the fractional misalignment reopens the seam. + use std::collections::HashMap; + let mut buckets: HashMap<(i64, i64), Vec<[f32; 3]>> = HashMap::new(); + for c in &tile.chunks { + for p in &c.positions { + let key = ((p[0] * 4.0).round() as i64, (p[1] * 4.0).round() as i64); + buckets.entry(key).or_default().push(*p); + } + } + let mut shared = 0usize; + for ps in buckets.values() { + if ps.len() < 2 { + continue; + } + shared += 1; + for w in ps.windows(2) { + assert_eq!( + w[0][0], w[1][0], + "shared-edge X must be bit-identical (no seam)" + ); + assert_eq!( + w[0][1], w[1][1], + "shared-edge Y must be bit-identical (no seam)" + ); + assert!( + (w[0][2] - w[1][2]).abs() < 0.01, + "shared-edge heights should match: {} vs {}", + w[0][2], + w[1][2] + ); + } + } + assert!( + shared > 0, + "tile should have shared chunk-edge vertices to check" + ); +} + +#[test] +fn loads_a_block_of_elwynn_tiles() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let tiles = benilla_formats::load_tiles_around(&mut chain, "Azeroth", -8840.56, 489.7, 1) + .expect("load tile block"); + + assert!( + tiles.len() > 1, + "radius 1 around Stormwind should load several contiguous tiles, got {}", + tiles.len() + ); + let mut coords: Vec<_> = tiles.iter().map(|(c, _)| *c).collect(); + coords.sort_unstable(); + coords.dedup(); + assert_eq!(coords.len(), tiles.len(), "tiles must be distinct"); +} + +#[test] +fn reads_and_decodes_a_terrain_texture() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) + .expect("find a tile"); + let tile = + benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); + + let texture = tile + .chunks + .iter() + .find_map(|c| c.base_texture.clone()) + .expect("at least one chunk has a base texture"); + + let (w, h, rgba) = + benilla_formats::read_texture_rgba(&mut chain, &texture).expect("decode terrain texture"); + assert!(w > 0 && h > 0, "non-empty texture, got {w}x{h}"); + assert_eq!(rgba.len(), (w * h * 4) as usize, "RGBA8 pixel buffer"); +} + +#[test] +fn resolves_creature_models_from_dbcs() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let catalog = + benilla_formats::load_creature_catalog(&mut chain).expect("load creature display catalog"); + assert!( + catalog.len() > 1000, + "vanilla has thousands of creature displays, got {}", + catalog.len() + ); + + // Scan display ids; many should resolve. Prove at least one resolved creature model that ships + // in this client actually meshes (not every CreatureModelData path is present on disk). Scale 0 + // is valid — many low-id displays are invisible trigger NPCs — so we only require most positive. + let (mut resolved, mut positive_scale, mut meshed) = (0u32, 0u32, false); + for id in 1..6000u32 { + let Some(m) = catalog.model(id) else { continue }; + resolved += 1; + if m.scale > 0.0 { + positive_scale += 1; + } + let p = m.model_path.to_ascii_lowercase(); + if !meshed && m.scale > 0.0 && p.contains("creature") && p.ends_with(".mdx") { + if let Ok(subs) = + benilla_formats::load_m2_mesh_skinned(&mut chain, &m.model_path, &m.textures) + { + meshed |= !subs.is_empty(); + } + } + } + assert!( + resolved > 500, + "many display ids should resolve, got {resolved}" + ); + assert!( + positive_scale * 2 > resolved, + "most resolved displays should have positive scale, got {positive_scale}/{resolved}" + ); + assert!( + meshed, + "expected at least one resolved creature model to mesh" + ); +} + +#[test] +fn resolves_gameobject_models_from_dbc() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let catalog = benilla_formats::load_gameobject_catalog(&mut chain) + .expect("load gameobject display catalog"); + assert!( + catalog.len() > 500, + "vanilla has many GameObject displays, got {}", + catalog.len() + ); + + // Every resolved path is a model; prove at least one meshes (dispatches .mdx/.mdl/.m2 vs .wmo). + let mut meshed = false; + for id in 1..3000u32 { + let Some(path) = catalog.model_path(id).map(str::to_string) else { + continue; + }; + let p = path.to_ascii_lowercase(); + assert!( + p.ends_with(".mdx") || p.ends_with(".mdl") || p.ends_with(".wmo"), + "unexpected GameObject model path: {path}" + ); + if !meshed { + if let Ok(subs) = benilla_formats::load_object_model(&mut chain, &path) { + meshed |= !subs.is_empty(); + } + } + } + assert!(meshed, "expected at least one GameObject model to mesh"); +} + +#[test] +fn loads_classic_models_with_cosmetic_chunks() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + // These vanilla M2s aborted `parse_m2` with "failed to fill whole buffer" on stock wow-m2 0.6.4 + // (malformed particle-emitter / light / texture-animation chunks — warcraft-rs#56). Our patched + // fork degrades those cosmetic chunks to empty, so geometry now loads. They must mesh. + for path in [ + "Creature\\Kobold\\Kobold.mdx", + "Creature\\Imp\\Imp.mdx", + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.mdx", + ] { + let subs = benilla_formats::load_m2_mesh(&mut chain, path) + .unwrap_or_else(|e| panic!("load {path}: {e:#}")); + assert!(!subs.is_empty(), "{path} should produce submeshes"); + } +} + +/// Span (max−min) per axis of a point set; `[0;3]` if empty. +fn span3(ps: &[[f32; 3]]) -> [f32; 3] { + if ps.is_empty() { + return [0.0; 3]; + } + let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in ps { + for i in 0..3 { + mn[i] = mn[i].min(p[i]); + mx[i] = mx[i].max(p[i]); + } + } + [mx[0] - mn[0], mx[1] - mn[1], mx[2] - mn[2]] +} + +#[test] +fn decodes_m2_collision_hulls() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Golden vectors hand-verified against the raw bytes 2026-06-02. The + // hull is a coarse TRUNK, far smaller than the render canopy — the "blocked by trunk, walk through + // leaves" shape. Exact tri/vert counts fire if a wow-m2 bump changes the bounding-array decode. + let pine = benilla_formats::load_m2_collision_hull( + &mut chain, + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTree01\\ElwynnPine01.mdx", + ) + .expect("pine hull"); + assert_eq!(pine.triangle_count(), 6, "ElwynnPine01 hull = 6 tris"); + assert_eq!(pine.positions.len(), 5, "ElwynnPine01 hull = 5 verts"); + let s = span3(&pine.positions); + assert!( + (9.0..11.0).contains(&s[2]), + "pine trunk hull ~10 yd tall, got Z span {:.2}", + s[2] + ); + assert!( + s[0] < 4.0 && s[1] < 4.0, + "pine trunk hull is thin (≪ canopy), got X={:.2} Y={:.2}", + s[0], + s[1] + ); + assert!( + pine.indices + .iter() + .all(|&i| (i as usize) < pine.positions.len()), + "hull indices in range" + ); + + let canopy = benilla_formats::load_m2_collision_hull( + &mut chain, + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTreeCanopy01.mdx", + ) + .expect("canopy hull"); + assert_eq!( + canopy.triangle_count(), + 52, + "ElwynnTreeCanopy01 hull = 52 tris" + ); + assert_eq!( + canopy.positions.len(), + 30, + "ElwynnTreeCanopy01 hull = 30 verts" + ); + + // Sanity: the hull is much smaller than the rendered model's authored bbox (trunk vs full canopy). + let bounds = benilla_formats::load_m2_bounds( + &mut chain, + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Trees\\ElwynnTreeCanopy01.mdx", + ) + .expect("canopy bounds"); + let render_x = bounds.bbox_max[0] - bounds.bbox_min[0]; + let hull_x = span3(&canopy.positions)[0]; + assert!( + hull_x < render_x * 0.6, + "collision hull (X span {hull_x:.1}) should be far tighter than the render bbox ({render_x:.1})" + ); +} + +#[test] +fn filters_wmo_collidable_triangles_by_mopy() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Golden vector: the binary-verified client filter (collide iff `!(flags & 0x04 DETAIL)`, + // WoW.exe 5875 — collision.md §F) selects exactly 5114 collidable tris from the Goldshire Inn. (On + // this fixture that equals the old `COLLISION||(RENDER&&!DETAIL)` count — every non-DETAIL face here + // is also COLLISION-or-RENDER; the rules diverge only on flags-0x00/0x40-only faces, absent here.) + // Pins both the filter clause and the wow-wmo group decode. + let inn = benilla_formats::load_wmo_collision_tris( + &mut chain, + "World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn.wmo", + ) + .expect("goldshire inn collision"); + assert_eq!(inn.triangle_count(), 5114, "Goldshire Inn collidable tris"); + assert!( + inn.indices + .iter() + .all(|&i| (i as usize) < inn.positions.len()), + "collidable indices in range" + ); + let s = span3(&inn.positions); + assert!( + (40.0..80.0).contains(&s[0]) + && (20.0..50.0).contains(&s[1]) + && (20.0..45.0).contains(&s[2]), + "inn collidable bbox should be building-sized, got {s:?}" + ); + + // A bridge is ~entirely collision surface (you walk across the deck): elwynnwidebridge = 100%. + let bridge = benilla_formats::load_wmo_collision_tris( + &mut chain, + "World\\wmo\\Azeroth\\Collidable Doodads\\Elwynn\\WideBridge\\ElwynnWideBridge.wmo", + ) + .expect("elwynn wide bridge collision"); + assert!( + bridge.triangle_count() > 0, + "a bridge must carry collidable triangles (the 'walk under bridges' fix)" + ); +} + +#[test] +fn derives_the_wmo_window_glass_law_from_momt() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Golden vectors for the three MOMT window/glass mechanisms (wow-re `wmo-lit-selector` §1, + // `wmo-interior-night-light` §2/§4). The Goldshire Inn authors both window kinds: + // mat 5 MM_ELWYNN_WND_EXT__01, flags 0x11 (UNLIT|SIDN), exterior groups only → the unlit + // fullbright pane (SIDN inert under lighting-off), sidn colour still carried; + // mat 21 MM_ELWYNN_WND_INT__01, flags 0x28 (WINDOW), interior groups' EXT section → lit by + // the interior midpoint light, never fullbright, no SIDN colour. + let inn = benilla_formats::load_wmo( + &mut chain, + "World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn.wmo", + ) + .expect("goldshire inn render submeshes"); + let tex_is = |sub: &benilla_formats::RenderSubmesh, name: &str| { + sub.texture + .as_deref() + .is_some_and(|t| t.to_ascii_uppercase().ends_with(name)) + }; + let ext_panes: Vec<_> = inn + .iter() + .filter(|s| tex_is(s, "MM_ELWYNN_WND_EXT__01.BLP")) + .collect(); + assert!(!ext_panes.is_empty(), "inn exterior window panes present"); + for s in &ext_panes { + assert!(!s.interior, "the EXT pane sits in an exterior group"); + assert!(s.emissive, "UNLIT on an exterior group ⇒ unlit fullbright"); + assert_eq!(s.sidn, Some([203, 203, 203]), "the authored SIDN colour"); + assert!(!s.window, "no WINDOW bit on the exterior pane"); + } + let int_panes: Vec<_> = inn + .iter() + .filter(|s| tex_is(s, "MM_ELWYNN_WND_INT__01.BLP")) + .collect(); + assert!(!int_panes.is_empty(), "inn interior window panes present"); + for s in &int_panes { + assert!(s.interior, "the INT pane sits in interior groups"); + assert!( + !s.emissive, + "the interior drawer ignores UNLIT — never fullbright" + ); + assert!(s.window, "WINDOW (0x20) ⇒ the interior midpoint light"); + assert_eq!(s.sidn, None, "no SIDN flag on the interior pane"); + assert_eq!( + s.wmo_batch, + Some(benilla_formats::WmoBatchClass::Ext), + "the inn's interior panes land in the EXT-in-group (lit) section" + ); + } + + // The SIDN colour is a CImVector — BGRA on disk. Shadowfang's stained glass authors an + // ASYMMETRIC colour (disk bytes 216,231,250,255), so this pins the BGRA→RGB decode: a warm + // cream (250,231,216), not a cool blue — a swizzle bug can't pass. + let sfk = benilla_formats::load_wmo( + &mut chain, + "World\\wmo\\Dungeon\\LD_ShadowFang\\LD_ShadowFang.wmo", + ) + .expect("shadowfang render submeshes"); + assert!( + sfk.iter().any(|s| s.sidn == Some([250, 231, 216])), + "Shadowfang authors the warm-cream SIDN glass (BGRA 216,231,250 → RGB 250,231,216)" + ); +} + +#[test] +fn reads_authentic_atmosphere_from_light_dbc() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); + + // Northshire (Human start) on Azeroth resolves to the continent's global daytime light. + let a = cat.sample_noon(0, [-8949.95, -132.49, 83.5]); + + // Fog bands are RAW (no /36 — that scale is positions-only; wow-re `rf-weather-fog-veil.md`): + // the client pushes `min(raw, farclip)`, so these huge land values resolve to the view distance. + // Must have read real bands, not fallen back to the default. + assert_ne!( + a.fog_end, + benilla_formats::Atmosphere::DEFAULT.fog_end, + "got the fallback atmosphere — Light bands didn't resolve" + ); + + // Exact verified ground truth (2026-05-25): Northshire → global light #1 → clear LightParams 12. + // Decoded independently from the raw DBC bytes and cross-checked vs wowdev.wiki; locking these + // pins the band index formula, the row→meaning map, the color decode, AND the light selection. + let to255 = |c: [f32; 3]| { + [ + (c[0] * 255.0).round() as i32, + (c[1] * 255.0).round() as i32, + (c[2] * 255.0).round() as i32, + ] + }; + assert!( + (a.fog_end - 500.0).abs() < 1.0, + "clear fog_end should be 500 yd (raw 18000/36, decision 0324), got {}", + a.fog_end + ); + assert!( + (a.fog_start_frac - 0.25).abs() < 0.001, + "clear fog start fraction should be +0.25, got {}", + a.fog_start_frac + ); + assert_eq!(to255(a.sky[0]), [0, 31, 73], "SkyTop/zenith (int row 2)"); + assert_eq!(to255(a.sky[1]), [58, 162, 207], "SkyMiddle (int row 3)"); + assert_eq!(to255(a.sky[2]), [153, 220, 245], "SkyBand1 (int row 4)"); + assert_eq!(to255(a.sky[3]), [175, 218, 224], "SkyBand2 (int row 5)"); + assert_eq!( + to255(a.sky[4]), + [180, 180, 180], + "SkySmog/horizon (int row 6)" + ); + assert_eq!(to255(a.sun_color), [255, 247, 222], "sun (int row 9)"); + assert_eq!(to255(a.ambient), [104, 130, 154], "ambient (int row 1)"); + assert_eq!(to255(a.fog_color), [77, 120, 143], "fog color (int row 7)"); + for c in [a.fog_color, a.sun_color, a.ambient] { + for ch in c { + assert!( + (0.0..=1.0).contains(&ch), + "color channel out of range: {ch}" + ); + } + } + assert!( + a.fog_color.iter().sum::() > 0.2 && a.sun_color.iter().sum::() > 0.5, + "daytime colors implausibly dark: fog {:?} sun {:?}", + a.fog_color, + a.sun_color + ); +} + +/// Elwynn's STORM param (k2 = LightParams 10) at midday — the §5-verified endpoints from wow-re's +/// `rf-weather-fog-veil.md` (decoded off the real DBCs and byte-traced through the blend). The +/// load-bearing pin is the **negative fog-start fraction**: −0.5, UNCLAMPED — the mechanism behind +/// the reference's constant ~33% near veil under rain. A clamp regression here silently kills the veil. +#[test] +fn reads_elwynn_storm_fog_endpoints() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); + + // Northshire, noon, storm slot (Elwynn global light #1 → storm LightParams 10, single-stop bands). + let s = cat.sample(0, [-8949.95, -132.49, 83.5], 1440, true); + assert!( + (s.fog_end - 277.8).abs() < 1.0, + "storm fog_end should be ~278 yd (raw 10000/36, decision 0324), got {}", + s.fog_end + ); + assert!( + (s.fog_start_frac - -0.5).abs() < 0.001, + "storm fog start fraction should be −0.5 (negative ⇒ the near veil), got {}", + s.fog_start_frac + ); + let to255 = |c: [f32; 3]| { + [ + (c[0] * 255.0).round() as i32, + (c[1] * 255.0).round() as i32, + (c[2] * 255.0).round() as i32, + ] + }; + assert_eq!(to255(s.fog_color), [82, 84, 82], "storm fog color (grey)"); + assert_eq!( + to255(s.sun_diffuse), + [101, 101, 101], + "storm diffuse (flat grey)" + ); + assert_eq!(to255(s.ambient), [78, 78, 95], "storm ambient (grey-blue)"); +} + +#[test] +fn reads_map_directories_from_map_dbc() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let cat = benilla_formats::load_map_catalog(&mut chain).expect("load Map.dbc"); + + // 5875 has ~44 maps (2 continents, ~12 dungeons, BGs, dev maps). + assert!( + cat.len() >= 40, + "expected ≥40 Map.dbc rows, got {}", + cat.len() + ); + // Canonical IDs — pin the schema so a field-count drift can't silently break the directory + // column (which would silently make every cross-map teleport pick the wrong terrain). + assert_eq!(cat.directory(0), Some("Azeroth")); + assert_eq!(cat.directory(1), Some("Kalimdor")); + // Deadmines instance — a dungeon, proves dungeons resolve too. + assert_eq!(cat.directory(36), Some("DeadminesInstance")); +} + +#[test] +fn decodes_azeroth_wdl_against_apitrace_ground_truth() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Eastern Kingdoms distant-terrain heightmap. + let wdl = benilla_formats::WdlFile::load(&mut chain, "Azeroth").expect("load Azeroth.wdl"); + + // VERIFIED by walking the real file (2026-05-30): MVER=18, 687 present tiles. + assert_eq!(wdl.present_count(), 687, "Azeroth.wdl present-tile count"); + + // Golden vector tied to apitrace WoW.8: the 2nd WDL tile drawn that frame spanned world + // X[-9066.7,-8533.3] Y[-1600,-1066.7] (= tile_x 34, tile_y 48) with vertex heights 54..349. + assert!( + wdl.is_present(34, 48), + "apitrace tile (34,48) must be present" + ); + let mesh = wdl.tile_mesh(34, 48).expect("mesh for (34,48)"); + assert_eq!(mesh.positions.len(), 545, "545 verts (17x17 + 16x16)"); + assert_eq!( + mesh.indices.len(), + 3072, + "3072 indices (16x16 cells x 4 tris)" + ); + + // Heights (world Z) — exact int16 from the file; must match the captured VB range. + let zs: Vec = mesh.positions.iter().map(|p| p[2]).collect(); + let zmin = zs.iter().copied().fold(f32::INFINITY, f32::min); + let zmax = zs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert_eq!(zmin, 54.0, "tile (34,48) min height (apitrace VB)"); + assert_eq!(zmax, 349.0, "tile (34,48) max height (apitrace VB)"); + + // Planar extent — one ADT tile (533.33 yd) on each axis, matching the captured VB span. + let xs: Vec = mesh.positions.iter().map(|p| p[0]).collect(); + let ys: Vec = mesh.positions.iter().map(|p| p[1]).collect(); + let span = |v: &[f32]| { + v.iter().copied().fold(f32::NEG_INFINITY, f32::max) + - v.iter().copied().fold(f32::INFINITY, f32::min) + }; + assert!( + (span(&xs) - 533.333).abs() < 0.5, + "X span ~533.33, got {}", + span(&xs) + ); + assert!( + (span(&ys) - 533.333).abs() < 0.5, + "Y span ~533.33, got {}", + span(&ys) + ); + // Max-X/-Y corner = tile origin (matches wow_wdt::tile_to_world(34,48)). + let xmax = xs.iter().copied().fold(f32::NEG_INFINITY, f32::max); + let ymax = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!((xmax - (-8533.3)).abs() < 0.5, "tile origin X, got {xmax}"); + assert!((ymax - (-1066.7)).abs() < 0.5, "tile origin Y, got {ymax}"); +} diff --git a/crates/benilla-formats/tests/ground_quad.rs b/crates/benilla-formats/tests/ground_quad.rs new file mode 100644 index 00000000..15503220 --- /dev/null +++ b/crates/benilla-formats/tests/ground_quad.rs @@ -0,0 +1,59 @@ +//! Pins the ground-plane-quad detector ([`RenderSubmesh::ground_quad`]) against real build-5875 +//! assets — the shape the ground-fx decal lane (`benilla::ground_fx`) re-renders as projected +//! surface decals. Battle Shout's cast-base model is the canonical population member (verified by +//! hand off the raw M2, 2026-07-14: 24 vertices, all at z = 0 exactly, six 4-vert quads each +//! fully weighted to one of bones {1, 2, 3, 5, 6, 7}, rect −0.776..0.212 × ±0.494); a character +//! body model must detect NOTHING (its one incidentally-flat batch, if any, is not this shape at +//! the base anchor — and regressing a body part into a decal would be spectacular). Skips when +//! the gitignored client data isn't present. + +use benilla_formats::{open_chain, parse_m2_render_submeshes}; + +#[test] +fn battle_shout_crescents_detect_as_ground_quads() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + + let bytes = chain + .read_file("Spells\\BattleShout_Cast_Base.m2") + .expect("Battle Shout cast-base model"); + let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes"); + assert_eq!(subs.len(), 6, "six crescent batches"); + let mut bones = Vec::new(); + for sub in &subs { + let quad = sub + .ground_quad() + .expect("every crescent batch is a ground quad"); + bones.push(quad.bone); + // The authored rect (raw M2 vertex table): x ∈ [−0.776, 0.212], y ∈ [−0.494, 0.494]. + assert!((quad.corners[0][0] + 0.776).abs() < 1e-3, "min x"); + assert!((quad.corners[3][0] - 0.212).abs() < 1e-3, "max x"); + assert!((quad.corners[0][1] + 0.494).abs() < 1e-3, "min y"); + assert!((quad.corners[3][1] - 0.494).abs() < 1e-3, "max y"); + assert!(quad.corners.iter().all(|c| c[2] == 0.0), "authored z = 0"); + } + bones.sort_unstable(); + assert_eq!(bones, [1, 2, 3, 5, 6, 7], "one quad per slide bone"); +} + +#[test] +fn character_model_detects_no_ground_quads() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Character\\Human\\Male\\HumanMale.m2") + .expect("human male model"); + let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("submeshes"); + assert!( + subs.iter().all(|s| s.ground_quad().is_none()), + "no body batch reads as a ground quad" + ); +} diff --git a/crates/benilla-formats/tests/item_icons.rs b/crates/benilla-formats/tests/item_icons.rs new file mode 100644 index 00000000..162214da --- /dev/null +++ b/crates/benilla-formats/tests/item_icons.rs @@ -0,0 +1,47 @@ +//! Asset-gated fixture: the ItemDisplayInfo **icon** column against the real 5875 data — pins +//! col 5 (see `src/items.rs`) to display ids the local vmangos DB actually pairs with known +//! items, so a schema drift or column slip fails loudly. The model/geoset columns have their own +//! synthetic tests in the adapter. Skips (passes) without `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{load_item_display_catalog, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn item_icons_resolve_known_display_ids() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let catalog = load_item_display_catalog(&mut chain).expect("load item display catalog"); + assert!( + catalog.len() > 29_000, + "5875 ships 29604 display rows, got {}", + catalog.len() + ); + + // The icon-column derivation anchors, now as regressions — display ids straight from the + // server's item_template (the values the live wire answers for these items). + for (item, display_id, icon) in [ + ("Worn Shortsword", 1542, "Interface\\Icons\\INV_Sword_04"), + ("Tough Jerky", 2473, "Interface\\Icons\\INV_Misc_Food_16"), + ( + "Worn Wooden Shield", + 18730, + "Interface\\Icons\\INV_Shield_09", + ), + ("Hearthstone", 6418, "Interface\\Icons\\INV_Misc_Rune_01"), + ] { + assert_eq!( + catalog.get(display_id).and_then(|d| d.icon.as_deref()), + Some(icon), + "{item} ({display_id})" + ); + } +} diff --git a/crates/benilla-formats/tests/m2_anim_events.rs b/crates/benilla-formats/tests/m2_anim_events.rs new file mode 100644 index 00000000..0ba6e60c --- /dev/null +++ b/crates/benilla-formats/tests/m2_anim_events.rs @@ -0,0 +1,60 @@ +//! M2 anim-event regression test against real vanilla creatures (decision 0070 slice 3). +//! +//! Guards the event-track parse (events @MD20 0x114, stride 44, timestamps on the global +//! sequence timeline — byte-verified on DireWolf.m2): every event key must land inside its +//! sequence's `[0, duration]` window after the rebase, footstep tags must exist on walking +//! creatures, and the identifier bytes must be forward-stored printable `$xxx` tags (a reversed +//! read would produce `xxx$`). Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_animations}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn creature_anim_events_parse_within_sequences() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let chain = open_chain(&data).expect("open chain"); + + for model in [ + "Creature\\DireWolf\\DireWolf.m2", + "Creature\\Kobold\\Kobold.m2", + "Creature\\Murloc\\Murloc.m2", + ] { + let bytes = chain.read(model).expect("model bytes"); + let anims = parse_m2_animations(&bytes); + assert!(!anims.is_empty(), "{model}: sequences parse"); + + let mut total_events = 0usize; + let mut footstep_tags = 0usize; + for a in &anims { + for e in &a.events { + total_events += 1; + assert!( + e.time >= 0.0 && e.time <= a.duration + 1e-3, + "{model}: event {:?} at {}s escapes its {}s sequence (anim {})", + std::str::from_utf8(&e.ident), + e.time, + a.duration, + a.anim_id + ); + assert_eq!(e.ident[0], b'$', "{model}: forward-stored $ tag"); + if matches!(&e.ident[..3], b"$FL" | b"$FR" | b"$RL" | b"$RR") { + footstep_tags += 1; + } + } + } + assert!(total_events > 0, "{model}: has event keys"); + assert!( + footstep_tags > 0, + "{model}: walking creature has footstep tags" + ); + } +} diff --git a/crates/benilla-formats/tests/m2_billboard.rs b/crates/benilla-formats/tests/m2_billboard.rs new file mode 100644 index 00000000..c09fe19e --- /dev/null +++ b/crates/benilla-formats/tests/m2_billboard.rs @@ -0,0 +1,79 @@ +//! Difftest M2 billboard-bone detection against the real Lamppost: its glow card (GLOW32.BLP) rides a +//! spherical billboard bone (flag 0x08); its post does not. Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{load_m2_mesh, open_chain, BillboardKind}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn lamppost_glow_is_spherical_billboard() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let subs = load_m2_mesh( + &mut chain, + "World\\Azeroth\\Elwynn\\PassiveDoodads\\Lamppost\\Lamppost.m2", + ) + .expect("load Lamppost"); + + // The glow card is a billboard; at least one batch (the post) is not. + let spherical = subs + .iter() + .filter_map(|s| s.billboard.as_ref()) + .find(|b| b.kind == BillboardKind::Spherical); + let bb = spherical.expect("lamppost has a spherical-billboard batch (the glow card)"); + assert!( + bb.pivot.iter().all(|c| c.is_finite()), + "billboard pivot is finite, got {:?}", + bb.pivot + ); + assert!( + subs.iter().any(|s| s.billboard.is_none()), + "the lamppost post batch is not billboarded" + ); + eprintln!("lamppost billboard pivot = {:?}", bb.pivot); + + // The glow card's spherical billboard bone carries a global-sequence SCALE pulse (the "breathe"): + // 5 keys oscillating ~0.86..1.04 over the 1333 ms global sequence. This is what makes the lamppost + // glow pulse in the reference. Verified against the real Lamppost.m2 (bone[4] scale track, gseq 0). + let anim = bb + .scale_anim + .as_ref() + .expect("the lamppost glow card's billboard bone has a global-sequence scale pulse"); + assert_eq!(anim.duration_ms, 1333, "global sequence loop length (ms)"); + assert_eq!(anim.keys.len(), 5, "scale keyframe count"); + assert!(anim.interp, "the scale track interpolates (interp != 0)"); + // It is a genuine pulse: the sampled scale varies over the loop, and stays in a sane breathe range. + let samples: Vec = (0..anim.duration_ms) + .step_by(33) + .map(|t| anim.sample(t)[0]) + .collect(); + let (lo, hi) = samples + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v))); + assert!( + (0.8..0.95).contains(&lo) && (1.0..1.1).contains(&hi), + "scale pulse breathes within ~0.86..1.04, got {lo}..{hi}" + ); + assert!( + hi - lo > 0.1, + "the pulse has visible amplitude, got {lo}..{hi}" + ); + // Uniform scale (x == y == z) at an arbitrary phase — a billboard card scales evenly. + let s = anim.sample(700); + assert!( + (s[0] - s[1]).abs() < 1e-6 && (s[1] - s[2]).abs() < 1e-6, + "scale is uniform, got {s:?}" + ); + eprintln!( + "lamppost glow pulse: {lo:.3}..{hi:.3} over {} ms", + anim.duration_ms + ); +} diff --git a/crates/benilla-formats/tests/m2_color.rs b/crates/benilla-formats/tests/m2_color.rs new file mode 100644 index 00000000..22dd9489 --- /dev/null +++ b/crates/benilla-formats/tests/m2_color.rs @@ -0,0 +1,60 @@ +//! Regression: an M2 batch that references an M2Color (`texUnit.colorIndex != 0xffff`) must carry that +//! colour's RGB as a per-vertex tint, so the model shader multiplies it into the texture. Load-bearing +//! for additive glow cards drawn with a neutral glow texture — the Orgrimmar bonfire's base glow +//! (`GenericGlow_Alpha_128`, a white-cored radial) gets its warmth *only* from this M2Color; without it +//! the additive draw washes the bright core to white. See decision 0029. Skips when the client is absent. + +use std::path::PathBuf; + +use benilla_formats::{load_m2_mesh, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn bonfire_glow_carries_warm_m2color_tint() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let subs = load_m2_mesh( + &mut chain, + "World\\Kalimdor\\Orgrimmar\\PassiveDoodads\\OrgrimmarBonfire\\OrgrimmarBonfire01.m2", + ) + .expect("load OrgrimmarBonfire01.m2"); + + // The additive billboard glow card (GenericGlow) references M2Color[0] = warm fire-orange. + let glow = subs + .iter() + .find(|s| s.additive && s.billboard.is_some()) + .expect("bonfire has an additive billboard glow card"); + assert_eq!( + glow.vertex_colors.len(), + glow.positions.len(), + "the glow card's M2Color must be baked as a per-vertex tint" + ); + let c = glow.vertex_colors[0]; + // Authored M2Color RGB ≈ (0.925, 0.663, 0.098): warm (R ≫ B), not the neutral/white it'd be without + // the tint. Asserting the shape (warm) rather than exact bytes keeps this robust. + assert!( + (c[0] - 0.925).abs() < 0.02 && (c[1] - 0.663).abs() < 0.02 && (c[2] - 0.098).abs() < 0.02, + "glow tint should be the authored warm fire-orange, got {c:?}" + ); + assert!( + c[0] > c[1] && c[1] > c[2] && c[0] - c[2] > 0.5, + "glow tint must be distinctly warm (R≫G≫B), got {c:?}" + ); + + // The wood/rock batches don't reference an M2Color (colorIndex 0xffff) → no baked tint. + let untinted = subs + .iter() + .filter(|s| !s.additive && s.vertex_colors.is_empty()) + .count(); + assert!( + untinted >= 2, + "the two opaque batches carry no M2Color tint (empty vertex colours), found {untinted}" + ); +} diff --git a/crates/benilla-formats/tests/m2_emissive.rs b/crates/benilla-formats/tests/m2_emissive.rs new file mode 100644 index 00000000..d6f72b30 --- /dev/null +++ b/crates/benilla-formats/tests/m2_emissive.rs @@ -0,0 +1,35 @@ +//! Difftest the M2 UNLIT (0x01) → `emissive` detection against the real GeneralLantern03: its glass +//! batch (ElwynnLantern01.blp, material flags 0x05) is self-lit; its body batch (flags 0x00) is not. +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{load_m2_mesh, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn lantern_glass_batch_is_emissive() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let subs = load_m2_mesh( + &mut chain, + "World\\Generic\\Human\\Passive Doodads\\Lanterns\\GeneralLantern03.m2", + ) + .expect("load GeneralLantern03"); + + assert!( + subs.iter().any(|s| s.emissive), + "the lantern has a self-lit (UNLIT 0x01) glass batch" + ); + assert!( + subs.iter().any(|s| !s.emissive), + "the lantern body batch is lit normally (not emissive)" + ); +} diff --git a/crates/benilla-formats/tests/m2_global_seq_constant.rs b/crates/benilla-formats/tests/m2_global_seq_constant.rs new file mode 100644 index 00000000..99788da6 --- /dev/null +++ b/crates/benilla-formats/tests/m2_global_seq_constant.rs @@ -0,0 +1,65 @@ +//! Global-sequence CONSTANT channel regression test (the floating-stowed-sword bug, 2026-07-03). +//! +//! Vanilla art authors the stowed-weapon attach-bone orientations as **global-sequence** tracks with a +//! single key at t=0 (HumanMale bones 29/30 hips, 58–62 back family — the blade-down / shield-on-back +//! quaternions), on global-seq 0, which is itself 0 ms long: a pure constant. The parser used to skip +//! every global-sequence track ("deferred"), leaving those joints at identity, so a stowed sword lay +//! horizontal through the body. The constant must now reach **every** sequence's keyframe set. Skips +//! when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_animations}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// The hip-sheath attach bone (attachment id 32 → bone 29) and its authored constant rotation, +/// verified by direct dump of the real file (decision 0072's attach survey). +const HIP_BONE: u16 = 29; +const HIP_QUAT: [f32; 4] = [0.382, 0.063, -0.922, 0.0]; + +#[test] +fn stow_attach_bones_carry_their_constant_rotation_in_every_sequence() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("Character\\Human\\Male\\HumanMale.m2") + .expect("HumanMale.m2 in the chain"); + let anims = parse_m2_animations(&bytes); + assert!(!anims.is_empty(), "HumanMale should have sequences"); + + for anim in &anims { + let hip = anim + .bones + .iter() + .find(|bk| bk.bone == HIP_BONE) + .unwrap_or_else(|| { + panic!( + "anim {}: hip attach bone {HIP_BONE} missing from the keyframe set — the \ + global-seq constant was dropped again", + anim.anim_id + ) + }); + assert_eq!( + hip.rotation.len(), + 1, + "anim {}: expected the single constant rotation key", + anim.anim_id + ); + let (t, q) = &hip.rotation[0]; + assert_eq!(*t, 0.0); + for (a, b) in q.iter().zip(HIP_QUAT) { + assert!( + (a - b).abs() < 1e-3, + "anim {}: hip constant {q:?} != authored {HIP_QUAT:?}", + anim.anim_id + ); + } + } +} diff --git a/crates/benilla-formats/tests/m2_idle_anim.rs b/crates/benilla-formats/tests/m2_idle_anim.rs new file mode 100644 index 00000000..52674325 --- /dev/null +++ b/crates/benilla-formats/tests/m2_idle_anim.rs @@ -0,0 +1,94 @@ +//! Animation-keyframe regression test against real vanilla creatures (decision 0019). +//! +//! Guards the bug that froze most creatures in Milestone B: a per-sequence keyframe window pulled in a +//! keyframe from a *later* sequence (14–64 s away), which inflated the Bevy clip's duration and made +//! the whole animation crawl. We now select keys by absolute timestamp within each sequence's band, so +//! every keyframe of every sequence must land inside `[0, duration]`. Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_animations, ModelAnimation}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// A spread of vanilla creatures: simple (rabbit/chicken) through rigged humanoid-ish (kobold/murloc), +/// covering the ones that animated *and* the ones that froze before the fix. +const CREATURES: &[&str] = &[ + "Creature\\Rabbit\\Rabbit.m2", + "Creature\\Chicken\\Chicken.m2", + "Creature\\Deer\\Deer.m2", + "Creature\\Kobold\\Kobold.m2", + "Creature\\Wolf\\Wolf.m2", + "Creature\\Murloc\\Murloc.m2", + "Creature\\Bear\\Bear.m2", + "Creature\\Cat\\Cat.m2", +]; + +#[test] +fn creature_animation_keyframes_stay_within_their_sequence() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + let mut checked = 0; + for path in CREATURES { + let Ok(bytes) = chain.read_file(path) else { + continue; // a model not in this install — skip + }; + let anims = parse_m2_animations(&bytes); + assert!( + !anims.is_empty(), + "{path}: a creature should have sequences" + ); + // Every creature must carry a Stand (AnimationData id 0) somewhere — the records are NOT + // ordered by id (e.g. Rabbit's record 0 is Walk), so the idle is found by id, not position. + let ids: Vec = anims.iter().map(|a| a.anim_id).collect(); + assert!( + ids.contains(&0), + "{path}: no Stand (id 0) among sequence ids {ids:?}" + ); + for anim in &anims { + assert!( + anim.duration > 0.0, + "{path}: anim {} has no duration", + anim.anim_id + ); + // No keyframe may sit past its sequence: a key beyond `duration` is the cross-sequence leak + // that froze creatures (it stretched a clip to many seconds). Small epsilon for rounding. + let max_t = max_key_time(anim); + assert!( + max_t <= anim.duration + 1e-3, + "{path}: anim {} key at {max_t:.3}s exceeds duration {:.3}s — a cross-sequence leak", + anim.anim_id, + anim.duration + ); + } + checked += 1; + } + assert!( + checked > 0, + "no test creatures present — expected at least the rabbit" + ); +} + +/// The latest keyframe time across every bone/channel of the animation (0.0 if it has no keys). +fn max_key_time(anim: &ModelAnimation) -> f32 { + let mut max = 0.0_f32; + for b in &anim.bones { + for (t, _) in &b.translation { + max = max.max(*t); + } + for (t, _) in &b.rotation { + max = max.max(*t); + } + for (t, _) in &b.scale { + max = max.max(*t); + } + } + max +} diff --git a/crates/benilla-formats/tests/m2_lights.rs b/crates/benilla-formats/tests/m2_lights.rs new file mode 100644 index 00000000..daa24cff --- /dev/null +++ b/crates/benilla-formats/tests/m2_lights.rs @@ -0,0 +1,109 @@ +//! M2 light-block parse — byte-level check against a real Elwynn campfire, the dynamic point-light +//! "hot-spot" caster (decision 0016). Pins the vanilla `0xd4` record stride and the diffuse-colour track +//! offset. Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_m2_lights, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn elwynn_campfire_has_one_warm_point_light() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let bytes = reader + .read("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2") + .expect("read ElwynnCampfire.m2"); + + let lights = parse_m2_lights(&bytes); + assert_eq!(lights.len(), 1, "campfire has exactly one M2 light"); + + let l = lights[0]; + assert!( + l.is_point(), + "campfire light is type 1 (point), got type {}", + l.light_type + ); + // The authored warm-orange diffuse pins the 0xd4 record stride AND the 0x48 diffuse-colour track + // offset (independently cross-checked ≈ (0.71, 0.20, 0.00) — a garbled stride would not land here). + let d = l.diffuse_color; + assert!( + (d[0] - 0.71).abs() < 0.03 && (d[1] - 0.20).abs() < 0.03 && d[2] < 0.05, + "campfire diffuse ≈ (0.71, 0.20, 0.00), got {d:?}" + ); +} + +/// The **held torch** — the light an NPC carries into the world (`Club_1H_Torch_A_01.m2`, the model +/// `ItemDisplayInfo` 12236 / item 1906 "Monster - Torch" resolves to). Pins the one asset the entity +/// carried-light path exists for: a `type==1` point light on bone 9, up the shaft, warm, `×3` +/// intensity, and — the gate — a visibility track whose first key is nonzero, so it CASTS. +#[test] +fn held_torch_casts_one_warm_point_light_up_the_shaft() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let bytes = reader + .read("Item\\ObjectComponents\\Weapon\\Club_1H_Torch_A_01.m2") + .expect("read Club_1H_Torch_A_01.m2"); + + let lights = parse_m2_lights(&bytes); + assert_eq!(lights.len(), 1, "the torch has exactly one M2 light"); + let l = lights[0]; + assert!(l.casts(), "point + visibility-on: the torch really glows"); + assert_eq!(l.bone, 9, "the light hangs on the torch's own bone"); + assert!( + (l.position[0] - 0.5765).abs() < 0.01 && l.position[1].abs() < 0.01, + "0.58 yd up the shaft (the head), got {:?}", + l.position + ); + let d = l.diffuse_color.map(|c| c * l.diffuse_intensity); + assert!( + (d[0] - 1.40).abs() < 0.01 && (d[1] - 0.871).abs() < 0.01 && (d[2] - 0.40).abs() < 0.01, + "diffuse × intensity ≈ (1.40, 0.87, 0.40) — flame orange, over-driven, got {d:?}" + ); +} + +/// The **dark** shape (wow-re `m2-dynamic-lights.md` §9.4): a `type==1` light whose visibility track +/// ships a static `0` key never casts. 11 of the corpus's 85 point lights are this — mostly spell +/// impact VFX — so the gate is not academic; before it, every one of them lit the world. +#[test] +fn a_static_zero_visibility_key_reads_as_dark() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let bytes = reader + .read("Spells\\FlameStrike_ImpactDD_Med_Base.m2") + .expect("read FlameStrike_ImpactDD_Med_Base.m2"); + + let dark: Vec<_> = parse_m2_lights(&bytes) + .into_iter() + .filter(|l| l.is_point()) + .collect(); + assert!( + !dark.is_empty(), + "the impact model does author point lights" + ); + assert!( + dark.iter().all(|l| l.visibility_off && !l.casts()), + "every one is held dark by its visibility track" + ); +} + +#[test] +fn too_short_yields_no_lights() { + // No MD20 lights array header → empty, no panic. + assert!(parse_m2_lights(&[0u8; 16]).is_empty()); +} diff --git a/crates/benilla-formats/tests/m2_playable_animation_lookup.rs b/crates/benilla-formats/tests/m2_playable_animation_lookup.rs new file mode 100644 index 00000000..abbe2f5a --- /dev/null +++ b/crates/benilla-formats/tests/m2_playable_animation_lookup.rs @@ -0,0 +1,46 @@ +//! Pins the M2 **PlayableAnimationLookup** table parse (decision 0082 — missing-animation-clip +//! resolution) against a real build-5875 model. `nPlayableAnimationLookup` is byte-verified (wow-re +//! `anim-id-resolution.md`) to be a fixed 203 across the entire retail 1.12.1 M2 corpus; row 6 is the +//! note's own decisive example (`playableAnimationLookup[6] = 0x00030001`). Skips when the gitignored +//! client data isn't present. + +use benilla_formats::{open_chain, parse_m2_playable_animation_lookup}; + +#[test] +fn humanmale_playable_animation_lookup_matches_the_byte_verified_shape() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let chain = open_chain(&data).expect("open chain"); + let bytes = chain + .read("character\\human\\male\\humanmale.m2") + .expect("read m2"); + let pal = parse_m2_playable_animation_lookup(&bytes).expect("parse playable animation lookup"); + + // `nPlayableAnimationLookup` is a fixed 203 across the retail corpus (wow-re empirical + // cross-check) — the array is sized to `AnimationData.dbc`'s playable set, identically for every + // model regardless of its own sequence count. + assert_eq!(pal.len(), 203); + + // Identity entries: HumanMale actually plays Stand(0)/Death(1)/WalkBackwards(3)/Walk(4)/Run(5) + // itself, so each row maps back to its own id with no direction code. + for id in [0u16, 1, 3, 4, 5] { + let row = pal[id as usize]; + assert_eq!(row.resolved_id, id, "row {id} should be identity"); + assert_eq!(row.dir_flags, 0, "row {id} should carry no dir-flags code"); + } + + // The RE note's own decisive empirical proof (`anim-id-resolution.md` §4, "the DECISIVE + // empirical fact"): row 6 packs `0x00030001` — resolved id 1 (Death), dir-flags code 3 — computed + // by hand-replaying the DBC Fallback walk (row 6: Fallback=1, Flags=0x28) and shown bit-for-bit + // identical to this baked entry. The single strongest real-asset anchor for the whole mechanism. + assert_eq!(pal[6].resolved_id, 1, "row 6 -> Death, the DBC-walk proof"); + assert_eq!(pal[6].dir_flags, 3, "row 6's direction/variant code"); + + // A genuine fallback entry away from the DBC-walk showcase row: HumanMale plays every attack + // (2H/1H/unarmed all present), so pick a row known to fall back rather than resolve to itself — + // row 32 substitutes AttackUnarmed(16) for whatever id 32 requests. + assert_eq!(pal[32].resolved_id, 16, "row 32 substitutes AttackUnarmed"); +} diff --git a/crates/benilla-formats/tests/m2_portrait_camera.rs b/crates/benilla-formats/tests/m2_portrait_camera.rs new file mode 100644 index 00000000..0c6960d0 --- /dev/null +++ b/crates/benilla-formats/tests/m2_portrait_camera.rs @@ -0,0 +1,80 @@ +//! M2 portrait-camera parse — byte-level check against real character/creature models. Pins the +//! vanilla camera record stride (`0x7c`) + the `cameraLookup[0]` selection (wow-re +//! `system/ui/scratch/portrait-render.md` §4: the unit-frame portrait renders through exactly this +//! authored camera). Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_m2_portrait_camera, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn character_and_creature_portrait_cameras_parse_sane() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + for path in [ + "Character\\Human\\Male\\HumanMale.m2", + "Creature\\Wolf\\Wolf.m2", + "Creature\\Rabbit\\Rabbit.m2", + ] { + let bytes = reader.read(path).expect("read model"); + let cam = parse_m2_portrait_camera(&bytes) + .unwrap_or_else(|| panic!("{path}: no portrait camera")); + eprintln!("{path}: {cam:?}"); + // Structural sanity that a garbled stride/offset could not land on: a usable perspective + // (fov in a plausible authored range, near < far), the camera off the target, and the rig + // in front of the model (WoW models author facing +X; a portrait camera sits +X of its + // subject looking back). + assert!( + cam.fov > 0.1 && cam.fov < 1.6, + "{path}: fov {} outside plausible authored range", + cam.fov + ); + assert!( + cam.near_clip > 0.0 && cam.near_clip < cam.far_clip, + "{path}: bad clip planes {} .. {}", + cam.near_clip, + cam.far_clip + ); + let dx = cam.position[0] - cam.target[0]; + assert!( + dx > 0.1, + "{path}: camera not in front of the model (Δx {dx})" + ); + } + // Numeric regression pin — HumanMale's authored rig (fov exactly π/4; eye head-height, in + // front and off to the model's right — why the ref portrait faces viewer-left; target on the + // head center). A wrong stride or a swapped base/track offset cannot land on all nine. + let bytes = reader + .read("Character\\Human\\Male\\HumanMale.m2") + .expect("read HumanMale.m2"); + let cam = parse_m2_portrait_camera(&bytes).expect("HumanMale portrait camera"); + let close = |a: f32, b: f32| (a - b).abs() < 1e-3; + assert!( + close(cam.fov, std::f32::consts::FRAC_PI_4), + "fov {}", + cam.fov + ); + for (got, want) in cam + .position + .iter() + .zip([0.6335, -0.3879, 1.8867]) + .chain(cam.target.iter().zip([0.0627, 0.0343, 1.8636])) + { + assert!(close(*got, want), "eye/target drifted: {got} vs {want}"); + } + assert!(close(cam.roll, 0.0), "roll {}", cam.roll); +} + +#[test] +fn too_short_yields_no_camera() { + // No MD20 camera array header → None, no panic. + assert!(parse_m2_portrait_camera(&[0u8; 16]).is_none()); +} diff --git a/crates/benilla-formats/tests/m2_sequence_bounds.rs b/crates/benilla-formats/tests/m2_sequence_bounds.rs new file mode 100644 index 00000000..6cb97787 --- /dev/null +++ b/crates/benilla-formats/tests/m2_sequence_bounds.rs @@ -0,0 +1,46 @@ +//! Pins the per-sequence bounds-sphere parse (`M2Sequence` CAaBox @+0x24/+0x30, radius @+0x3c) on +//! real build-5875 unit models. This sphere is the mouse-pick **broad phase** for the model's +//! current animation (wow-re pick-volume RE `bd630be`, `0x7089c0` — §5-verified): if it parsed as +//! zero or a sliver, the faithful pick would reject clicks the reference accepts. Skips when the +//! gitignored client data isn't present. + +use benilla_formats::{open_chain, parse_m2_animations}; + +#[test] +fn stand_sequence_bounds_sphere_is_body_scale() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let chain = open_chain(&data).expect("open chain"); + + // (model, Stand bounds-sphere radius read off the real files). Note the chicken: its sphere + // (0.30) is *smaller* than its ring footprint (0.57) — the ring's nested-sqrt inflates small + // extents — so no ring-relative bound holds in general; the parsed values themselves are the pin. + for (path, radius) in [ + ("Creature\\Chicken\\Chicken.mdx", 0.304_f32), + ("Creature\\Horse\\Horse.mdx", 2.019), + ("Creature\\Wolf\\Wolf.mdx", 1.963), + ("Creature\\Kobold\\Kobold.mdx", 0.970), + ("Character\\Human\\Male\\HumanMale.mdx", 1.118), + ] { + // The chain stores `.m2`; the DBC-style `.mdx` spelling normalizes (as the loaders do). + let bytes = chain + .read(&path.to_ascii_lowercase().replace(".mdx", ".m2")) + .expect("read m2"); + let anims = parse_m2_animations(&bytes); + let stand = anims + .iter() + .find(|a| a.anim_id == 0) + .expect("Stand sequence"); + assert!( + (stand.bounds_radius - radius).abs() < 5e-3, + "{path}: Stand bounds sphere {:.3}, expected {radius:.3}", + stand.bounds_radius + ); + // The centre sits above the feet (positive raw-WoW Z), inside the sphere — a zeroed/garbage + // box would fail one of these. + assert!(stand.bounds_center[2] > 0.0 && stand.bounds_center[2] < stand.bounds_radius * 2.0); + } +} diff --git a/crates/benilla-formats/tests/particles.rs b/crates/benilla-formats/tests/particles.rs new file mode 100644 index 00000000..8b87107f --- /dev/null +++ b/crates/benilla-formats/tests/particles.rs @@ -0,0 +1,258 @@ +//! Difftest the vanilla M2 particle-emitter parser against the real `ElwynnCampfire.m2` (the +//! Goldshire-area campfire). Skips (passes) when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_particle_emitters, ParticleBlend, ParticleShape}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn campfire_emitters_match_real_bytes() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2") + .expect("read ElwynnCampfire.m2"); + + let emitters = parse_m2_particle_emitters(&bytes).expect("parse emitters"); + + // The campfire has exactly two additive plane emitters: a wide slow glow/smoke plume and a fast + // narrow flame with a 4×4 cell flicker. These values are read straight off the real file. + assert_eq!(emitters.len(), 2, "campfire has two emitters"); + + for e in &emitters { + assert_eq!(e.shape, ParticleShape::Plane); + assert_eq!(e.blend, ParticleBlend::Add); + assert!(e.lifespan > 0.0 && e.lifespan.is_finite()); + assert!(e.emission_rate.first() > 0.0 && e.emission_rate.first().is_finite()); + assert_eq!( + e.emission_rate.keys.len(), + 1, + "ambient prop rates are constant tracks" + ); + assert!(e.horizontal_range > 6.0, "campfire emits in a full ring"); + // Texture resolves to a real .blp via the M2 textures table. + assert!( + e.texture.as_deref().is_some_and(|t| !t.is_empty()), + "emitter texture resolves, got {:?}", + e.texture + ); + } + + // Glow/smoke plume: wide 20° cone, long life, low rate, single cell. + let glow = &emitters[0]; + assert!( + (glow.lifespan - 4.0).abs() < 1e-3, + "glow lifespan ~4.0, got {}", + glow.lifespan + ); + assert!( + (glow.emission_rate.first() - 6.0).abs() < 1e-3, + "glow rate ~6, got {}", + glow.emission_rate.first() + ); + assert_eq!((glow.tile_rows, glow.tile_cols), (1, 1)); + assert!(glow.vertical_range > 0.3, "glow has a wide cone"); + + // Flame: short life, high rate, narrow cone, 4×4 cell animation. + let flame = &emitters[1]; + assert!( + (flame.lifespan - 1.5).abs() < 1e-3, + "flame lifespan ~1.5, got {}", + flame.lifespan + ); + assert!( + (flame.emission_rate.first() - 20.0).abs() < 1e-3, + "flame rate ~20, got {}", + flame.emission_rate.first() + ); + assert_eq!( + (flame.tile_rows, flame.tile_cols), + (4, 4), + "flame has a 4×4 flicker atlas" + ); + assert!(flame.vertical_range < 0.2, "flame is a tight upward jet"); + + // Drag (file +0x194): the velocity-decay term the verified integrator applies as + // `vel −= min(dt·drag, 1)·vel`. The campfire's smoke/glow plume carries a gentle 0.5 (contained + // column); its short-lived flame carries 0.0 (a free upward jet). Read straight off the real + // bytes — the candelabra props instead author a strong 10.0 and rely on it to stay a flicker + // (decision 0027). + assert!( + (glow.drag - 0.5).abs() < 1e-3, + "campfire glow drag ~0.5, got {}", + glow.drag + ); + assert!( + flame.drag == 0.0, + "campfire flame drag 0, got {}", + flame.drag + ); + + // Over-life ramps (verified tail). Dump the sampled color/size/cell across life, and assert the + // believability invariants: a fading additive weight (A) and a sensible size in yards. + eprintln!("campfire emitters OK:"); + for (i, e) in emitters.iter().enumerate() { + let ol = &e.over_life; + eprintln!( + " [{i}] {:?} {:?} tex={:?} life={} rate={:?} cone={:.3} tiles={}x{}", + e.shape, + e.blend, + e.texture, + e.lifespan, + e.emission_rate.keys, + e.vertical_range, + e.tile_rows, + e.tile_cols + ); + eprintln!( + " mid={:.2} color={:?} scale={:?} cells A{:?} B{:?}", + ol.mid, ol.color, ol.scale, ol.cell_a, ol.cell_b + ); + for u in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let (c, s, cell) = ol.sample(u); + eprintln!(" u={u:.2}: rgba={c:?} size={s:.3} cell={cell}"); + } + + // Sizes are finite, non-negative, and small (campfire props are ~sub-yard to a couple yards). + for s in ol.scale { + assert!( + s.is_finite() && (0.0..8.0).contains(&s), + "emitter {i} scale {s} sane" + ); + } + // Color/alpha keys are valid 0..1. + for k in ol.color { + for ch in k { + assert!( + (0.0..=1.0).contains(&ch), + "emitter {i} color channel {ch} in 0..1" + ); + } + } + assert!( + (0.0..=1.0).contains(&ol.mid), + "emitter {i} midPoint in 0..1" + ); + } + + // The glow/smoke plume fades out: its additive weight (alpha) at end-of-life is below its peak. + let glow_ol = &emitters[0].over_life; + let a_start = glow_ol.sample(0.0).0[3]; + let a_end = glow_ol.sample(1.0).0[3]; + assert!( + a_end <= a_start, + "glow alpha should not rise over life ({a_start} -> {a_end})" + ); + + // The flame's cell index advances across its 4×4 atlas over life (the flicker animation). + let flame_ol = &emitters[1].over_life; + let cell_start = flame_ol.sample(0.0).2; + let cell_end = flame_ol.sample(1.0).2; + assert!( + cell_end >= cell_start, + "flame cell advances ({cell_start} -> {cell_end})" + ); +} + +/// The record-tail **twinkle** fields (wow-re `part-simspace-fields.md`, their `ac915a7d`): +/// file +0x188/+0x18c are twinkleScale **{min, max}** — a GATED per-frame size flicker, skipped +/// when the range is degenerate — NOT a spawn-time size multiplier. The discriminating real-data +/// case is the kobold candle: it authors `{0, 0}` and burns in the reference client, which the old +/// `base + rand·variation` reading collapsed to size zero (the director's "candles not burning"). +#[test] +fn twinkle_fields_gate_not_scale() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // Kobold candle: twinkle {0,0} — degenerate range, the multiplier must be identity. + let kobold = parse_m2_particle_emitters( + &chain + .read_file("Creature\\Kobold\\Kobold.m2") + .expect("read Kobold.m2"), + ) + .expect("parse kobold emitters"); + assert_eq!(kobold.len(), 1, "kobold has one candle emitter"); + let candle = &kobold[0]; + assert_eq!((candle.twinkle_min, candle.twinkle_max), (0.0, 0.0)); + assert_eq!( + candle.twinkle(0.7), + 1.0, + "degenerate {{0,0}} twinkle is identity — the candle burns at ramp size" + ); + // Its base size is the over-life ramp alone — nonzero at mid-life. + assert!( + candle.over_life.sample(0.5).1 > 0.0, + "the candle flame's over-life size ramp is nonzero" + ); + + // Campfire glow plume: twinkle {0, 1} — an active flicker range; samples lerp min..max. + let campfire = parse_m2_particle_emitters( + &chain + .read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2") + .expect("read ElwynnCampfire.m2"), + ) + .expect("parse campfire emitters"); + let glow = &campfire[0]; + assert_eq!((glow.twinkle_min, glow.twinkle_max), (0.0, 1.0)); + assert_eq!(glow.twinkle(0.25), 0.25, "active range lerps min..max"); + // A degenerate NON-ZERO range is also identity ({1,1} torches burn steady, not 1–2× inflated). + assert!(glow.twinkle_percent.is_finite() && glow.twinkle_speed.is_finite()); +} + +/// The file→runtime flag remap (wow-re `part-simspace-fields.md` corrections `1f40db0b`, loader +/// block `0x70faf8–0x70fc44`): the space switch is FILE bit 0x10 (→ rt 0x100), the size-by-scale +/// enable FILE 0x20 (→ rt 0x200) — pinned on real content whose behavior the reference shows: +/// the kobold candle (0x01) is carried with no trail and un-flagged for both; the swinging +/// chandelier's candle flames (0x11/0x15) are model-space (they rigidly ride the swing); the +/// campfire (0x21/0x29) scales its flame size with the placement. +#[test] +fn flag_remap_reads_the_file_bits() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + let kobold = + parse_m2_particle_emitters(&chain.read_file("Creature\\Kobold\\Kobold.m2").unwrap()) + .unwrap(); + assert_eq!(kobold[0].flags, 0x01); + assert!(!kobold[0].model_space() && !kobold[0].scale_size_by_instance()); + + let chandelier = parse_m2_particle_emitters( + &chain + .read_file("World\\Dungeon\\GoldshireInn\\InnChandelier\\InnChandelier.m2") + .unwrap(), + ) + .unwrap(); + assert!( + chandelier.iter().take(6).all(|e| e.model_space()), + "the swinging candle flames are model-space (file bit 0x10)" + ); + + let campfire = parse_m2_particle_emitters( + &chain + .read_file("World\\Azeroth\\Elwynn\\PassiveDoodads\\Campfire\\ElwynnCampfire.m2") + .unwrap(), + ) + .unwrap(); + assert!( + campfire + .iter() + .all(|e| e.scale_size_by_instance() && !e.model_space()), + "campfire: size-by-scale (0x20) set, model-space (0x10) clear" + ); +} diff --git a/crates/benilla-formats/tests/ribbons.rs b/crates/benilla-formats/tests/ribbons.rs new file mode 100644 index 00000000..2f9e2d20 --- /dev/null +++ b/crates/benilla-formats/tests/ribbons.rs @@ -0,0 +1,74 @@ +//! Difftest the vanilla M2 ribbon-emitter parser against real trail-carrying models (wow-5875-re +//! `ribbon-emitter-spec.md` field map). Skips (passes) when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_ribbon_emitters}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn ribbon_records_match_real_bytes() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + + // The red wisp — three streamers trailing an animated creature. + let wisp = + parse_m2_ribbon_emitters(&chain.read_file("Creature\\WISP\\WispRed.m2").unwrap()).unwrap(); + assert_eq!(wisp.len(), 3, "WispRed authors three ribbons"); + for (i, r) in wisp.iter().enumerate() { + assert!( + r.edges_per_second > 0.0 && r.edges_per_second < 1000.0, + "ribbon {i} sane edge rate, got {}", + r.edges_per_second + ); + assert!( + r.edge_lifetime >= 0.25, + "ribbon {i} lifetime respects the reference's 0.25 s load clamp" + ); + assert!( + r.height_above.peak() + r.height_below.peak() > 0.0, + "ribbon {i} has a nonzero peak cross-section" + ); + assert!( + r.texture.is_some(), + "ribbon {i} resolves a trail texture from the M2 textures table" + ); + for &(_, a) in &r.alpha.keys { + assert!( + (0.0..=1.0).contains(&a), + "ribbon {i} fixed16 alpha decodes into 0..1, got {a}" + ); + } + for &(_, c3) in &r.color.keys { + for c in c3 { + assert!((0.0..=1.5).contains(&c), "ribbon {i} color channel {c}"); + } + } + } + + // The Thunderblade — a weapon whose enchant trail rides the item root. + let blade = parse_m2_ribbon_emitters( + &chain + .read_file("ITEM\\ObjectComponents\\WEAPON\\Sword_1H_Thunderblade_A_01.m2") + .unwrap(), + ) + .unwrap(); + assert_eq!(blade.len(), 3, "Thunderblade authors three ribbons"); + assert!(blade.iter().all(|r| r.texture.is_some())); + + // A ribbon-less model parses to empty (the overwhelming majority). + let torch = parse_m2_ribbon_emitters( + &chain + .read_file("World\\Generic\\PassiveDoodads\\Lights\\Torch.m2") + .unwrap(), + ) + .unwrap(); + assert!(torch.is_empty(), "the torch has no ribbons"); +} diff --git a/crates/benilla-formats/tests/selection_ring_radius.rs b/crates/benilla-formats/tests/selection_ring_radius.rs new file mode 100644 index 00000000..44d9430f --- /dev/null +++ b/crates/benilla-formats/tests/selection_ring_radius.rs @@ -0,0 +1,58 @@ +//! Pins the selection-ring radius against the **pixel-measured reference** on real build-5875 assets. +//! +//! The reference client sizes a **living unit's** ground selection ring (draw `0x608e00`, sizer +//! `0x60aee0`) as: +//! +//! ```text +//! ring_radius = OBJECT_FIELD_SCALE_X × sqrt( 0.5 · sqrt(dx² + dy²) ) +//! ``` +//! +//! where `dx,dy` are the horizontal (X,Y) extents of the unit's **Stand** animation bounding box (the M2 +//! `M2Sequence` CAaBox), Stand being animation **id 0** via `animationLookup[0]`. This was byte-traced +//! *and* Unicorn-emulated in wow-re (`system/object-layer/scratch/selection-ring-scale.md`) and +//! reproduces the reference apitrace's measured ring radii to ~1 mm. It is NOT the render bounding sphere +//! (`0xCC`) — that (`0x5d6fe0`, `[unit+0x2b0]`) is the *corpse* decal; the render sphere over-sized tall +//! humans and under-sized the squat chicken because it folds in height, whereas the nested-sqrt footprint +//! compresses range. +//! +//! [`M2Bounds::ring_footprint`] is the model-local (pre-scale) part; the world radius is it × +//! `OBJECT_FIELD_SCALE_X`. The four creatures below were the ones captured in the reference trace, drawn +//! life-size (scale ≈ 1.0), so `ring_footprint` ≈ the measured world radius. Skips when the gitignored +//! client data isn't present. + +use benilla_formats::{load_m2_bounds, open_chain}; + +#[test] +fn ring_footprint_matches_reference_pixels() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + + // (model, ring radius measured from the reference apitrace at scale 1.0). The Stand-box footprint + // must reproduce these; a header-render-box or render-sphere source does not. + let cases = [ + ("Creature\\Chicken\\Chicken.mdx", 0.572_f32), + ("Character\\Human\\Female\\HumanFemale.mdx", 0.731), + ("Character\\Human\\Male\\HumanMale.mdx", 0.841), + ("Creature\\Horse\\Horse.mdx", 1.295), + ]; + + for (path, measured) in cases { + let b = load_m2_bounds(&mut chain, path).unwrap_or_else(|e| panic!("bounds {path}: {e}")); + assert!( + (b.ring_footprint - measured).abs() < 0.01, + "{path}: ring_footprint {:.4} should reproduce the reference-measured {measured} (Stand-box \ + sqrt(0.5·sqrt(dx²+dy²)))", + b.ring_footprint + ); + // Guard against a regression to the (wrong) corpse-path render-sphere source: for all four it is + // far off (0.5×sphere = 0.33 / 1.19 / 1.39 / 1.96), so it can't sneak past the bound above. + assert!( + (b.ring_footprint - 0.5 * b.sphere_radius).abs() > 0.05, + "{path}: ring_footprint must not coincide with the corpse-path 0.5×renderSphere" + ); + } +} diff --git a/crates/benilla-formats/tests/spell_catalog.rs b/crates/benilla-formats/tests/spell_catalog.rs new file mode 100644 index 00000000..7888f2ff --- /dev/null +++ b/crates/benilla-formats/tests/spell_catalog.rs @@ -0,0 +1,169 @@ +//! Asset-gated fixture: the Spell/SpellIcon join against the real 5875 data — pins the derived +//! columns (SpellIconID = 117, SpellName enUS = 120, SpellVisualID = 115, Speed = 37; see +//! `src/spells.rs` docs) to known spells, so a schema drift or column slip fails loudly. Skips +//! (passes) without `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{load_spell_catalog, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn spell_catalog_resolves_known_spells() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let catalog = load_spell_catalog(&mut chain).expect("load spell catalog"); + assert!( + catalog.len() > 20_000, + "5875 ships 22357 spells, got {}", + catalog.len() + ); + + // The column-derivation probes, now as regressions. + let hs = catalog.get(78).expect("Heroic Strike"); + assert_eq!(hs.name, "Heroic Strike"); + assert_eq!( + hs.icon.as_deref(), + Some("Interface\\Icons\\Ability_Rogue_Ambush") + ); + + let bs = catalog.get(6673).expect("Battle Shout"); + assert_eq!(bs.name, "Battle Shout"); + assert_eq!( + bs.icon.as_deref(), + Some("Interface\\Icons\\Ability_Warrior_BattleShout") + ); + + let attack = catalog.get(6603).expect("Attack"); + assert_eq!(attack.name, "Attack"); + assert!(attack.icon.is_some(), "auto-attack has an icon"); + + // The visual/speed column pins (decision 0107 data plane), cross-checked against the local + // vmangos `spell_template` (`spellVisual1`/`speed`) — see `src/spells.rs` docs for the method. + let fireball = catalog.get(133).expect("Fireball"); + assert_eq!(fireball.visual, 67, "Fireball's SpellVisual id"); + assert_eq!(fireball.speed, 24.0, "Fireball's projectile speed"); + + let frostbolt = catalog.get(116).expect("Frostbolt"); + assert_eq!(frostbolt.visual, 13); + assert_eq!(frostbolt.speed, 28.0); + + let corruption = catalog.get(172).expect("Corruption"); + assert_eq!(corruption.visual, 381); + assert_eq!(corruption.speed, 0.0, "a DoT tick has no projectile"); +} + +#[test] +fn shapeshift_bonus_bars_match_the_verified_table() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = benilla_formats::open_chain(&data).expect("open vanilla patch chain"); + let forms = benilla_formats::load_shapeshift_forms(&mut chain).expect("load shapeshift rows"); + // The complete 5875 non-zero BonusActionBar set (wow-re byte-verified: SpellShapeshiftForm + // field 1, the exact lookup GetBonusBarOffset's cached global is filled from). + let expect = [(1, 1), (5, 3), (8, 3), (17, 1), (18, 2), (19, 3), (30, 1)]; + let nonzero = forms.values().filter(|f| f.bonus_bar != 0).count(); + assert_eq!(nonzero, expect.len(), "exactly the seven non-zero rows"); + for (form, bar) in expect { + assert_eq!( + forms.get(&form).map(|f| f.bonus_bar), + Some(bar), + "form {form}" + ); + } + // flags1 (the form gate's stance bit): warrior stances + stealth are stances; cat is a + // true shapeshift. + for stance in [17u32, 18, 19, 30] { + assert!(forms.get(&stance).unwrap().is_stance(), "form {stance}"); + } + assert!(!forms.get(&1).unwrap().is_stance(), "cat is a shapeshift"); + // flags1 bit 0x2 (the stance bar's toggle-cancel BLOCK, wow-re shapeshift-bar-api.md): the + // three warrior stances carry it (0x7 — clicking the active stance is a silent no-op); the + // cancelable forms don't (Cat 0x70, Bear/DireBear 0x50, Ghost Wolf 0x40, Shadowform 0x9, + // Stealth 0x1, Moonkin 0x41 — probed on the extracted file). + for stance in [17u32, 18, 19] { + assert!( + !forms.get(&stance).unwrap().cancelable(), + "warrior stance {stance} blocks the toggle-cancel" + ); + } + for form in [1u32, 5, 8, 16, 28, 30, 31] { + assert!( + forms.get(&form).unwrap().cancelable(), + "form {form} is cancelable" + ); + } +} + +/// The stance-bar Spell.dbc columns (wow-re shapeshift-bar-api.md; column pins probed on the +/// extracted 5875 file, decision 0270): the MOD_SHAPESHIFT form id, the signed StanceBarOrder +/// (Stealth's −1 sorts last), and the druid forms' ActiveIconID. Skips without client data. +#[test] +fn stance_bar_spell_columns_match_the_probed_values() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = benilla_formats::open_chain(&data).expect("open vanilla patch chain"); + let catalog = load_spell_catalog(&mut chain).expect("load spell catalog"); + // (spell, form id, order): Battle/Defensive/Berserker Stance, Bear, Cat, Stealth, Moonkin. + for (spell, form, order) in [ + (2457u32, 17u32, 0i32), + (71, 18, 1), + (2458, 19, 2), + (5487, 5, 0), + (768, 1, 2), + (1784, 30, -1), + (24858, 31, 4), + ] { + let d = catalog.get(spell).expect("form spell"); + assert_eq!(d.shapeshift_form, Some(form), "spell {spell} form"); + assert_eq!(d.stance_bar_order, order, "spell {spell} order"); + } + // ActiveIconID: druid forms carry the dismiss-paw (icon 122 resolves); warrior stances 0. + assert!(catalog.get(5487).unwrap().active_icon.is_some(), "bear"); + assert!(catalog.get(2457).unwrap().active_icon.is_none(), "battle"); + // A non-form spell reads none of it. + let fireball = catalog.get(133).unwrap(); + assert_eq!(fireball.shapeshift_form, None); +} + +/// AttributesEx3 (column 9) bit 15 — `SPELL_ATTR3_NORMAL_RANGED_ATTACK`, the combat-text +/// melee-white flip (decision 0376): set on exactly the ranged basic shots, clear on melee +/// abilities and true spells. +#[test] +fn melee_white_damage_marks_the_ranged_basic_shots() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let catalog = load_spell_catalog(&mut chain).expect("load spell catalog"); + // Auto Shot, Shoot Bow, Throw, wand Shoot: the white-damage ranged shots. + for id in [75u32, 2480, 2764, 5019] { + assert!( + catalog.get(id).unwrap().melee_white_damage(), + "spell {id} should carry AttributesEx3 & 0x8000" + ); + } + // Heroic Strike (a melee ability — its yellow rides the ATTACKERSTATEUPDATE spell-id rider, + // not this bit), Fireball, and Attack itself: all clear. + for id in [78u32, 133, 6603] { + assert!( + !catalog.get(id).unwrap().melee_white_damage(), + "spell {id} should NOT carry AttributesEx3 & 0x8000" + ); + } +} diff --git a/crates/benilla-formats/tests/transport_speed_profile.rs b/crates/benilla-formats/tests/transport_speed_profile.rs new file mode 100644 index 00000000..6e1ac40a --- /dev/null +++ b/crates/benilla-formats/tests/transport_speed_profile.rs @@ -0,0 +1,120 @@ +//! The transport speed-profile probe (decision 0438's reconciliation instrument, director report +//! 2026-07-17: "ship lagged a bit on the way into Booty Bay"). +//! +//! Samples the Ratchet–Booty Bay boat's timetable at 60 Hz across its whole cycle and measures the +//! finite-difference speed. Root-caused the report same day: ClientForms-mode u32 windows +//! (distance-linear interior timestamps) fed the trapezoid easing — seconds of disagreement, so +//! the boat pinned at segment ends and leapt on window flips (65 spikes, max 15,009 yd/s, one +//! 59 s crawl). `build()` now uses the internally-consistent Vmangos accumulation for the sample +//! table; this test stays as the smooth-motion regression gate. Anomaly classes: +//! - **speed spikes ≫ cruise (30 yd/s)** at segment boundaries → inter-segment position +//! discontinuities (the integer arrive/depart times disagreeing with the float easing) — reads +//! as stutter; +//! - **smooth but modulated speed** (swings well above/below cruise inside segments, worst on +//! curves) → the Catmull-Rom *parameter* fraction standing in for an *arc-length* fraction — +//! reads as the boat surging/dragging through bends; +//! - **neither** → the lag wasn't the timetable (frame hitches from Booty Bay's asset streaming +//! are the next suspect). +//! +//! Run: `cargo test -p benilla-formats --test transport_speed_profile -- --nocapture` + +use benilla_formats::{load_taxi_path_nodes, open_chain, TransportTimetable}; + +fn dist(a: [f32; 3], b: [f32; 3]) -> f32 { + let (dx, dy, dz) = (a[0] - b[0], a[1] - b[1], a[2] - b[2]); + (dx * dx + dy * dy + dz * dz).sqrt() +} + +#[test] +fn ratchet_booty_bay_speed_profile() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open chain"); + let nodes = load_taxi_path_nodes(&mut chain).expect("taxi nodes"); + let path = nodes.path(241).expect("path 241 (Ratchet–Booty Bay)"); + let tt = TransportTimetable::build(path, 30.0, 1.0).expect("timetable"); + // The build self-pins its period to the client-transcribed bookkeeping (bit-exact, wow-re + // §5 2026-07-17); this cross-checks the pin landed for the path under measurement. + assert_eq!(tt.period_ms, 350_818, "path 241's self-pinned period"); + + let step_ms = 16u32; // ~60 Hz + let dt = step_ms as f32 * 0.001; + let mut prev = tt.sample(0); + let mut moving_samples = 0u32; + let mut speed_sum = 0.0f64; + let mut max_speed = 0.0f32; + let mut max_speed_at = 0u32; + let mut spikes = Vec::new(); // (cycle_ms, speed) where speed > 1.5× cruise + let mut slow_underway = Vec::new(); // (cycle_ms, speed) where moving but < 0.5× cruise + let mut t = step_ms; + while t < tt.period_ms { + let cur = tt.sample(t); + if cur.moving && prev.moving && cur.map == prev.map { + let v = dist(cur.pos, prev.pos) / dt; + moving_samples += 1; + speed_sum += f64::from(v); + if v > max_speed { + max_speed = v; + max_speed_at = t; + } + if v > 45.0 { + spikes.push((t, v)); + } else if v < 15.0 { + slow_underway.push((t, v)); + } + } + prev = cur; + t += step_ms; + } + + let mean = speed_sum / f64::from(moving_samples.max(1)); + eprintln!( + "path 241: period {} ms, {} moving samples", + tt.period_ms, moving_samples + ); + eprintln!("mean speed {mean:.2} yd/s (cruise 30), max {max_speed:.2} at cycle {max_speed_at}"); + eprintln!( + "spikes >45 yd/s: {} samples{}", + spikes.len(), + if spikes.is_empty() { + String::new() + } else { + format!(" — first 10: {:?}", &spikes[..spikes.len().min(10)]) + } + ); + // Cluster the slow samples into runs so the report reads as "windows", not thousands of rows. + let mut slow_runs: Vec<(u32, u32, f32)> = Vec::new(); // (start, end, min_speed) + for &(t, v) in &slow_underway { + match slow_runs.last_mut() { + Some((_, end, min_v)) if t - *end <= 2 * step_ms => { + *end = t; + *min_v = min_v.min(v); + } + _ => slow_runs.push((t, t, v)), + } + } + eprintln!( + "slow-underway (<15 yd/s while moving): {} samples in {} runs", + slow_underway.len(), + slow_runs.len() + ); + for (start, end, min_v) in slow_runs.iter().take(20) { + eprintln!( + " cycle {start}..{end} ms ({} s long), min {min_v:.2} yd/s", + (end - start) / 1000 + ); + } + + // The structural assertion: a window/easing inconsistency reads as a position jump traversed + // in one 16 ms step — hundreds to thousands of yd/s (the pre-fix table measured 15,009). + // Healthy motion peaks ~51 yd/s (the Catmull-Rom parameter-vs-arc-length artifact through one + // sharp bend, ~1.5 s per cycle — a known, bounded residual pending the wow-re per-point-table + // verdict, decision 0439). + assert!( + max_speed < 100.0, + "position discontinuity: {max_speed:.1} yd/s at cycle {max_speed_at} ms" + ); +} diff --git a/crates/benilla-formats/tests/wmo_fogs.rs b/crates/benilla-formats/tests/wmo_fogs.rs new file mode 100644 index 00000000..25b0a97b --- /dev/null +++ b/crates/benilla-formats/tests/wmo_fogs.rs @@ -0,0 +1,54 @@ +//! WMO MFOG + group fog-index parse — byte check against the Goldshire inn (the interior-fog +//! fold's reference building, wow-re `rf-weather-emission-timeline` ROUND 5). Pins the MFOG +//! record decode AND the MOGP fog-index disk offset (`0x30` — an earlier from-memory `0x40` +//! guess read zeros there; the `uniqueID @0x38` / no-liquid `@0x34` neighbours self-validate +//! the layout). Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_wmo_fogs, wmo_group_header, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn goldshire_inn_fogs_and_group_indices() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let root = reader + .read("World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn.wmo") + .expect("read GoldshireInn.wmo"); + + // Two MFOG records: the WMO default (the selector's seed, record 0) and a denser room fog. + // Both are the warm tavern cream the storm veil must fade toward indoors. + let fogs = parse_wmo_fogs(&root); + assert_eq!(fogs.len(), 2, "inn should carry 2 MFOG records"); + assert!((fogs[0].fog_end - 194.44444).abs() < 1e-3); + assert!((fogs[0].fog_start_scalar - 0.25).abs() < 1e-6); + assert_eq!(fogs[0].color, 0xfffad890, "record 0: warm cream (ARGB)"); + assert_eq!(fogs[1].flags, 0x1); + assert!((fogs[1].fog_end - 83.333336).abs() < 1e-3); + assert_eq!(fogs[1].color, 0xfffdcf9e); + + // Group fog indices at disk +0x30: the tavern rooms point at record 1, the rest at the + // default. area_table_id doubles as the layout's self-check (893.. = the inn's rows). + let group = |gi: u32| { + let bytes = reader + .read(&format!( + "World\\wmo\\Azeroth\\Buildings\\GoldshireInn\\GoldshireInn_{gi:03}.wmo" + )) + .expect("read inn group"); + wmo_group_header(&bytes).expect("group header") + }; + let g0 = group(0); + assert_eq!(g0.area_table_id, 893); + assert_eq!(g0.fog_indices, [1, 0, 0, 0]); + let g2 = group(2); + assert_eq!(g2.area_table_id, 895); + assert_eq!(g2.fog_indices, [0, 0, 0, 0]); +} diff --git a/crates/benilla-formats/tests/wmo_lights.rs b/crates/benilla-formats/tests/wmo_lights.rs new file mode 100644 index 00000000..f60c8480 --- /dev/null +++ b/crates/benilla-formats/tests/wmo_lights.rs @@ -0,0 +1,48 @@ +//! WMO MOLT light parse — byte check against the Goldshire blacksmith's 3 warm forge lights. Pins the +//! chunk walk *past* zero-size chunks (`MOVV(0)`/`MOVB(0)` sit right before MOLT — a walk that stops +//! there finds nothing) and the SMOLight field offsets. Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_wmo_lights, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn goldshire_blacksmith_has_three_warm_omni_lights() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let bytes = reader + .read("World\\wmo\\Azeroth\\Buildings\\GoldshireBlacksmith\\GoldshireBlacksmith.wmo") + .expect("read GoldshireBlacksmith.wmo"); + + let lights = parse_wmo_lights(&bytes); + // 3 MOLT lights (the forge). A chunk walk that breaks at MOVV(0)/MOVB(0) — which precede MOLT — + // would find 0; this is the regression guard for that bug. + assert_eq!(lights.len(), 3, "blacksmith should have 3 MOLT lights"); + for l in &lights { + assert!( + l.is_omni(), + "forge MOLT lights are type 0 (omni), got {}", + l.light_type + ); + // The forge glow is warm orange — RGB(255,140,37) ≈ (1.0, 0.55, 0.145). Pins the BGRA decode + // and the 0x30 stride (a garbled offset would not land on a warm colour). + let c = l.color; + assert!( + c[0] > 0.9 && c[1] > 0.4 && c[1] < 0.7 && c[2] < 0.25, + "forge light should be warm-orange, got {c:?}" + ); + } +} + +#[test] +fn no_molt_yields_empty() { + assert!(parse_wmo_lights(&[0u8; 8]).is_empty()); +} diff --git a/crates/benilla-formats/tests/wmo_liquid.rs b/crates/benilla-formats/tests/wmo_liquid.rs new file mode 100644 index 00000000..1666ffac --- /dev/null +++ b/crates/benilla-formats/tests/wmo_liquid.rs @@ -0,0 +1,58 @@ +//! WMO MLIQ liquid-surface build — byte check against Stormwind's canals (the reference building +//! for WMO-embedded water; its city is one 306-group WMO, 22 of whose groups carry `MLIQ`). Pins +//! the SMOLiquidHeader decode, the `xtiles = xverts − 1` grid, the per-tile hole nibble (`0xf`), and +//! the lake_a (nibble 4) type resolution. Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{wmo_group_liquid_mesh, Chain, LiquidKind}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn stormwind_canal_group_builds_still_water() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + + // Group 099 is a canal segment: MLIQ header xverts=12 yverts=9 (12×9 = 108 verts), 11×8 = 88 + // tiles of which 52 are wet (nibble 4 = lake_a) and 36 are holes (nibble 0xf). + let g099 = reader + .read("World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind_099.wmo") + .expect("read Stormwind_099.wmo"); + let mesh = wmo_group_liquid_mesh(&g099).expect("group 099 carries water"); + assert_eq!(mesh.kind, LiquidKind::Still, "canal water is lake_a/still"); + assert_eq!(mesh.positions.len(), 12 * 9, "full 12×9 grid emitted"); + assert_eq!(mesh.uvs.len(), 12 * 9); + assert_eq!(mesh.depths.len(), 12 * 9); + assert_eq!( + mesh.indices.len(), + 52 * 6, + "52 wet tiles → 2 tris each; the 36 hole tiles are skipped" + ); + // Every index is in range and every vertex height is a sane, finite value (flat canal water). + assert!(mesh + .indices + .iter() + .all(|&i| (i as usize) < mesh.positions.len())); + for p in &mesh.positions { + assert!( + p[2].is_finite() && p[2].abs() < 10_000.0, + "sane height {p:?}" + ); + } + + // Group 000 is dry masonry — no MLIQ, so no liquid mesh. + let g000 = reader + .read("World\\wmo\\Azeroth\\Buildings\\Stormwind\\Stormwind_000.wmo") + .expect("read Stormwind_000.wmo"); + assert!( + wmo_group_liquid_mesh(&g000).is_none(), + "a masonry group carries no liquid" + ); +} diff --git a/crates/benilla-m2/Cargo.toml b/crates/benilla-m2/Cargo.toml new file mode 100644 index 00000000..a6923e78 --- /dev/null +++ b/crates/benilla-m2/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "benilla-m2" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "M2 (MD20) model reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021" + +[dependencies] +benilla-bytes = { path = "../benilla-bytes" } diff --git a/crates/benilla-m2/examples/dump_attach.rs b/crates/benilla-m2/examples/dump_attach.rs new file mode 100644 index 00000000..d7e56d37 --- /dev/null +++ b/crates/benilla-m2/examples/dump_attach.rs @@ -0,0 +1,38 @@ +//! Session probe: dump an M2's attachment table (+ each attach bone's parent/pivot) — +//! `cargo run -p benilla-m2 --example dump_attach `. Extract the file first via +//! `mpqx` (benilla-mpq). Built for the nocked-ammo RE round (decision 0406 follow-up), kept +//! because "which attach ids does this model actually have" keeps coming up. + +use std::io::Cursor; + +fn main() { + let path = std::env::args() + .nth(1) + .expect("usage: dump_attach "); + let data = std::fs::read(&path).expect("read m2"); + let m2 = benilla_m2::parse_m2(&mut Cursor::new(data.as_slice())).expect("parse m2"); + let model = m2.model(); + println!("attachments: {}", model.attachments.len()); + for a in &model.attachments { + let bone = model.bones.get(a.bone as usize); + let parent = bone.map_or(-1, |b| b.parent); + println!( + "id {:2} bone {:3} (parent {:3}) pos ({:+.3},{:+.3},{:+.3})", + a.id, a.bone, parent, a.position[0], a.position[1], a.position[2] + ); + } + println!("event markers: {}", model.event_markers.len()); + for m in &model.event_markers { + let bone = model.bones.get(m.bone as usize); + let parent = bone.map_or(-1, |b| b.parent); + println!( + "{} bone {:3} (parent {:3}) pos ({:+.3},{:+.3},{:+.3})", + String::from_utf8_lossy(&m.ident), + m.bone, + parent, + m.position[0], + m.position[1], + m.position[2] + ); + } +} diff --git a/crates/benilla-m2/examples/dump_skin.rs b/crates/benilla-m2/examples/dump_skin.rs new file mode 100644 index 00000000..ed94bcdb --- /dev/null +++ b/crates/benilla-m2/examples/dump_skin.rs @@ -0,0 +1,70 @@ +//! Session probe: dump an M2's skin-0 sections/batches with material + texture resolution — +//! `cargo run -p benilla-m2 --example dump_skin `. Extract the file first via `mpqx` +//! (benilla-mpq). Built for the bowstring round (decision 0406 follow-up), kept because "why is +//! this submesh (not) rendering" keeps coming up. + +use std::io::Cursor; + +fn main() { + let path = std::env::args().nth(1).expect("usage: dump_skin "); + let data = std::fs::read(&path).expect("read m2"); + let m2 = benilla_m2::parse_m2(&mut Cursor::new(data.as_slice())).expect("parse m2"); + let model = m2.model(); + println!("textures: {}", model.textures.len()); + for (i, t) in model.textures.iter().enumerate() { + use benilla_m2::M2TextureType as T; + let ty = match t.texture_type { + T::Hardcoded => "hardcoded".to_string(), + T::Monster1 => "monster1".to_string(), + T::Monster2 => "monster2".to_string(), + T::Monster3 => "monster3".to_string(), + T::Other(v) => format!("other({v})"), + }; + println!( + " tex {i}: type {ty} file {:?}", + t.filename.string.to_string_lossy() + ); + } + println!("materials: {}", model.materials.len()); + for (i, m) in model.materials.iter().enumerate() { + println!( + " mat {i}: flags {:#06x} blend {}", + m.flags.bits(), + m.blend_mode.bits() + ); + } + println!( + "colors: {} transparency tracks: {} (lookup {:?})", + model.color_alpha_tracks.len(), + model.transparency_tracks.len(), + model.transparency_lookup + ); + println!( + "texture_lookup_table: {:?}", + model.raw_data.texture_lookup_table + ); + let skin = model.parse_embedded_skin(&data, 0).expect("skin 0"); + println!( + "skin 0: {} sections, {} batches", + skin.submeshes().len(), + skin.batches().len() + ); + for (i, s) in skin.submeshes().iter().enumerate() { + println!( + " section {i}: id {} tri_start {} tri_count {}", + s.id, s.triangle_start, s.triangle_count + ); + } + for (i, b) in skin.batches().iter().enumerate() { + println!( + " batch {i}: section {} tex_combo {} mat {} color {} weight_combo {} tex_count {} uvanim {}", + b.skin_section_index, + b.texture_combo_index, + b.material_index, + b.color_index, + b.weight_combo_index, + b.texture_count, + b.texture_transform_combo_index + ); + } +} diff --git a/crates/benilla-m2/examples/dump_verts.rs b/crates/benilla-m2/examples/dump_verts.rs new file mode 100644 index 00000000..4b608dc5 --- /dev/null +++ b/crates/benilla-m2/examples/dump_verts.rs @@ -0,0 +1,39 @@ +//! Session probe: dump an M2's vertices (position / UV / bone binding) plus bone table — +//! `cargo run -p benilla-m2 --example dump_verts `. Built for the bowstring round: +//! find which triangles form the string and how they're textured/bound. + +use std::io::Cursor; + +fn main() { + let path = std::env::args() + .nth(1) + .expect("usage: dump_verts "); + let data = std::fs::read(&path).expect("read m2"); + let m2 = benilla_m2::parse_m2(&mut Cursor::new(data.as_slice())).expect("parse m2"); + let model = m2.model(); + println!("bones: {}", model.bones.len()); + for (i, b) in model.bones.iter().enumerate() { + println!( + " bone {i}: key {} parent {} flags {:#06x} pivot ({:+.3},{:+.3},{:+.3})", + b.key_bone, + b.parent, + b.flags.bits(), + b.pivot.x, + b.pivot.y, + b.pivot.z + ); + } + println!("vertices: {}", model.vertices.len()); + for (i, v) in model.vertices.iter().enumerate() { + println!( + " v {i:3}: pos ({:+.3},{:+.3},{:+.3}) uv ({:+.3},{:+.3}) bones {:?} w {:?}", + v.position.x, + v.position.y, + v.position.z, + v.tex_coords.x, + v.tex_coords.y, + v.bone_indices, + v.bone_weights + ); + } +} diff --git a/crates/benilla-m2/src/error.rs b/crates/benilla-m2/src/error.rs new file mode 100644 index 00000000..2d87e304 --- /dev/null +++ b/crates/benilla-m2/src/error.rs @@ -0,0 +1,22 @@ +//! The crate's parse error type. + +/// M2 parse error. +#[derive(Debug)] +pub enum Error { + NotMd20, + UnsupportedVersion(u32), + Truncated, +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotMd20 => write!(f, "not an MD20 model"), + Error::UnsupportedVersion(v) => { + write!(f, "unsupported M2 version {v} (expected 256..=263)") + } + Error::Truncated => write!(f, "truncated M2"), + } + } +} +impl std::error::Error for Error {} +pub(crate) type Result = std::result::Result; diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs new file mode 100644 index 00000000..c787da02 --- /dev/null +++ b/crates/benilla-m2/src/lib.rs @@ -0,0 +1,487 @@ +//! An M2 (MD20) model reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing the `wow-m2` fork +//! (decision 0021), scoped to the **render path** the renderer consumes. +//! +//! Vanilla `.m2` files are MD20 version **256/257**: a fixed header of `M2Array`s (each a `count` + +//! file `offset`), then the referenced arrays, with the **skin profile(s) embedded** in the same file. +//! We read only what the mesh builder needs — vertices, textures, materials (render flags), bones +//! (pivot + flags), the texture-lookup table, and skin profile 0 (indices / triangles / submeshes / +//! batches) — and deliberately **skip the cosmetic chunks** (particle emitters, lights, texture +//! animations). That sidesteps exactly what the `wow-m2` fork patched: those chunks can be malformed +//! in Classic art and abort a full parse; benilla-formats parses lights/particles itself from raw +//! bytes, so we never touch them here. +//! +//! Proven against `wow-m2` over real models during the decision-0021 migration (oracle test in git +//! history); the creature/GameObject mesh + cosmetic-chunk-model golden tests in benilla-formats pin +//! it end-to-end on every run. +//! +//! Byte access goes through `benilla-bytes` (decision 0064): every read is bounds-checked and a +//! truncated array/record is a typed [`Error::Truncated`], never a panic; header-driven `Vec` +//! reservations are capped by what the input could actually hold, so a corrupt count can no longer +//! OOM-abort the process before the first bounds-checked read ever runs. +//! +//! Split by concern: this file keeps the public re-export surface and the top-level [`parse_m2`] +//! entry + MD20 header walk; `model` holds the plain parsed-record types, `track` the M2Track +//! scalar/vector readers, `skin` the embedded skin-profile decoder, and `error` the parse error type. + +mod error; +mod model; +mod skin; +mod track; + +pub use error::Error; +pub use model::{ + M2ArrayString, M2Attachment, M2BlendMode, M2Bone, M2BoneFlags, M2EventMarker, M2Format, + M2Header, M2Material, M2Model, M2PlayableAnim, M2RawData, M2RenderFlags, M2Texture, + M2TextureTransform, M2TextureType, M2Vertex, C2, C3, +}; +pub use skin::{Skin, SkinBatch, SkinSection}; +pub use track::{M2QuatTrack, M2ScalarTrack, M2Track, M2Vec3Track}; + +use std::ffi::CString; +use std::io::Cursor; + +use benilla_bytes::{capped, ByteExt}; + +use error::Result; +use track::{track_fix16, track_quat, track_vec3_timed}; + +/// Read a `C3Vector` (3×f32) at `o`, bounds-checked. `None` iff any of the three reads would run +/// past the end — callers map that to their own [`Error::Truncated`]. +fn rd_c3(b: &[u8], o: usize) -> Option { + Some(C3 { + x: b.f32_at(o)?, + y: b.f32_at(o + 4)?, + z: b.f32_at(o + 8)?, + }) +} + +/// Parse an MD20 model (v256/257). `cursor` position is ignored; the file starts at the header. +pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { + let b: &[u8] = cursor.get_ref(); + if b.len() < 8 || &b[0..4] != b"MD20" { + return Err(Error::NotMd20); + } + let version = b.u32_at(4).ok_or(Error::Truncated)?; + if !(256..=263).contains(&version) { + return Err(Error::UnsupportedVersion(version)); + } + + // Walk the header reading the M2Array `(count, offset)` pairs we need (8 bytes each), applying the + // pre-Wrath version conditionals. Layout order per wowdev ADT/M2 + the reference. + let arr = |p: usize| -> Result<(u32, u32)> { + Ok(( + b.u32_at(p).ok_or(Error::Truncated)?, + b.u32_at(p + 4).ok_or(Error::Truncated)?, + )) + }; + let mut p = 8; + let _name = arr(p)?; + p += 8; + p += 4; // flags u32 + let _global = arr(p)?; + p += 8; + let _anim = arr(p)?; + p += 8; + let _anim_lookup = arr(p)?; + p += 8; + // PlayableAnimationLookup (decision 0082, header `+0x2c`/`+0x30`, pre-Wrath only — dropped past + // version 263, which `parse_m2`'s own top guard already excludes, so this is unconditional on any + // version that reaches here; the `if` stays for documentation). + let playable_animation_lookup_arr = if (256..=263).contains(&version) { + let v = arr(p)?; + p += 8; + v + } else { + (0, 0) + }; + let bones = arr(p)?; + p += 8; + let _key_bone = arr(p)?; + p += 8; + let vertices = arr(p)?; + p += 8; + let views = if version <= 263 { + let v = arr(p)?; + p += 8; + v + } else { + p += 4; + (0, 0) + }; + let colors_arr = arr(p)?; // header 0x54: M2Color[] (colour + alpha tracks) + p += 8; + let textures = arr(p)?; + p += 8; + let transparency_arr = arr(p)?; // header 0x64: M2TextureWeight[] (weight tracks) + p += 8; + if version <= 263 { + p += 8; // texture_flipbooks (pre-Wrath) + } + let tex_anim_arr = arr(p)?; // header 0x74: M2TextureTransform[] (3 UV-animation tracks each) + p += 8; + let _color_repl = arr(p)?; + p += 8; + let render_flags = arr(p)?; + p += 8; + let _bone_lookup = arr(p)?; + p += 8; + let texture_lookup_table = arr(p)?; + p += 8; + // header 0x9c: texture_unit_lookup (the env-vs-UV mapping table) — skipped for now. + // + // The three lookup slots here follow wow-re's *stride-pin reconciliation* (renderFlags pinned + // at 0x84 ⇒ texLookup 0x94 · texUnitLookup 0x9c · transLookup 0xa4 · texAnimLookup 0xac), NOT + // the stale one-slot-early labels in models.md's field-map line. Byte-proof on real art: + // ElwynnTallWaterfall01 (2 transforms, batch combos 0/1) has [0,1] at 0xac, and + // StormwindMagePortal01 (4 weight tracks) has the identity [0,1,2,3] at 0xa4 with [0] at 0x9c + // — reading 0x9c as the transparency lookup silently dropped the portal's combo-1..3 tracks. + p += 8; + // header 0xa4: transparency_lookup (u16) — `weight_combo_index` indexes it to reach a weight track. + let transparency_lookup_arr = arr(p)?; + p += 8; + // header 0xac: texture_animation_lookup (u16) — `texture_transform_combo_index` (texUnit + // +0x16) indexes it to reach a texture transform (wow-re rf72 `0x70b897`); 0xffff = none. + let tex_anim_lookup_arr = arr(p)?; + p += 8; + // Authored bounds: AABB (min C3, max C3) then sphere radius. + let read_box = |o: usize| -> Result<[f32; 3]> { + Ok([ + b.f32_at(o).ok_or(Error::Truncated)?, + b.f32_at(o + 4).ok_or(Error::Truncated)?, + b.f32_at(o + 8).ok_or(Error::Truncated)?, + ]) + }; + let bounding_box_min = read_box(p)?; + p += 12; + let bounding_box_max = read_box(p)?; + p += 12; + let bounding_sphere_radius = b.f32_at(p).ok_or(Error::Truncated)?; + p += 4; + // The collision box (min C3 + max C3) + collision sphere radius — the tight collision hull (28 bytes). + let collision_box_min = read_box(p)?; + p += 12; + let collision_box_max = read_box(p)?; + p += 12; + let collision_sphere_radius = b.f32_at(p).ok_or(Error::Truncated)?; + p += 4; + let bounding_triangles = arr(p)?; + p += 8; + let bounding_vertices = arr(p)?; + p += 8; + // Continue the header to the attachments block: boundingNormals, then attachments + attachLookup. + let _bounding_normals = arr(p)?; + p += 8; + let attachments = arr(p)?; + p += 8; + let attach_lookup = arr(p)?; + p += 8; + // header 0x114: the animation-event table (the `$CSL`/`$BWR`/`$SND`… records) — the positional + // markers are kept below; the per-sequence fire keys are `benilla-formats`' concern. + let events = arr(p)?; + // The follow-camera pivot height source (wow-re `follow-camera`, `0x50cbc0`): attachment **id 17**'s + // model-space Z. `None` if the model has no slot-17 attachment (caller falls back to the vertex box). + let pivot_attach_z = attachment_z(b, 17, attachments, attach_lookup); + + // --- read the arrays we keep --- + let get = |start: usize, len: usize| -> Result<&[u8]> { + b.bytes_at(start, len).ok_or(Error::Truncated) + }; + + // Vertices: 48 bytes each. The reservation is capped by what the file could actually hold + // (decision 0064, `benilla_bytes::capped`): a corrupt/hostile `vertices.0` count can then at + // worst reserve the input's own size, never OOM-abort the process before the loop reaches its + // first bounds-checked read. The loop below still walks the *real* declared count — a short + // file fails cleanly at `get(..)?` instead. + let vert_avail = b.len().saturating_sub(vertices.1 as usize); + let mut verts = Vec::with_capacity(capped(vertices.0 as usize, 48, vert_avail)); + for i in 0..vertices.0 as usize { + let v = get(vertices.1 as usize + i * 48, 48)?; + verts.push(M2Vertex { + position: rd_c3(v, 0).ok_or(Error::Truncated)?, + bone_weights: [v[12], v[13], v[14], v[15]], + bone_indices: [v[16], v[17], v[18], v[19]], + normal: rd_c3(v, 20).ok_or(Error::Truncated)?, + tex_coords: C2 { + x: v.f32_at(32).ok_or(Error::Truncated)?, + y: v.f32_at(36).ok_or(Error::Truncated)?, + }, + }); + } + + // Textures: 16 bytes each (type u32, flags u32, filename M2Array). + let tex_avail = b.len().saturating_sub(textures.1 as usize); + let mut texs = Vec::with_capacity(capped(textures.0 as usize, 16, tex_avail)); + for i in 0..textures.0 as usize { + let t = get(textures.1 as usize + i * 16, 16)?; + let ttype = M2TextureType::from_u32(t.u32_at(0).ok_or(Error::Truncated)?); + let (fcount, fofs) = ( + t.u32_at(8).ok_or(Error::Truncated)? as usize, + t.u32_at(12).ok_or(Error::Truncated)? as usize, + ); + let raw = b.bytes_at(fofs, fcount).unwrap_or(&[]); + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + let string = CString::new(&raw[..end]).unwrap_or_default(); + texs.push(M2Texture { + texture_type: ttype, + filename: M2ArrayString { string }, + }); + } + + // Materials (render flags): 4 bytes each (flags u16, blend_mode u16). + let flags_avail = b.len().saturating_sub(render_flags.1 as usize); + let mut mats = Vec::with_capacity(capped(render_flags.0 as usize, 4, flags_avail)); + for i in 0..render_flags.0 as usize { + let m = get(render_flags.1 as usize + i * 4, 4)?; + mats.push(M2Material { + flags: M2RenderFlags(m.u16_at(0).ok_or(Error::Truncated)?), + blend_mode: M2BlendMode(m.u16_at(2).ok_or(Error::Truncated)?), + }); + } + + // Bones: 108 bytes each in v256 — header 12 (id i32, flags u32, parent i16, submesh u16; vanilla + // has NO boneNameCRC field, unlike TBC+), then 3×28 pre-Wrath tracks, then pivot C3. flags @4, + // pivot @96. The reference zeroes NaN pivot components (a corruption guard); mirror it. + let bones_avail = b.len().saturating_sub(bones.1 as usize); + let mut bone_list = Vec::with_capacity(capped(bones.0 as usize, 108, bones_avail)); + for i in 0..bones.0 as usize { + let bn = get(bones.1 as usize + i * 108, 108)?; + let mut pivot = rd_c3(bn, 96).ok_or(Error::Truncated)?; + if pivot.x.is_nan() { + pivot.x = 0.0; + } + if pivot.y.is_nan() { + pivot.y = 0.0; + } + if pivot.z.is_nan() { + pivot.z = 0.0; + } + bone_list.push(M2Bone { + key_bone: bn.u32_at(0).ok_or(Error::Truncated)? as i32 as i16, + flags: M2BoneFlags(bn.u32_at(4).ok_or(Error::Truncated)?), + parent: bn.u16_at(8).ok_or(Error::Truncated)? as i16, + pivot, + }); + } + + // Attachments: 48 bytes each (id u32 @0, bone u32 @4, position C3 @8; a 28-byte visibility + // M2Track follows and is skipped — see [`M2Attachment`]). `id`/`bone` are narrowed to `u16`; a + // record whose id/bone doesn't fit, or whose bone indexes past `bone_list` (now fully parsed + // above), is dropped rather than kept malformed — real attachment ids run ≤ 36, so this never + // drops legitimate data. + let att_avail = b.len().saturating_sub(attachments.1 as usize); + let mut attachment_list = Vec::with_capacity(capped(attachments.0 as usize, 48, att_avail)); + for i in 0..attachments.0 as usize { + let a = get(attachments.1 as usize + i * 48, 48)?; + let id = a.u32_at(0).ok_or(Error::Truncated)?; + let bone = a.u32_at(4).ok_or(Error::Truncated)?; + let position = rd_c3(a, 8).ok_or(Error::Truncated)?; + let (Ok(id), Ok(bone)) = (u16::try_from(id), u16::try_from(bone)) else { + continue; + }; + if bone as usize >= bone_list.len() { + continue; + } + attachment_list.push(M2Attachment { + id, + bone, + position: [position.x, position.y, position.z], + }); + } + + // Event markers: 44 bytes each (identifier 4CC @0, data u32 @4, bone u32 @8, position C3 @12; + // a 20-byte enabled M2TrackBase follows and is skipped). Same bone-range discipline as the + // attachments above; kept in file order (queries take the first ident match). + let ev_avail = b.len().saturating_sub(events.1 as usize); + let mut event_markers = Vec::with_capacity(capped(events.0 as usize, 44, ev_avail)); + for i in 0..events.0 as usize { + let e = get(events.1 as usize + i * 44, 44)?; + let ident = [e[0], e[1], e[2], e[3]]; + let bone = e.u32_at(8).ok_or(Error::Truncated)?; + let position = rd_c3(e, 12).ok_or(Error::Truncated)?; + let Ok(bone) = u16::try_from(bone) else { + continue; + }; + if bone as usize >= bone_list.len() { + continue; + } + event_markers.push(M2EventMarker { + ident, + bone, + position: [position.x, position.y, position.z], + }); + } + + // PlayableAnimationLookup (decision 0082): one dword each, low16 resolved id / high16 dir flags. + let pal_avail = b + .len() + .saturating_sub(playable_animation_lookup_arr.1 as usize); + let mut playable_animation_lookup = Vec::with_capacity(capped( + playable_animation_lookup_arr.0 as usize, + 4, + pal_avail, + )); + for i in 0..playable_animation_lookup_arr.0 as usize { + let dword = get(playable_animation_lookup_arr.1 as usize + i * 4, 4)? + .u32_at(0) + .ok_or(Error::Truncated)?; + playable_animation_lookup.push(M2PlayableAnim { + resolved_id: (dword & 0xffff) as u16, + dir_flags: (dword >> 16) as u16, + }); + } + + // Texture lookup table: u16 each. + let tlt_avail = b.len().saturating_sub(texture_lookup_table.1 as usize); + let mut tlt = Vec::with_capacity(capped(texture_lookup_table.0 as usize, 2, tlt_avail)); + for i in 0..texture_lookup_table.0 as usize { + tlt.push( + get(texture_lookup_table.1 as usize + i * 2, 2)? + .u16_at(0) + .ok_or(Error::Truncated)?, + ); + } + + // Each M2Color (stride 0x38) is an RGB **colour** track @ +0x00 (C3Vector keys) + an **alpha** + // track @ +0x1c (fix16); each M2TextureWeight (stride 0x1c) is a single **weight** track. The + // alpha/weight values gate batch visibility (a constant 0 hides the batch — wow-re + // `m2-alpha-combine-cull`); the RGB is the per-batch tint multiplied into the vertex colour. + // `track_fix16`/`track_vec3_timed` bounds-check internally and return an empty key list for + // an out-of-range track (kept exactly — real art relies on that "no keys" tolerance); the + // reservation for the *outer* per-record `Vec` is capped the same way as the arrays above. + let colors_avail = b.len().saturating_sub(colors_arr.1 as usize); + let colors_cap = capped(colors_arr.0 as usize, 0x38, colors_avail); + let mut color_alpha_tracks = Vec::with_capacity(colors_cap); + let mut color_rgb_tracks = Vec::with_capacity(colors_cap); + for i in 0..colors_arr.0 as usize { + color_alpha_tracks.push(track_fix16(b, colors_arr.1 as usize + i * 0x38 + 0x1c)); + color_rgb_tracks.push(track_vec3_timed(b, colors_arr.1 as usize + i * 0x38)); + } + let transparency_avail = b.len().saturating_sub(transparency_arr.1 as usize); + let transparency_cap = capped(transparency_arr.0 as usize, 0x1c, transparency_avail); + let mut transparency_tracks = Vec::with_capacity(transparency_cap); + for i in 0..transparency_arr.0 as usize { + transparency_tracks.push(track_fix16(b, transparency_arr.1 as usize + i * 0x1c)); + } + let tlookup_avail = b.len().saturating_sub(transparency_lookup_arr.1 as usize); + let mut transparency_lookup = + Vec::with_capacity(capped(transparency_lookup_arr.0 as usize, 2, tlookup_avail)); + for i in 0..transparency_lookup_arr.0 as usize { + transparency_lookup.push( + get(transparency_lookup_arr.1 as usize + i * 2, 2)? + .u16_at(0) + .ok_or(Error::Truncated)?, + ); + } + + // Each M2TextureTransform (header 0x74, stride 0x54) is 3 back-to-back M2Tracks — translation + // (C3Vector) @+0x00, rotation (quaternion) @+0x1c, scaling (C3Vector) @+0x38 (wow-re models.md + // element table). Same "no keys" tolerance as the colour/weight tracks above. + let ttf_avail = b.len().saturating_sub(tex_anim_arr.1 as usize); + let mut texture_transforms = + Vec::with_capacity(capped(tex_anim_arr.0 as usize, 0x54, ttf_avail)); + for i in 0..tex_anim_arr.0 as usize { + let base = tex_anim_arr.1 as usize + i * 0x54; + texture_transforms.push(M2TextureTransform { + translation: track_vec3_timed(b, base), + rotation: track_quat(b, base + 0x1c), + scaling: track_vec3_timed(b, base + 0x38), + }); + } + let talookup_avail = b.len().saturating_sub(tex_anim_lookup_arr.1 as usize); + let mut texture_transform_lookup = + Vec::with_capacity(capped(tex_anim_lookup_arr.0 as usize, 2, talookup_avail)); + for i in 0..tex_anim_lookup_arr.0 as usize { + texture_transform_lookup.push( + get(tex_anim_lookup_arr.1 as usize + i * 2, 2)? + .u16_at(0) + .ok_or(Error::Truncated)?, + ); + } + + // Global-sequence durations (header `0x14` count / `0x18` offset, u32 ms each): the free-running + // loop clocks a `gseq`-tagged track wraps on (`t mod global_sequences[gseq]`). Bounds-tolerant + // like the tracks: a truncated table yields what fits. + let gseq_arr = ( + b.u32_at(0x14).ok_or(Error::Truncated)?, + b.u32_at(0x18).ok_or(Error::Truncated)?, + ); + let gseq_avail = b.len().saturating_sub(gseq_arr.1 as usize); + let mut global_sequences = Vec::with_capacity(capped(gseq_arr.0 as usize, 4, gseq_avail)); + for i in 0..gseq_arr.0 as usize { + let Some(ms) = b.u32_at(gseq_arr.1 as usize + i * 4) else { + break; + }; + global_sequences.push(ms); + } + + // Collision hull: raw bytes (triangles = count×2 u16, vertices = count×12 C3Vector). Many props + // carry none (count 0) → empty. `get(..)` is already bounds-checked before the `to_vec()`, so a + // corrupt count fails at the checked slice, never at an unbounded allocation. + let bt_bytes = get( + bounding_triangles.1 as usize, + bounding_triangles.0 as usize * 2, + )? + .to_vec(); + let bv_bytes = get( + bounding_vertices.1 as usize, + bounding_vertices.0 as usize * 12, + )? + .to_vec(); + + Ok(M2Format { + model: M2Model { + vertices: verts, + textures: texs, + materials: mats, + color_alpha_tracks, + color_rgb_tracks, + transparency_tracks, + transparency_lookup, + texture_transforms, + texture_transform_lookup, + global_sequences, + bones: bone_list, + raw_data: M2RawData { + texture_lookup_table: tlt, + bounding_triangles: bt_bytes, + bounding_vertices: bv_bytes, + }, + header: M2Header { + bounding_box_min, + bounding_box_max, + bounding_sphere_radius, + collision_box_min, + collision_box_max, + collision_sphere_radius, + }, + pivot_attach_z, + attachments: attachment_list, + event_markers, + playable_animation_lookup, + views, + version, + }, + }) +} + +/// The model-space **Z** of an M2 attachment selected by **attachment id** (not array index) — the +/// follow-camera pivot reads id 17 this way. `attachments`/`lookup` are the header `(count, offset)` +/// pairs; the id → array-index map (`lookup`, `u16` each) picks the record, whose `position` C3 sits at +/// `+8` (vanilla stride 48: `id u32 · bone u32 · position C3 · animate M2Track`). `None` when the id has +/// no lookup slot or the record/bytes are out of range. +fn attachment_z(b: &[u8], id: usize, attachments: (u32, u32), lookup: (u32, u32)) -> Option { + if id >= lookup.0 as usize { + return None; + } + let li = lookup.1 as usize + id * 2; + let idx = b.u16_at(li)? as usize; + if idx >= attachments.0 as usize { + return None; + } + let rec = attachments.1 as usize + idx * 48; + // position C3 at rec+8 → its Z is rec+16. + b.f32_at(rec + 16) +} + +#[cfg(test)] +mod tests; diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs new file mode 100644 index 00000000..c8941c2c --- /dev/null +++ b/crates/benilla-m2/src/model.rs @@ -0,0 +1,289 @@ +//! The plain M2 data types: the parsed record shapes (vertices, textures, materials, bones, +//! attachments, the authored bounds) and the top-level [`M2Model`]/[`M2Format`] the parser +//! (`crate::parse_m2`, in `lib.rs`) builds. + +use std::ffi::CString; + +use crate::track::{M2QuatTrack, M2ScalarTrack, M2Vec3Track}; + +/// A 3-float vector (position / normal / pivot). +#[derive(Clone, Copy)] +pub struct C3 { + pub x: f32, + pub y: f32, + pub z: f32, +} +/// A 2-float vector (texture coordinate). +#[derive(Clone, Copy)] +pub struct C2 { + pub x: f32, + pub y: f32, +} + +/// M2 texture purpose. Vanilla uses `Hardcoded` (the `filename` is the real path) or the `Monster*` +/// skin slots (filename usually empty; the slot is filled from the creature's display variation). +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum M2TextureType { + Hardcoded, + Monster1, + Monster2, + Monster3, + Other(u32), +} + +impl M2TextureType { + pub(crate) fn from_u32(v: u32) -> Self { + match v { + 0 => Self::Hardcoded, + 11 => Self::Monster1, + 12 => Self::Monster2, + 13 => Self::Monster3, + other => Self::Other(other), + } + } +} + +/// The texture's filename, kept as a `CString` so callers can `.string.to_string_lossy()` (matching +/// the reference's shape). +pub struct M2ArrayString { + pub string: CString, +} + +/// One M2 texture definition. +pub struct M2Texture { + pub texture_type: M2TextureType, + pub filename: M2ArrayString, +} + +/// M2 material render flags (`& 0x01` = unlit/emissive, `& 0x04` = two-sided). +#[derive(Clone, Copy)] +pub struct M2RenderFlags(pub(crate) u16); +impl M2RenderFlags { + pub fn bits(&self) -> u16 { + self.0 + } +} +/// M2 material blend mode (0 opaque, 1 alpha-key, 2 alpha, 3/4 additive). +#[derive(Clone, Copy)] +pub struct M2BlendMode(pub(crate) u16); +impl M2BlendMode { + pub fn bits(&self) -> u16 { + self.0 + } +} + +/// One M2 material (MD20 "render flags" entry). +pub struct M2Material { + pub flags: M2RenderFlags, + pub blend_mode: M2BlendMode, +} + +/// M2 bone flags (`& 0x08` spherical billboard, `& 0x10/0x20/0x40` cylindrical billboard). +#[derive(Clone, Copy)] +pub struct M2BoneFlags(pub(crate) u32); +impl M2BoneFlags { + pub fn bits(&self) -> u32 { + self.0 + } +} + +/// One M2 bone. The render path reads billboard flags + pivot; the skeletal-animation path also needs +/// the **parent index** (`-1` = root) to compose bone matrices up the tree and to build the joint +/// hierarchy + inverse bind poses (decision 0019). Vanilla bone record is stride 108: keyBoneId i32 +/// @+0x00, flags @+0x04, parent i16 @+0x08, pivot C3 @+0x60 (no boneNameCRC, unlike TBC+). +pub struct M2Bone { + /// The bone's `KeyBoneID` (`-1` = none): the semantic anchor table — 0/1 arms L/R, 2/3 + /// shoulders L/R, 4 spine-low, 5 waist, 6 head… The per-arm animation masks resolve their + /// subtree roots through it. + pub key_bone: i16, + pub flags: M2BoneFlags, + /// Parent bone index, or `-1` for a root bone (vanilla bone record `+0x08`). + pub parent: i16, + pub pivot: C3, +} +impl M2Bone { + /// Whether this bone billboards (any of the spherical/cylindrical bits). + pub fn is_billboard(&self) -> bool { + self.flags.0 & (0x08 | 0x10 | 0x20 | 0x40) != 0 + } +} + +/// One M2 vertex (48 bytes on disk). Layout: position C3 @0, bone_weights [u8;4] @0x0c, +/// bone_indices [u8;4] @0x10, normal C3 @0x14, tex_coords C2 @0x20 (a second UV @0x28 we skip). The +/// weights + indices are the 4-bone skin binding the skeletal-animation path uploads (decision 0019); +/// the static render path uses only `bone_indices[0]` (billboard glow-card pivot). +pub struct M2Vertex { + pub position: C3, + /// Per-vertex bone influence weights (`/255`), paired with [`Self::bone_indices`]. + pub bone_weights: [u8; 4], + pub bone_indices: [u8; 4], + pub normal: C3, + pub tex_coords: C2, +} + +/// Lookup tables + collision geometry read from the header. The renderer uses the texture-combo → +/// texture mapping; benilla-formats reads the collision hull from the two `bounding_*` byte blocks +/// (the MD20 `boundingVertices` are `C3Vector`s = 12 bytes; `boundingTriangles` are `u16` = 2 bytes — +/// kept as raw bytes so callers slice them as they need). +pub struct M2RawData { + pub texture_lookup_table: Vec, + /// Collision-hull triangle indices, raw bytes (`count × 2`, `u16` each). + pub bounding_triangles: Vec, + /// Collision-hull vertices, raw bytes (`count × 12`, `C3Vector` each). + pub bounding_vertices: Vec, +} + +/// The authored bounds from the MD20 header. Two boxes: the **bounding** box (+ sphere radius) is the +/// render/cull volume (the all-animation vertex extent — large); the **collision** box (+ sphere radius) +/// is the tight collision hull (~the body). The **render sphere** sizes the *unit* selection ring +/// (`0.5 × radius × scale`, the client's cached `[unit+0x2b0]`, wow-re selection-circle RE) and drives +/// render culling; the **collision box** is the *GameObject* ring's source (wow-re `0x711a20`/`0x713700`) +/// — parsed here for that path. +pub struct M2Header { + pub bounding_box_min: [f32; 3], + pub bounding_box_max: [f32; 3], + pub bounding_sphere_radius: f32, + pub collision_box_min: [f32; 3], + pub collision_box_max: [f32; 3], + pub collision_sphere_radius: f32, +} + +/// One row of the M2's baked **PlayableAnimationLookup** table (decision 0082, wow-re +/// `anim-id-resolution.md`, byte-verified `0x711bf0`): the model's own precomputed answer to "if the +/// game requests `AnimationData.dbc` id X, which id do I actually play, and in which +/// direction/variant". Header `count@+0x2c`/`offset@+0x30` (pre-Wrath M2 only — the array is dropped +/// past version 263), stride 4 (one dword per row): low16 = the resolved id, high16 = a +/// direction/variant playback code. `nPlayableAnimationLookup` is a fixed **203** across the entire +/// retail 1.12.1 corpus (VERIFIED, wow-re empirical cross-check) — the per-model table is a baked +/// cache of the `AnimationData.dbc` Fallback-column walk (PATH 2, below), frozen against that model's +/// actual sequence set at export (a chicken lacking Attack2H bakes `[18] = [17] = 16`). +#[derive(Clone, Copy)] +pub struct M2PlayableAnim { + /// The `AnimationData.dbc` id this model actually plays for the row's requested id. + pub resolved_id: u16, + /// Direction/variant playback code — **not yet consumed** by benilla (decision 0082 flags its + /// exact playback semantics, wow-re `~0x7126d2`, as untraced; carried here for a future slice). + pub dir_flags: u16, +} + +/// One M2 attachment-point record (vanilla stride 48: `id u32 @+0`, `bone u32 @+4`, `position C3 @+8`, +/// then a 28-byte visibility `M2Track` we skip). `id`/`bone` are narrowed to `u16` — safe on real data +/// (attachment ids run ≤ 36; `bone` is checked against the bone array during parse) — and any record +/// that doesn't fit, or whose bone is out of the model's bone-array range, is dropped rather than kept +/// malformed. +/// +/// Empirical ground truth (direct dump of `HumanMale.m2`, `decisions/0072`): on character models the +/// attachment `position` equals its bone's pivot — the attach bones are leaves sitting exactly at the +/// attach point, so `position` and the bone pivot agree. Known ids: `0` shield (left forearm), `1` +/// right hand, `2` left hand, `26`/`27` right/left back sheath, `28` shield back, `30`/`31` lower-back +/// pair, `32`/`33` hip pair. +#[derive(Clone, Copy)] +pub struct M2Attachment { + pub id: u16, + pub bone: u16, + /// Model-space position, **raw WoW space** — matches how [`M2Bone::pivot`] is stored; the WoW→Bevy + /// bake happens at the render boundary (`benilla-assets`). + pub position: [f32; 3], +} + +/// One M2 **animation-event marker** (vanilla stride 44: `identifier 4CC @+0`, `data u32 @+4`, +/// `bone u32 @+8`, `position C3 @+12`, then a 20-byte enabled `M2TrackBase` we skip — its +/// per-sequence fire keys surface through `benilla-formats`' per-sequence event stream instead). +/// This is the record's **positional half**: the client's `0x7130e0`/`0x7131b0` scan exactly this +/// table by 4CC and transform `position` by `bone`'s live matrix — the cast-release launch points +/// `$CSL`/`$CSR`/`$CST` (casting hand left/right/two-hand), the bow anchors `$WTT`/`$WTB`, the +/// ranged release `$BWR`. Idents are NOT unique per model (character models carry six `$CSD` +/// records, one per voice payload); the client's queries take the first match. A record whose +/// `bone` doesn't fit `u16` or indexes past the bone array is dropped rather than kept malformed. +#[derive(Clone, Copy)] +pub struct M2EventMarker { + /// The identifier 4CC, stored forward (`*b"$CSL"`). + pub ident: [u8; 4], + pub bone: u16, + /// Model-space position, **raw WoW space** — same convention as [`M2Attachment::position`]. + pub position: [f32; 3], +} + +/// One M2 **texture transform** (header `0x74`, file stride `0x54`): 3 back-to-back M2Tracks — +/// `translation` (C3Vector) @`+0x00`, `rotation` (quaternion) @`+0x1c`, `scaling` (C3Vector) +/// @`+0x38` (wow-re `models.md` element table, runtime `[model+0x98]`). Sampled per frame these +/// drive UV animation — flowing waterfalls, scrolling energy fields. How the sampled TRS is +/// *applied* at draw (texture matrix vs CPU UVs, pivot/composition convention) is under RE in +/// wow-re; parsed and carried here, not yet consumed. +pub struct M2TextureTransform { + pub translation: M2Vec3Track, + pub rotation: M2QuatTrack, + pub scaling: M2Vec3Track, +} + +/// The parsed model (render subset). +pub struct M2Model { + pub vertices: Vec, + pub textures: Vec, + pub materials: Vec, + /// Per-`M2Color` **alpha** track (full [`M2ScalarTrack`]: interp + gseq tag + timed keys), one + /// per color record (header `0x54`). A `texUnit.colorIndex` (direct) selects one; no keys ⇒ the + /// factor doesn't apply. The alpha governs batch visibility (a constant 0 hides the batch) and, + /// time-varying, the animated material combine (wow-re `m2-alpha-combine-cull`). + pub color_alpha_tracks: Vec, + /// Per-`M2Color` **RGB colour** track (full [`M2Vec3Track`]: interp + gseq tag + timed + /// `C3Vector` keys, the colour record's `+0x00` track), one per record. Selected by the same + /// `texUnit.colorIndex`. This is the per-batch tint the real client multiplies into the vertex + /// colour — load-bearing for additive glow cards drawn with a *neutral* glow texture (e.g. + /// `GenericGlow_Alpha_128`), whose warmth lives entirely here; dropping it renders the glow + /// grey/white. Time-varying tracks (e.g. a spell effect's white-hot flash cooling to red) carry + /// their timestamps so the render side can animate the tint. No keys ⇒ no tint. + pub color_rgb_tracks: Vec, + /// Per-`M2TextureWeight` **weight** track (full [`M2ScalarTrack`]), one per record (header + /// `0x64`). Selected via `texUnit.weight_combo_index → transparency_lookup → here` (two-hop). + pub transparency_tracks: Vec, + /// The transparency lookup table (header `0xa4`, u16): `weight_combo_index` indexes this to reach + /// [`Self::transparency_tracks`] (verified two-hop, wow-re `m2-alpha-combine-cull`). + pub transparency_lookup: Vec, + /// The texture-transform records (see [`M2TextureTransform`]), selected via + /// `texture_transform_combo_index → texture_transform_lookup → here`. + pub texture_transforms: Vec, + /// The texture-animation lookup table (header `0xac`, u16): `texture_transform_combo_index` + /// (texUnit `+0x16`, wow-re rf72 `0x70b897`) indexes this to reach + /// [`Self::texture_transforms`]; `0xffff` = no animation. + pub texture_transform_lookup: Vec, + /// Global-sequence durations (header `0x14/0x18`, ms): the free-running loop clocks + /// `gseq`-tagged tracks wrap on. + pub global_sequences: Vec, + pub bones: Vec, + pub raw_data: M2RawData, + pub header: M2Header, + /// Model-space **Z** of attachment id 17 — the reference's follow-camera pivot height (wow-re + /// `follow-camera`, `0x50cbc0`: the camera targets `feet + (attach17.z + 0.0972)·scale`). `None` when + /// the model has no slot-17 attachment (a non-character model → the camera falls back to the box). + pub pivot_attach_z: Option, + /// The full attachment-point table (see [`M2Attachment`]) — every record whose id/bone fit `u16` + /// and whose bone indexes into [`Self::bones`]. `pivot_attach_z` above is the id-17 special case + /// kept as its own field (the camera pivot's one historical consumer); this is the general table + /// held-item attach points, sheath points, etc. read from. + pub attachments: Vec, + /// The animation-event table's **positional markers** (see [`M2EventMarker`]) — every record + /// whose bone fits `u16` and indexes into [`Self::bones`], in file order (queries take the + /// first ident match, the client's `0x7130e0` scan order). The cast-release launch points + /// (`$CSL`/`$CSR`/`$CST`), the ranged release (`$BWR`), the bow anchors, the sound/impact tags' + /// anchor points. + pub event_markers: Vec, + /// The model's baked **PlayableAnimationLookup** (decision 0082 — see [`M2PlayableAnim`]): row `i` + /// is the model's own precomputed substitute for requested `AnimationData.dbc` id `i`. Empty for a + /// version past 263 (the array is dropped) or a model whose header couldn't be read that far — the + /// resolver degrades to identity in that case. + pub playable_animation_lookup: Vec, + /// Embedded skin-profile array `(count, file_offset)` — `parse_embedded_skin` reads from here. + pub(crate) views: (u32, u32), + pub(crate) version: u32, +} + +/// Holds the parsed model (mirrors the reference's `parse_m2(..).model()` shape). +pub struct M2Format { + pub(crate) model: M2Model, +} +impl M2Format { + pub fn model(&self) -> &M2Model { + &self.model + } +} diff --git a/crates/benilla-m2/src/skin.rs b/crates/benilla-m2/src/skin.rs new file mode 100644 index 00000000..0e9ad376 --- /dev/null +++ b/crates/benilla-m2/src/skin.rs @@ -0,0 +1,137 @@ +//! Embedded skin-profile parsing: [`Skin`]/[`SkinSection`]/[`SkinBatch`] and +//! [`M2Model::parse_embedded_skin`], which decodes one of the model's embedded skin profiles +//! (indices / triangles / submeshes / batches) by file offset. + +use benilla_bytes::{capped, ByteExt}; + +use crate::error::{Error, Result}; +use crate::model::M2Model; + +/// One skin submesh section (we keep the triangle range the renderer iterates, plus the section's +/// `skinSectionId` — the geoset/mesh-part ID the character compositor selects visibility by). +pub struct SkinSection { + /// `skinSectionId` (mesh-part / geoset ID): `group*100 + variant`. For a character model this is + /// what geoset selection toggles (hide all but the chosen hair/facial-hair/body variants); `0` for a + /// model with a single unnamed geoset (most creatures/doodads). + pub id: u16, + pub triangle_start: u16, + pub triangle_count: u16, +} + +/// One skin draw batch (section + material + texture-combo references). +pub struct SkinBatch { + pub skin_section_index: u16, + pub texture_combo_index: u16, + pub material_index: u16, + /// Direct index into [`crate::M2Model::color_alpha_tracks`] (`0xffff` = none). texUnit `+0x08`. + pub color_index: u16, + /// Index into [`crate::M2Model::transparency_lookup`] → a transparency track. texUnit `+0x14`. + pub weight_combo_index: u16, + /// Texture count (texUnit `+0x0e`); `0` ⇒ the transparency factor is not applied (verified gate). + pub texture_count: u16, + /// Index into [`crate::M2Model::texture_transform_lookup`] → a texture transform (UV + /// animation). texUnit `+0x16` (wow-re rf72, `0x70b897`). + pub texture_transform_combo_index: u16, +} + +/// A decoded embedded skin profile. +pub struct Skin { + indices: Vec, + triangles: Vec, + submeshes: Vec, + batches: Vec, +} +impl Skin { + pub fn indices(&self) -> &Vec { + &self.indices + } + pub fn triangles(&self) -> &Vec { + &self.triangles + } + pub fn submeshes(&self) -> &Vec { + &self.submeshes + } + pub fn batches(&self) -> &Vec { + &self.batches + } +} + +impl M2Model { + /// Decode embedded skin profile `index` from the original file `bytes`. The skin profiles are an + /// array of 44-byte `M2View` headers at `views.offset`; each references its indices / triangles / + /// submeshes / batches by file offset. + pub fn parse_embedded_skin(&self, bytes: &[u8], index: usize) -> Result { + let (count, ofs) = self.views; + if index >= count as usize { + return Err(Error::Truncated); + } + let vp = ofs as usize + index * 44; // M2View header: 5 M2Arrays (40) + bone count (4) + let rd_arr = |o: usize| -> Result<(usize, usize)> { + Ok(( + bytes.u32_at(o).ok_or(Error::Truncated)? as usize, + bytes.u32_at(o + 4).ok_or(Error::Truncated)? as usize, + )) + }; + let (n_idx, o_idx) = rd_arr(vp)?; + let (n_tri, o_tri) = rd_arr(vp + 8)?; + // properties @ vp+16 (unused) + let (n_sub, o_sub) = rd_arr(vp + 24)?; + let (n_bat, o_bat) = rd_arr(vp + 32)?; + + // `bytes_at(o, n * 2)` bounds-checks the whole run before any element is read, so — unlike + // the header-driven reservations above — `n` is already provably within the file's own size + // once this succeeds; no separate `capped()` needed for the `Vec::with_capacity(n)` below. + let read_u16s = |n: usize, o: usize| -> Result> { + let slice = bytes.bytes_at(o, n * 2).ok_or(Error::Truncated)?; + let mut v = Vec::with_capacity(n); + for c in slice.chunks_exact(2) { + v.push(c.u16_at(0).ok_or(Error::Truncated)?); + } + Ok(v) + }; + let indices = read_u16s(n_idx, o_idx)?; + let triangles = read_u16s(n_tri, o_tri)?; + + // M2SkinSection: 32 bytes pre-Wrath (v<260), 48 with the later centre-bounds; indexStart @8, + // indexCount @10 are the triangle range either way. + let sec = if self.version < 260 { 32 } else { 48 }; + // Reservations capped (decision 0064): `n_sub`/`n_bat` come straight from the M2View header, + // unvalidated, and the loops below check bounds only per-element — same shape as the header + // arrays above, so the same guard applies. + let sub_avail = bytes.len().saturating_sub(o_sub); + let mut submeshes = Vec::with_capacity(capped(n_sub, sec, sub_avail)); + for i in 0..n_sub { + let s = bytes + .bytes_at(o_sub + i * sec, sec) + .ok_or(Error::Truncated)?; + submeshes.push(SkinSection { + id: s.u16_at(0).ok_or(Error::Truncated)?, // skinSectionId @ +0x00 + triangle_start: s.u16_at(8).ok_or(Error::Truncated)?, + triangle_count: s.u16_at(10).ok_or(Error::Truncated)?, + }); + } + // M2TextureUnit (batch): 24 bytes — skinSectionIndex@4, colorIndex@8, materialIndex@0xa, + // textureCount@0xe, textureComboIndex@0x10, weightComboIndex@0x14 (the visibility-factor + // refs), textureTransformComboIndex@0x16 (UV animation). + let bat_avail = bytes.len().saturating_sub(o_bat); + let mut batches = Vec::with_capacity(capped(n_bat, 24, bat_avail)); + for i in 0..n_bat { + let u = bytes.bytes_at(o_bat + i * 24, 24).ok_or(Error::Truncated)?; + batches.push(SkinBatch { + skin_section_index: u.u16_at(4).ok_or(Error::Truncated)?, + material_index: u.u16_at(0x0a).ok_or(Error::Truncated)?, + texture_combo_index: u.u16_at(0x10).ok_or(Error::Truncated)?, + color_index: u.u16_at(0x08).ok_or(Error::Truncated)?, + weight_combo_index: u.u16_at(0x14).ok_or(Error::Truncated)?, + texture_count: u.u16_at(0x0e).ok_or(Error::Truncated)?, + texture_transform_combo_index: u.u16_at(0x16).ok_or(Error::Truncated)?, + }); + } + Ok(Skin { + indices, + triangles, + submeshes, + batches, + }) + } +} diff --git a/crates/benilla-m2/src/tests.rs b/crates/benilla-m2/src/tests.rs new file mode 100644 index 00000000..7dc2cc1b --- /dev/null +++ b/crates/benilla-m2/src/tests.rs @@ -0,0 +1,367 @@ +use super::*; + +// The field walk in `parse_m2` reads M2Arrays up through the event table (the last read, at +// header offset 0x114) and stops there — the fixture header covers exactly that walk. +const HEADER_LEN: usize = 284; + +// Byte offsets of the M2Arrays this parser reads, matching the field walk in `parse_m2` for a +// v256 (pre-Wrath) header. +const OFS_PLAYABLE_ANIM_LOOKUP: usize = 44; +const OFS_TEX_ANIM: usize = 0x74; +const OFS_TEX_UNIT_LOOKUP: usize = 0x9c; +const OFS_TRANS_LOOKUP: usize = 0xa4; +const OFS_TEX_ANIM_LOOKUP: usize = 0xac; +const OFS_BONES: usize = 52; +const OFS_VERTICES: usize = 68; +const OFS_VIEWS: usize = 76; +const OFS_ATTACHMENTS: usize = 260; +const OFS_EVENTS: usize = 276; + +/// A minimal v256 MD20 header: the magic + version, every M2Array zeroed (count 0, offset 0). +/// Callers patch specific arrays with [`set_arr`] and append payload bytes after `HEADER_LEN`. +fn header() -> Vec { + let mut b = vec![0u8; HEADER_LEN]; + b[0..4].copy_from_slice(b"MD20"); + b[4..8].copy_from_slice(&256u32.to_le_bytes()); + b +} + +fn set_arr(b: &mut [u8], ofs: usize, count: u32, offset: u32) { + b[ofs..ofs + 4].copy_from_slice(&count.to_le_bytes()); + b[ofs + 4..ofs + 8].copy_from_slice(&offset.to_le_bytes()); +} + +fn parse(b: &[u8]) -> Result { + parse_m2(&mut Cursor::new(b)) +} + +#[test] +fn minimal_header_parses_to_an_empty_model() { + let b = header(); + let fmt = parse(&b).expect("an all-zero-array header parses"); + let m = fmt.model(); + assert!(m.vertices.is_empty()); + assert!(m.textures.is_empty()); + assert!(m.materials.is_empty()); + assert!(m.bones.is_empty()); + assert!(m.color_alpha_tracks.is_empty()); + assert!(m.color_rgb_tracks.is_empty()); + assert!(m.transparency_tracks.is_empty()); + assert!(m.transparency_lookup.is_empty()); + assert!(m.raw_data.texture_lookup_table.is_empty()); + assert!(m.raw_data.bounding_triangles.is_empty()); + assert!(m.raw_data.bounding_vertices.is_empty()); + assert!(m.attachments.is_empty()); + assert!(m.playable_animation_lookup.is_empty()); + assert!(m.texture_transforms.is_empty()); + assert!(m.texture_transform_lookup.is_empty()); +} + +/// One v256 M2Track (stride 0x1c): interp@0, gseq@2, timestamps M2Array@0x0c, values M2Array@0x14. +fn m2track(interp: u16, gseq: u16, ts: (u32, u32), vals: (u32, u32)) -> Vec { + let mut t = vec![0u8; 0x1c]; + t[0..2].copy_from_slice(&interp.to_le_bytes()); + t[2..4].copy_from_slice(&gseq.to_le_bytes()); + t[0x0c..0x10].copy_from_slice(&ts.0.to_le_bytes()); + t[0x10..0x14].copy_from_slice(&ts.1.to_le_bytes()); + t[0x14..0x18].copy_from_slice(&vals.0.to_le_bytes()); + t[0x18..0x1c].copy_from_slice(&vals.1.to_le_bytes()); + t +} + +#[test] +fn texture_transform_translation_track_decodes() { + let mut b = header(); + let ts_ofs = b.len() as u32; + b.extend(0u32.to_le_bytes()); + b.extend(1000u32.to_le_bytes()); + let val_ofs = b.len() as u32; + for v in [[0.0f32, 0.0, 0.0], [0.5, 1.0, 0.0]] { + for c in v { + b.extend(c.to_le_bytes()); + } + } + // One M2TextureTransform record (stride 0x54): a 2-key gseq-0 translation track, then keyless + // rotation + scaling tracks. + let rec_ofs = b.len() as u32; + b.extend(m2track(1, 0, (2, ts_ofs), (2, val_ofs))); + b.extend(m2track(0, 0xffff, (0, 0), (0, 0))); + b.extend(m2track(0, 0xffff, (0, 0), (0, 0))); + set_arr(&mut b, OFS_TEX_ANIM, 1, rec_ofs); + let lk_ofs = b.len() as u32; + b.extend(0u16.to_le_bytes()); + set_arr(&mut b, OFS_TEX_ANIM_LOOKUP, 1, lk_ofs); + + let fmt = parse(&b).expect("header with one texture transform parses"); + let m = fmt.model(); + assert_eq!(m.texture_transform_lookup, vec![0]); + assert!(m.transparency_lookup.is_empty()); + assert_eq!(m.texture_transforms.len(), 1); + let t = &m.texture_transforms[0]; + assert_eq!(t.translation.interp, 1); + assert_eq!(t.translation.gseq, 0); + assert_eq!( + t.translation.keys, + vec![(0, [0.0, 0.0, 0.0]), (1000, [0.5, 1.0, 0.0])] + ); + assert!(t.translation.constant().is_none()); + assert!(t.rotation.keys.is_empty()); + assert!(t.scaling.keys.is_empty()); +} + +/// The three adjacent u16 lookup slots must not be confused: texUnitLookup@0x9c · +/// transLookup@0xa4 · texAnimLookup@0xac (wow-re stride-pin reconciliation; models.md's field-map +/// *line* labels them one slot early). Shaped like the real StormwindMagePortal01 header — [0] at +/// 0x9c, the identity [0,1,2,3] at 0xa4, [0xffff] at 0xac — where reading 0x9c as the transparency +/// lookup silently dropped the combo-1..3 weight tracks. +#[test] +fn transparency_lookup_reads_0xa4_not_the_texture_unit_lookup_at_0x9c() { + let mut b = header(); + let unit_ofs = b.len() as u32; + b.extend(0u16.to_le_bytes()); + set_arr(&mut b, OFS_TEX_UNIT_LOOKUP, 1, unit_ofs); + let trans_ofs = b.len() as u32; + for v in [0u16, 1, 2, 3] { + b.extend(v.to_le_bytes()); + } + set_arr(&mut b, OFS_TRANS_LOOKUP, 4, trans_ofs); + let ta_ofs = b.len() as u32; + b.extend(0xffffu16.to_le_bytes()); + set_arr(&mut b, OFS_TEX_ANIM_LOOKUP, 1, ta_ofs); + + let fmt = parse(&b).expect("header with all three lookup tables parses"); + let m = fmt.model(); + assert_eq!(m.transparency_lookup, vec![0, 1, 2, 3]); + assert_eq!(m.texture_transform_lookup, vec![0xffff]); +} + +#[test] +fn playable_animation_lookup_decodes_low16_high16() { + let mut b = header(); + let pal_ofs = b.len() as u32; + // Row 0: identity, no dir flags. Row 1: HumanMale's real byte-verified example + // (`playableAnimationLookup[6] = 0x00030001`, wow-re `anim-id-resolution.md` §4) — resolved + // id 1, dir-flags code 3. + b.extend(0u32.to_le_bytes()); + b.extend(0x0003_0001u32.to_le_bytes()); + set_arr(&mut b, OFS_PLAYABLE_ANIM_LOOKUP, 2, pal_ofs); + + let fmt = parse(&b).expect("header with a playable-animation-lookup table parses"); + let pal = &fmt.model().playable_animation_lookup; + assert_eq!(pal.len(), 2); + assert_eq!(pal[0].resolved_id, 0); + assert_eq!(pal[0].dir_flags, 0); + assert_eq!(pal[1].resolved_id, 1); + assert_eq!(pal[1].dir_flags, 3); +} + +fn bone_record(pivot: [f32; 3]) -> Vec { + let mut b = vec![0u8; 108]; + b[96..100].copy_from_slice(&pivot[0].to_le_bytes()); + b[100..104].copy_from_slice(&pivot[1].to_le_bytes()); + b[104..108].copy_from_slice(&pivot[2].to_le_bytes()); + b +} + +fn attachment_record(id: u32, bone: u32, position: [f32; 3]) -> Vec { + let mut b = vec![0u8; 48]; // id+bone+position (20 bytes) + the skipped 28-byte visibility track + b[0..4].copy_from_slice(&id.to_le_bytes()); + b[4..8].copy_from_slice(&bone.to_le_bytes()); + b[8..12].copy_from_slice(&position[0].to_le_bytes()); + b[12..16].copy_from_slice(&position[1].to_le_bytes()); + b[16..20].copy_from_slice(&position[2].to_le_bytes()); + b +} + +#[test] +fn attachments_parse_and_skip_out_of_range_records() { + let mut b = header(); + // Two bones, so a valid attachment bone index is 0 or 1. + let bones_ofs = b.len() as u32; + b.extend(bone_record([0.0, 0.0, 0.0])); + b.extend(bone_record([1.0, 1.0, 1.0])); + set_arr(&mut b, OFS_BONES, 2, bones_ofs); + + let att_ofs = b.len() as u32; + b.extend(attachment_record(1, 0, [1.0, 2.0, 3.0])); // valid + b.extend(attachment_record(70_000, 0, [0.0, 0.0, 0.0])); // id overflows u16 -> skipped + b.extend(attachment_record(2, 5, [9.0, 9.0, 9.0])); // bone out of range -> skipped + set_arr(&mut b, OFS_ATTACHMENTS, 3, att_ofs); + + let fmt = parse(&b).expect("header with bones + attachments parses"); + let atts = &fmt.model().attachments; + assert_eq!(atts.len(), 1, "only the in-range record survives"); + assert_eq!(atts[0].id, 1); + assert_eq!(atts[0].bone, 0); + assert_eq!(atts[0].position, [1.0, 2.0, 3.0]); +} + +fn event_record(ident: &[u8; 4], bone: u32, position: [f32; 3]) -> Vec { + let mut b = vec![0u8; 44]; // ident+data+bone+position (24 bytes) + the skipped 20-byte track + b[0..4].copy_from_slice(ident); + b[8..12].copy_from_slice(&bone.to_le_bytes()); + b[12..16].copy_from_slice(&position[0].to_le_bytes()); + b[16..20].copy_from_slice(&position[1].to_le_bytes()); + b[20..24].copy_from_slice(&position[2].to_le_bytes()); + b +} + +#[test] +fn event_markers_parse_and_skip_out_of_range_records() { + let mut b = header(); + let bones_ofs = b.len() as u32; + b.extend(bone_record([0.0, 0.0, 0.0])); + set_arr(&mut b, OFS_BONES, 1, bones_ofs); + + let ev_ofs = b.len() as u32; + b.extend(event_record(b"$CSL", 0, [1.0, 2.0, 3.0])); // valid + b.extend(event_record(b"$CSR", 5, [9.0, 9.0, 9.0])); // bone out of range -> skipped + set_arr(&mut b, OFS_EVENTS, 2, ev_ofs); + + let fmt = parse(&b).expect("header with bones + events parses"); + let markers = &fmt.model().event_markers; + assert_eq!(markers.len(), 1, "only the in-range record survives"); + assert_eq!(&markers[0].ident, b"$CSL"); + assert_eq!(markers[0].bone, 0); + assert_eq!(markers[0].position, [1.0, 2.0, 3.0]); +} + +#[test] +fn one_vertex_decodes_all_fields() { + let mut b = header(); + set_arr(&mut b, OFS_VERTICES, 1, HEADER_LEN as u32); + let mut vert = vec![0u8; 48]; + vert[0..4].copy_from_slice(&1.0f32.to_le_bytes()); + vert[4..8].copy_from_slice(&2.0f32.to_le_bytes()); + vert[8..12].copy_from_slice(&3.0f32.to_le_bytes()); + vert[12..16].copy_from_slice(&[10, 20, 30, 40]); + vert[16..20].copy_from_slice(&[1, 2, 3, 4]); + vert[20..24].copy_from_slice(&4.0f32.to_le_bytes()); + vert[24..28].copy_from_slice(&5.0f32.to_le_bytes()); + vert[28..32].copy_from_slice(&6.0f32.to_le_bytes()); + vert[32..36].copy_from_slice(&0.5f32.to_le_bytes()); + vert[36..40].copy_from_slice(&0.25f32.to_le_bytes()); + b.extend(vert); + + let fmt = parse(&b).expect("one full vertex record parses"); + let v = &fmt.model().vertices[0]; + assert_eq!((v.position.x, v.position.y, v.position.z), (1.0, 2.0, 3.0)); + assert_eq!(v.bone_weights, [10, 20, 30, 40]); + assert_eq!(v.bone_indices, [1, 2, 3, 4]); + assert_eq!((v.normal.x, v.normal.y, v.normal.z), (4.0, 5.0, 6.0)); + assert_eq!((v.tex_coords.x, v.tex_coords.y), (0.5, 0.25)); +} + +#[test] +fn hostile_vertex_count_errs_cleanly_not_oom() { + // The bug this migration fixes: `vertices.0` is read straight off the file with no relation + // to the buffer's actual size. Pre-0064, `Vec::with_capacity(vertices.0 as usize)` tried to + // reserve `u32::MAX * 48` bytes up front and aborted the process. Post-0064 the reservation + // is capped by the remaining input (here: zero bytes after the header), so this returns an + // ordinary `Err` from the first bounds-checked read instead. + let mut b = header(); + set_arr(&mut b, OFS_VERTICES, u32::MAX, HEADER_LEN as u32); + assert!(matches!(parse(&b), Err(Error::Truncated))); +} + +#[test] +fn truncated_vertex_record_errs_cleanly() { + let mut b = header(); + set_arr(&mut b, OFS_VERTICES, 1, HEADER_LEN as u32); + b.extend(vec![0u8; 40]); // one vertex declared, but only 40 of its 48 bytes are present + assert!(matches!(parse(&b), Err(Error::Truncated))); +} + +#[test] +fn hostile_shapes_do_not_panic() { + assert!(matches!(parse(&[]), Err(Error::NotMd20))); + assert!(matches!(parse(&[0u8; 7]), Err(Error::NotMd20))); + // A recognizable magic with an unsupported version. + let mut b = vec![0u8; HEADER_LEN]; + b[0..4].copy_from_slice(b"MD20"); + b[4..8].copy_from_slice(&999u32.to_le_bytes()); + assert!(matches!(parse(&b), Err(Error::UnsupportedVersion(999)))); + // A header truncated mid-array. + assert!(matches!( + parse(&header()[..HEADER_LEN - 4]), + Err(Error::Truncated) + )); +} + +/// Build a full synthetic MD20 file with one embedded skin profile (one index, one triangle, one +/// submesh, one batch) so `parse_embedded_skin` has something real to decode. +fn model_with_one_skin() -> Vec { + let mut b = header(); + let vp = HEADER_LEN as u32; // M2View header right after the fixed header + set_arr(&mut b, OFS_VIEWS, 1, vp); + b.extend(vec![0u8; 44]); // M2View header, patched below by absolute offset + let idx_ofs = b.len() as u32; + b.extend([1u16, 2, 3].iter().flat_map(|v| v.to_le_bytes())); + let tri_ofs = b.len() as u32; + b.extend([4u16, 5, 6].iter().flat_map(|v| v.to_le_bytes())); + let sub_ofs = b.len() as u32; + let mut section = vec![0u8; 32]; // v256 (<260) M2SkinSection stride + section[0..2].copy_from_slice(&7u16.to_le_bytes()); // skinSectionId + section[8..10].copy_from_slice(&0u16.to_le_bytes()); // triangle_start + section[10..12].copy_from_slice(&3u16.to_le_bytes()); // triangle_count + b.extend(section); + let bat_ofs = b.len() as u32; + let mut batch = vec![0u8; 24]; + batch[4..6].copy_from_slice(&0u16.to_le_bytes()); // skin_section_index + batch[8..10].copy_from_slice(&11u16.to_le_bytes()); // color_index + batch[0x0a..0x0c].copy_from_slice(&2u16.to_le_bytes()); // material_index + batch[0x0e..0x10].copy_from_slice(&1u16.to_le_bytes()); // texture_count + batch[0x10..0x12].copy_from_slice(&5u16.to_le_bytes()); // texture_combo_index + batch[0x14..0x16].copy_from_slice(&9u16.to_le_bytes()); // weight_combo_index + b.extend(batch); + + set_arr(&mut b, vp as usize, 3, idx_ofs); + set_arr(&mut b, vp as usize + 8, 3, tri_ofs); + set_arr(&mut b, vp as usize + 24, 1, sub_ofs); + set_arr(&mut b, vp as usize + 32, 1, bat_ofs); + b +} + +#[test] +fn embedded_skin_decodes_geometry_and_batches() { + let b = model_with_one_skin(); + let fmt = parse(&b).expect("synthetic one-skin model parses"); + let skin = fmt + .model() + .parse_embedded_skin(&b, 0) + .expect("skin profile 0 decodes"); + assert_eq!(*skin.indices(), vec![1, 2, 3]); + assert_eq!(*skin.triangles(), vec![4, 5, 6]); + assert_eq!(skin.submeshes().len(), 1); + assert_eq!(skin.submeshes()[0].id, 7); + assert_eq!(skin.submeshes()[0].triangle_count, 3); + assert_eq!(skin.batches().len(), 1); + assert_eq!(skin.batches()[0].material_index, 2); + assert_eq!(skin.batches()[0].color_index, 11); + assert_eq!(skin.batches()[0].texture_combo_index, 5); + assert_eq!(skin.batches()[0].weight_combo_index, 9); +} + +#[test] +fn embedded_skin_out_of_range_index_errs() { + let b = model_with_one_skin(); + let fmt = parse(&b).expect("synthetic one-skin model parses"); + assert!(matches!( + fmt.model().parse_embedded_skin(&b, 1), + Err(Error::Truncated) + )); +} + +#[test] +fn hostile_submesh_count_errs_cleanly_not_oom() { + // Same OOM-abort shape as the vertex case, but inside `parse_embedded_skin`'s M2View arrays. + let mut b = model_with_one_skin(); + let vp = HEADER_LEN; // M2View header offset (see `model_with_one_skin`) + let end = b.len() as u32; + set_arr(&mut b, vp + 24, u32::MAX, end); // submeshes M2Array, offset = end of file + let fmt = parse(&b).expect("header + view arrays are still well-formed"); + assert!(matches!( + fmt.model().parse_embedded_skin(&b, 0), + Err(Error::Truncated) + )); +} diff --git a/crates/benilla-m2/src/track.rs b/crates/benilla-m2/src/track.rs new file mode 100644 index 00000000..0f720d0d --- /dev/null +++ b/crates/benilla-m2/src/track.rs @@ -0,0 +1,107 @@ +//! M2Track readers: the typed [`M2Track`] (with the [`M2ScalarTrack`] / [`M2Vec3Track`] / +//! [`M2QuatTrack`] aliases) plus the track-parsing helpers the header walk (`crate::parse_m2`) +//! calls for the M2Color, M2TextureWeight, and M2TextureTransform tracks. + +use benilla_bytes::ByteExt; + +/// One typed `M2Track`, fully read: the interpolation type, the global-sequence tag, and the keys +/// as **absolute global-timeline milliseconds** paired with values. The v256 track (stride `0x1c`): +/// `interp`@0, `global_seq`@2, timestamps `M2Array`@`0x0c/0x10` (u32 ms), values `M2Array`@`0x14/0x18`. +/// A sequence-timeline track (`gseq == 0xffff`) keys inside each sequence's absolute time band; a +/// global-sequence track loops on `global_sequences[gseq]`'s own clock (wow-re +/// `eval.md`/`doodad-anim-host.md`). Key count is `min(timestamps, values)` — vanilla art +/// occasionally pads one array. +#[derive(Clone, Debug)] +pub struct M2Track { + /// Interpolation type: `0` = step (nearest-previous key), nonzero = linear (wow-re `eval.md`: + /// the scalar sampler's `cmp word[track],0` two-way dispatch). + pub interp: u16, + /// Global-sequence index, `0xffff` = an ordinary sequence-timeline track. + pub gseq: u16, + /// `(absolute ms, value)` keys, file order (time-ascending within a band). + pub keys: Vec<(u32, V)>, +} + +impl Default for M2Track { + fn default() -> Self { + Self { + interp: 0, + gseq: 0, + keys: Vec::new(), + } + } +} + +/// A scalar (`fix16`, `0..=1`) track — the M2Color **alpha** and M2TextureWeight **weight** tracks. +pub type M2ScalarTrack = M2Track; +/// A `C3Vector` track — the M2TextureTransform **translation** / **scaling** tracks. +pub type M2Vec3Track = M2Track<[f32; 3]>; +/// A quaternion (4×f32, raw v256 floats) track — the M2TextureTransform **rotation** track. +pub type M2QuatTrack = M2Track<[f32; 4]>; + +impl M2Track { + /// All key values equal (or a single key): the track is a constant — `Some(value)`; `None` for a + /// keyless or genuinely time-varying track. + pub fn constant(&self) -> Option { + let (_, first) = *self.keys.first()?; + self.keys.iter().all(|&(_, v)| v == first).then_some(first) + } +} + +/// Read one `M2Track` (see [`M2Track`]): the shared `interp`/`gseq`/timestamp walk with a +/// per-value reader. Out-of-range reads yield an empty key list (= "no keys", the factor/transform +/// does not apply) — real art relies on that tolerance. +fn track_read( + b: &[u8], + track_ofs: usize, + val_size: usize, + read_val: impl Fn(&[u8], usize) -> Option, +) -> M2Track { + let (Some(interp), Some(gseq)) = (b.u16_at(track_ofs), b.u16_at(track_ofs + 2)) else { + return M2Track::default(); + }; + let Some(((tn, to), (vn, vo))) = b + .u32_at(track_ofs + 0x0c) + .zip(b.u32_at(track_ofs + 0x10)) + .zip(b.u32_at(track_ofs + 0x14).zip(b.u32_at(track_ofs + 0x18))) + else { + return M2Track::default(); + }; + let n = tn.min(vn) as usize; + let (to, vo) = (to as usize, vo as usize); + let keys = (0..n) + .map_while(|i| b.u32_at(to + i * 4).zip(read_val(b, vo + i * val_size))) + .collect(); + M2Track { interp, gseq, keys } +} + +fn rd_vec3(b: &[u8], o: usize) -> Option<[f32; 3]> { + Some([b.f32_at(o)?, b.f32_at(o + 4)?, b.f32_at(o + 8)?]) +} + +/// Read a scalar `fix16` track (`uint16/32767` values). Used for the colour-**alpha** and +/// transparency-**weight** tracks that gate batch visibility and drive the animated material combine. +pub(crate) fn track_fix16(b: &[u8], track_ofs: usize) -> M2ScalarTrack { + track_read(b, track_ofs, 2, |b, o| { + b.u16_at(o).map(|v| v as f32 / 32767.0) + }) +} + +/// Read a timed `C3Vector` (3×f32) track — the texture-transform **translation**/**scaling** +/// tracks and the M2Color **RGB** track. +pub(crate) fn track_vec3_timed(b: &[u8], track_ofs: usize) -> M2Vec3Track { + track_read(b, track_ofs, 12, rd_vec3) +} + +/// Read a timed quaternion (4×f32, raw v256 floats — vanilla does not compress quat keys) track — +/// the texture-transform **rotation** track. +pub(crate) fn track_quat(b: &[u8], track_ofs: usize) -> M2QuatTrack { + track_read(b, track_ofs, 16, |b, o| { + Some([ + b.f32_at(o)?, + b.f32_at(o + 4)?, + b.f32_at(o + 8)?, + b.f32_at(o + 12)?, + ]) + }) +} diff --git a/crates/benilla-mpq/Cargo.toml b/crates/benilla-mpq/Cargo.toml new file mode 100644 index 00000000..ea4d5ebf --- /dev/null +++ b/crates/benilla-mpq/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "benilla-mpq" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Read-only MPQ archive reader for WoW 1.12.1 (build 5875) — in-repo, decision 0021" + +[dependencies] +# Bounds-checked byte readers + the allocation-cap guard (decision 0064). MPQ migrates minimally: the +# hand-rolled rd_u16/rd_u32 move to ByteExt and the header/block-table/sector allocations get capped; +# nothing else here changes (this crate is StormLib-pinned and deliberately not churned further). +benilla-bytes = { path = "../benilla-bytes" } +# zlib/inflate for the ZLIB (0x02) sector codec. miniz_oxide backend (pure Rust); no C, no archive +# writing. Any other codec the differential pass turns up in real 1.12.1 data (e.g. PKWARE-DCL 0x08) +# gets vendored alongside; `decompress` errors loudly on an unknown method so nothing is missed. +flate2 = "1.1" diff --git a/crates/benilla-mpq/src/crypto.rs b/crates/benilla-mpq/src/crypto.rs new file mode 100644 index 00000000..bd1b587b --- /dev/null +++ b/crates/benilla-mpq/src/crypto.rs @@ -0,0 +1,141 @@ +//! The Storm (MPQ) crypto primitives — byte-exact with the real client. +//! +//! Three pieces, all driven by one statically-generated 0x500-entry table: +//! - [`hash_string`] — Blizzard's string hash, used for hash-table indexing (`TABLE_OFFSET`), the two +//! filename verifiers (`NAME_A`/`NAME_B`), and table/file keys (`FILE_KEY`). +//! - [`decrypt_block`] — the in-place table/sector decryptor. +//! +//! 1.12.1 archives carry **no encrypted files** (verified across the real `Data/`), so only the hash +//! and block *tables* are ever decrypted. The known StormLib vectors are pinned as tests below. + +/// The MPQ encryption table (0x500 u32s), generated by the StormLib seed walk. `const fn` so it's +/// baked into the binary, identical to the table the client builds at runtime. +const fn encryption_table() -> [u32; 0x500] { + let mut table = [0u32; 0x500]; + let mut seed: u32 = 0x0010_0001; + let mut i1 = 0; + while i1 < 0x100 { + let mut i2 = 0; + while i2 < 5 { + let idx = i1 + i2 * 0x100; + seed = (seed.wrapping_mul(125).wrapping_add(3)) % 0x2A_AAAB; + let t1 = (seed & 0xFFFF) << 0x10; + seed = (seed.wrapping_mul(125).wrapping_add(3)) % 0x2A_AAAB; + let t2 = seed & 0xFFFF; + table[idx] = t1 | t2; + i2 += 1; + } + i1 += 1; + } + table +} + +static TABLE: [u32; 0x500] = encryption_table(); + +/// Hash-type selectors (the table-quadrant offsets the client uses). +pub mod hash_type { + /// Hash-table slot index. + pub const TABLE_OFFSET: u32 = 0x000; + /// Filename verifier A (stored in the hash entry). + pub const NAME_A: u32 = 0x100; + /// Filename verifier B (stored in the hash entry). + pub const NAME_B: u32 = 0x200; + /// File / table decryption key. + pub const FILE_KEY: u32 = 0x300; +} + +/// Blizzard's string hash. Normalizes `/`→`\` and ASCII-uppercases (matching the client's +/// `AsciiToUpper` table, which only folds `a`–`z`), then runs the seed mix. +pub fn hash_string(name: &str, kind: u32) -> u32 { + let mut seed1: u32 = 0x7FED_7FED; + let mut seed2: u32 = 0xEEEE_EEEE; + for &b in name.as_bytes() { + let ch = match b { + b'/' => b'\\', + other => other.to_ascii_uppercase(), + } as u32; + seed1 = TABLE[(kind.wrapping_add(ch)) as usize] ^ seed1.wrapping_add(seed2); + seed2 = ch + .wrapping_add(seed1) + .wrapping_add(seed2) + .wrapping_add(seed2 << 5) + .wrapping_add(3); + } + seed1 +} + +/// Decrypt a block of `u32`s in place with `key` (the StormLib block cipher). A zero key is a no-op, +/// as in the client. Used for the hash/block tables; file data is never encrypted in 1.12.1. +pub fn decrypt_block(data: &mut [u32], mut key: u32) { + if key == 0 { + return; + } + let mut seed: u32 = 0xEEEE_EEEE; + for v in data.iter_mut() { + seed = seed.wrapping_add(TABLE[0x400 + (key & 0xFF) as usize]); + let ch = *v ^ key.wrapping_add(seed); + *v = ch; + key = (!key << 0x15).wrapping_add(0x1111_1111) | (key >> 0x0B); + seed = ch + .wrapping_add(seed) + .wrapping_add(seed << 5) + .wrapping_add(3); + } +} + +/// Test-only inverse of [`decrypt_block`] — lets the reader's round-trip tests (here and in `lib`) +/// build the encrypted hash/block tables a real archive stores. Same walk, XOR on the plaintext side. +#[cfg(test)] +pub(crate) fn encrypt_block(data: &mut [u32], mut key: u32) { + let mut seed: u32 = 0xEEEE_EEEE; + for v in data.iter_mut() { + seed = seed.wrapping_add(TABLE[0x400 + (key & 0xFF) as usize]); + let plain = *v; + *v = plain ^ key.wrapping_add(seed); + key = (!key << 0x15).wrapping_add(0x1111_1111) | (key >> 0x0B); + seed = plain + .wrapping_add(seed) + .wrapping_add(seed << 5) + .wrapping_add(3); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// StormLib's canonical vectors — these pin our table + hash to the real client byte-for-byte. + #[test] + fn hash_vectors_match_stormlib() { + assert_eq!( + hash_string("(hash table)", hash_type::FILE_KEY), + 0xC3AF_3770 + ); + assert_eq!( + hash_string("(block table)", hash_type::FILE_KEY), + 0xEC83_B3A3 + ); + assert_eq!( + hash_string("file.txt", hash_type::TABLE_OFFSET), + 0x3EA9_8D7A + ); + } + + #[test] + fn hash_is_slash_and_case_insensitive() { + let a = hash_string("path\\to\\FILE.blp", hash_type::TABLE_OFFSET); + let b = hash_string("path/to/file.blp", hash_type::TABLE_OFFSET); + assert_eq!(a, b); + } + + /// decrypt∘encrypt round-trips (encrypt is the same walk with the xor on the other side). + #[test] + fn decrypt_inverts_encrypt() { + let key = hash_string("(hash table)", hash_type::FILE_KEY); + let original = [0x1234_5678u32, 0x9ABC_DEF0, 0x0F0F_0F0F, 42]; + let mut buf = original; + encrypt_block(&mut buf, key); + decrypt_block(&mut buf, key); + assert_eq!(buf, original); + } +} diff --git a/crates/benilla-mpq/src/lib.rs b/crates/benilla-mpq/src/lib.rs new file mode 100644 index 00000000..489b854b --- /dev/null +++ b/crates/benilla-mpq/src/lib.rs @@ -0,0 +1,662 @@ +//! A read-only MPQ archive reader for **WoW 1.12.1 (build 5875)** — in-repo, replacing `wow-mpq` +//! (decision 0021). +//! +//! Deliberately narrow to what the real `Data/` chain actually is (verified by probing every archive): +//! format **V1/V2**, every file `COMPRESS`-flagged and **sectored**, **no encrypted files**, **no +//! single-unit**, **no PTCH** incremental patches. So this does header parse → hash/block **table** +//! decrypt → name lookup → sectored read (per-sector inflate). It does *not* write archives, decrypt +//! file data, verify signatures, or load the `(attributes)` table — the wasteful per-open parse that +//! motivated 0021. Anything outside the verified envelope is a hard error, never a silent guess. +//! +//! **Delete-markers.** The patch archives *do* carry file tombstones — a block entry flagged +//! `MPQ_FILE_DELETE_MARKER` (`0x02000000`), `size == 0`, meaning "this path is deleted from the +//! composite; do not use any lower-priority archive's copy." (`patch.MPQ` uses these to remove stock +//! FrameXML it replaces with backported addons — decision 0246.) These are **not** readable files: +//! [`Archive::read_file`] refuses one with [`Error::NotFound`] rather than returning the empty buffer +//! a naive `size == 0` stored-read would (the silent mislead that cost 0246 a day), and +//! [`Archive::is_delete_marker`] lets the chain walker see a tombstone and stop instead of falling +//! through. A delete-marked entry still *exists* in the hash table, so [`Archive::contains`] reports +//! it present — the chain must consult [`Archive::is_delete_marker`] to tell "deleted" from "readable". +//! +//! ## Concurrency +//! The parsed [`Index`] (header offsets + decrypted hash/block tables) is immutable, so [`Archive`] is +//! a cheap `Arc` handle that is `Send + Sync` and `Clone`. [`Archive::read_file`] is `&self`: it opens +//! a **fresh** OS file handle for the read, so concurrent reads share no seek state and need no lock. +//! Opening the handle is a cheap syscall; the expensive table parse happens once, in [`Archive::open`]. +//! +//! Correctness was proven by a differential pass against `wow-mpq` over real archives — 37,971 reads +//! byte-identical across all 13 (decision 0021; the throwaway oracle test is in git history). The +//! `benilla-formats` loaders now exercise this reader end-to-end against real data on every run. +//! +//! Byte access goes through `benilla-bytes` (decision 0064), migrated minimally: the local rd_u16/ +//! rd_u32 are gone in favor of `ByteExt`, and every allocation sized from a header/block-table count +//! or a sector length — all attacker-controllable in a hostile archive — is capped by what the +//! archive file could actually hold (`capped`), so a lying header fails with [`Error::Corrupt`] +//! instead of aborting the allocator. Nothing else here changes. + +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use benilla_bytes::{capped, ByteExt}; + +mod crypto; +use crypto::{decrypt_block, hash_string, hash_type}; + +/// `MPQ\x1A` — the archive header signature (little-endian u32). +const SIGNATURE: u32 = 0x1A51_504D; +/// `MPQ\x1B` — the user-data header signature (some archives offset the real header past it). +const USERDATA_SIGNATURE: u32 = 0x1B51_504D; + +// Block-table entry flags (only the ones the 1.12.1 envelope can present; others are asserted absent). +const FLAG_IMPLODE: u32 = 0x0000_0100; +const FLAG_COMPRESS: u32 = 0x0000_0200; +const FLAG_ENCRYPTED: u32 = 0x0001_0000; +const FLAG_SINGLE_UNIT: u32 = 0x0100_0000; +/// A patch-archive tombstone: the path is deleted from the composite chain (size 0). Recognised so a +/// tombstone reads as [`Error::NotFound`], not an empty buffer (decision 0246). +const FLAG_DELETE_MARKER: u32 = 0x0200_0000; +const FLAG_EXISTS: u32 = 0x8000_0000; + +/// Hash-table sentinels. +const HASH_EMPTY: u32 = 0xFFFF_FFFF; // slot never used — stop probing +const HASH_DELETED: u32 = 0xFFFF_FFFE; // slot deleted — skip, keep probing + +/// One decrypted hash-table entry (16 bytes on disk). +#[derive(Clone, Copy)] +struct HashEntry { + name_a: u32, + name_b: u32, + block_index: u32, +} + +/// One decrypted block-table entry (16 bytes on disk). The on-disk `compressed_size` (word 1) is +/// skipped — sector sizes come from the offset table, so it's never needed for reading. +#[derive(Clone, Copy)] +struct BlockEntry { + file_pos: u32, + file_size: u32, + flags: u32, +} + +/// The immutable, parsed-once index of an archive: where the data starts, the sector size, and the +/// decrypted hash/block tables. Shared behind an `Arc` so [`Archive`] clones are cheap and reads borrow +/// it without locking. +struct Index { + path: PathBuf, + /// Byte offset of the MPQ header within the file (0 for vanilla, but found, not assumed). + archive_offset: u64, + /// `512 << block_size` — the uncompressed sector size. + sector_size: usize, + hash_table: Vec, + block_table: Vec, +} + +/// An open MPQ archive: a cheap, `Clone`, `Send + Sync` handle over a shared [`Index`]. Reads open a +/// fresh OS handle, so they are `&self` and lock-free. +#[derive(Clone)] +pub struct Archive { + index: Arc, +} + +/// Errors a read can produce. Anything outside the verified 1.12.1 envelope (encrypted file, unknown +/// codec, …) surfaces here rather than being papered over. +#[derive(Debug)] +pub enum Error { + Io(io::Error), + NotMpq, + NotFound(String), + /// A file or codec shape the 1.12.1 envelope shouldn't contain — investigate, don't guess. + Unsupported(String), + Decompress(String), + /// A header/block-table count or sector length claims more than the archive file could possibly + /// hold. Caught by the capped reservations (decision 0064) before the allocator would; a corrupt + /// or truncated archive, not a coding bug. + Corrupt(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Io(e) => write!(f, "io: {e}"), + Error::NotMpq => write!(f, "not an MPQ archive (no header signature found)"), + Error::NotFound(n) => write!(f, "file not in archive: {n}"), + Error::Unsupported(m) => write!(f, "unsupported (outside 1.12.1 envelope): {m}"), + Error::Decompress(m) => write!(f, "decompress: {m}"), + Error::Corrupt(m) => write!(f, "corrupt archive: {m}"), + } + } +} + +impl std::error::Error for Error {} +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} + +type Result = std::result::Result; + +/// Reinterpret a byte buffer (length a multiple of 4) as a `Vec` (little-endian) for table decrypt. +fn to_u32s(bytes: &[u8]) -> Vec { + bytes + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +/// Bytes actually available in the archive file from `pos` to EOF, clamped into `usize`. Table counts +/// and sector lengths are attacker-controllable header/block-table values (decision 0064); every +/// reservation sized from one of them is capped against this, so a lying value reserves at most the +/// archive's own size and the mismatch surfaces as a clean [`Error::Corrupt`], never an allocator abort. +fn avail_from(file_len: u64, pos: u64) -> usize { + usize::try_from(file_len.saturating_sub(pos)).unwrap_or(usize::MAX) +} + +impl Archive { + /// Open and index an archive: find the header, then read+decrypt the hash and block tables. The + /// file handle used here is dropped on return; reads reopen their own. + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref().to_path_buf(); + let mut file = File::open(&path)?; + let file_len = file.metadata()?.len(); + + // Find the header. Vanilla archives put it at 0, but the format allows a user-data header + // first, so scan 512-aligned boundaries for the `MPQ\x1A` signature (resolving `MPQ\x1B`). + let archive_offset = find_header(&mut file, file_len)?; + + // Read the 32-byte V1 header prefix (V2/V3/V4 only extend it; the V1 fields are all we need — + // every real table offset fits in u32, so the V2 hi-block table is irrelevant here). + file.seek(SeekFrom::Start(archive_offset))?; + let mut hdr = [0u8; 32]; + file.read_exact(&mut hdr)?; + // Every field below is a fixed offset within a buffer that was just `read_exact`'d to exactly + // 32 bytes, so `u16_at`/`u32_at` can only return `None` if that invariant is somehow broken — + // map it to `Error::NotMpq` (a header we can't decode isn't a valid one) rather than trust it. + let signature = hdr.u32_at(0).ok_or(Error::NotMpq)?; + debug_assert_eq!(signature, SIGNATURE); + let block_size_shift = hdr.u16_at(14).ok_or(Error::NotMpq)?; + // The shift is a raw header u16: unchecked, a corrupt value ≥ 55 overflows the `usize` + // shift (a panic in debug, a masked shift in release — both wrong). 23 (512 B << 23 = 4 GiB + // sectors) is already far beyond any real archive (1.12 data uses 3 → 4 KiB sectors). + if block_size_shift > 23 { + return Err(Error::Corrupt(format!( + "block size shift {block_size_shift} exceeds the sanity cap" + ))); + } + let sector_size = 512usize << block_size_shift; + let hash_table_pos = hdr.u32_at(16).ok_or(Error::NotMpq)? as u64; + let block_table_pos = hdr.u32_at(20).ok_or(Error::NotMpq)? as u64; + let hash_table_size = hdr.u32_at(24).ok_or(Error::NotMpq)? as usize; + let block_table_size = hdr.u32_at(28).ok_or(Error::NotMpq)? as usize; + + let hash_table = read_hash_table( + &mut file, + archive_offset + hash_table_pos, + hash_table_size, + file_len, + )?; + let block_table = read_block_table( + &mut file, + archive_offset + block_table_pos, + block_table_size, + file_len, + )?; + + Ok(Archive { + index: Arc::new(Index { + path, + archive_offset, + sector_size, + hash_table, + block_table, + }), + }) + } + + /// Whether the archive holds `name` (case-insensitive; `/` and `\` equivalent). No I/O. + pub fn contains(&self, name: &str) -> bool { + self.index.find(name).is_some() + } + + /// The uncompressed size of `name`, if present. No I/O (read from the block table). + pub fn file_size(&self, name: &str) -> Option { + self.index.find(name).map(|b| b.file_size) + } + + /// Whether `name`'s entry is a **delete-marker** (a patch tombstone) rather than a readable file. + /// [`Archive::contains`] is still `true` for one (the hash entry exists); the chain walker calls + /// this to tell "the client deleted this path" (stop, don't fall through) from "readable file" + /// (decision 0246). No I/O. + pub fn is_delete_marker(&self, name: &str) -> bool { + self.index + .find(name) + .is_some_and(|b| b.flags & FLAG_DELETE_MARKER != 0) + } + + /// The archive's path. + pub fn path(&self) -> &Path { + &self.index.path + } + + /// Read and decompress a file by its internal path. `/` or `\`; case-insensitive. `&self`: opens a + /// fresh OS handle, so concurrent reads need no lock. + pub fn read_file(&self, name: &str) -> Result> { + let idx = &self.index; + let block = idx.find(name).ok_or_else(|| Error::NotFound(name.into()))?; + + if block.flags & FLAG_EXISTS == 0 { + return Err(Error::NotFound(name.into())); + } + // A delete-marker is a tombstone, not a file: `size == 0` with no data. Reading it as a + // stored 0-byte file would hand back an empty buffer that looks like a real (empty) file — + // the silent mislead of 0246. It is "not found" (the composite deleted this path). + if block.flags & FLAG_DELETE_MARKER != 0 { + return Err(Error::NotFound(name.into())); + } + // The verified 1.12.1 envelope: no encrypted files, no single-unit. Refuse rather than guess. + if block.flags & FLAG_ENCRYPTED != 0 { + return Err(Error::Unsupported(format!("encrypted file {name}"))); + } + if block.flags & FLAG_SINGLE_UNIT != 0 { + return Err(Error::Unsupported(format!("single-unit file {name}"))); + } + + let mut file = File::open(&idx.path)?; + let file_pos = idx.archive_offset + block.file_pos as u64; + let file_size = block.file_size as usize; + let compressed = block.flags & (FLAG_COMPRESS | FLAG_IMPLODE) != 0; + let implode_only = block.flags & FLAG_IMPLODE != 0 && block.flags & FLAG_COMPRESS == 0; + + if !compressed { + // Stored: raw bytes, exactly file_size. + file.seek(SeekFrom::Start(file_pos))?; + let mut out = vec![0u8; file_size]; + file.read_exact(&mut out)?; + return Ok(out); + } + + // Sectored read. The offset table is `sector_count + 1` u32s at file_pos; sector offsets are + // absolute from file_pos, so a SECTOR_CRC table (present only on the patch archives) sits + // between the offset table and the first sector and is skipped for free. + // + // `file_size` (hence `sector_count`) and each sector's `comp_len` all come from the + // block-table entry / on-disk offset table — the same attacker-controllable-header shape as + // the hash/block tables (decision 0064). Cap every reservation below by what the archive file + // could actually hold from `file_pos` on, so a corrupt entry fails cleanly instead of the + // allocator aborting. + let file_len = file.metadata()?.len(); + let avail = avail_from(file_len, file_pos); + let sector_count = file_size.div_ceil(idx.sector_size); + let otab_len = sector_count.checked_add(1).ok_or_else(|| { + Error::Corrupt(format!("{name}: sector count overflow ({sector_count})")) + })?; + let otab_cap = capped(otab_len, 4, avail); + if otab_cap < otab_len { + return Err(Error::Corrupt(format!( + "{name}: sector offset table ({otab_len} entries) larger than the archive" + ))); + } + file.seek(SeekFrom::Start(file_pos))?; + let mut otab = vec![0u8; otab_cap * 4]; // == otab_len * 4, proven <= avail above + file.read_exact(&mut otab)?; + let offsets: Vec = otab + .chunks_exact(4) + .map(|c| { + c.u32_at(0) + .ok_or_else(|| Error::Corrupt(format!("{name}: corrupt sector offset table"))) + }) + .collect::>>()?; + + let mut out = Vec::with_capacity(capped(file_size, 1, avail)); + for i in 0..sector_count { + let start = offsets[i] as u64; + let end = offsets[i + 1] as u64; + let comp_len = end + .checked_sub(start) + .ok_or_else(|| Error::Decompress(format!("{name}: sector {i} offsets reversed")))? + as usize; + let comp_cap = capped(comp_len, 1, avail); + if comp_cap < comp_len { + return Err(Error::Corrupt(format!( + "{name}: sector {i} length ({comp_len}) larger than the archive" + ))); + } + let want = (file_size - out.len()).min(idx.sector_size); // last sector may be short + + file.seek(SeekFrom::Start(file_pos + start))?; + let mut raw = vec![0u8; comp_len]; // proven <= avail above + file.read_exact(&mut raw)?; + + if comp_len >= want { + // Not actually compressed (a sector that wouldn't shrink is stored verbatim). + out.extend_from_slice(&raw[..want]); + } else if implode_only { + // IMPLODE files carry no per-sector method byte. (Verified absent in 1.12.1; kept for + // completeness so the shape is explicit rather than a silent fall-through.) + out.extend_from_slice(&decompress(0x08, &raw, want, name)?); + } else { + // COMPRESS: leading byte is the codec mask, rest is the payload. + let method = raw[0]; + out.extend_from_slice(&decompress(method, &raw[1..], want, name)?); + } + } + Ok(out) + } +} + +impl Index { + /// Locate a file's block entry via the hash table (Blizzard's open-addressed probe). + fn find(&self, name: &str) -> Option { + let len = self.hash_table.len(); + if len == 0 { + return None; + } + let mask = (len - 1) as u32; + let start = (hash_string(name, hash_type::TABLE_OFFSET) & mask) as usize; + let want_a = hash_string(name, hash_type::NAME_A); + let want_b = hash_string(name, hash_type::NAME_B); + for i in 0..len { + let e = &self.hash_table[(start + i) % len]; + if e.block_index == HASH_EMPTY { + return None; // never-used slot ends the chain + } + if e.block_index == HASH_DELETED { + continue; + } + if e.name_a == want_a && e.name_b == want_b { + return self.block_table.get(e.block_index as usize).copied(); + } + } + None + } +} + +/// Scan 512-aligned boundaries for the MPQ header, resolving a `MPQ\x1B` user-data header to the real +/// one it points at. Returns the byte offset of the `MPQ\x1A` header. +fn find_header(file: &mut File, file_len: u64) -> Result { + let mut off = 0u64; + while off + 4 <= file_len { + file.seek(SeekFrom::Start(off))?; + let mut sig = [0u8; 4]; + if file.read_exact(&mut sig).is_err() { + break; + } + match u32::from_le_bytes(sig) { + SIGNATURE => return Ok(off), + USERDATA_SIGNATURE => { + // user-data header: u32 sig, u32 user_data_size, u32 header_offset (relative to here). + let mut rest = [0u8; 8]; + file.read_exact(&mut rest)?; + let header_offset = u32::from_le_bytes([rest[4], rest[5], rest[6], rest[7]]) as u64; + return Ok(off + header_offset); + } + _ => {} + } + off += 512; + } + Err(Error::NotMpq) +} + +fn read_hash_table( + file: &mut File, + pos: u64, + count: usize, + file_len: u64, +) -> Result> { + // `count` is a header u32 (decision 0064): cap the reservation by what the archive could actually + // hold from `pos` on, so a lying header can at worst reserve the file's own size. If the capped + // reservation had to shrink below `count`, the header is lying (or the file is truncated) — fail + // cleanly here rather than let the entry-building loop below index past a short `words`. + let avail = avail_from(file_len, pos); + let cap = capped(count, 16, avail); + if cap < count { + return Err(Error::Corrupt(format!( + "hash table: header claims {count} entries, only room for {cap} in the archive" + ))); + } + let mut bytes = vec![0u8; cap * 16]; // == count * 16, proven <= avail above — never a wild reserve + file.seek(SeekFrom::Start(pos))?; + file.read_exact(&mut bytes)?; + let mut words = to_u32s(&bytes); + decrypt_block(&mut words, hash_string("(hash table)", hash_type::FILE_KEY)); + Ok((0..count) + .map(|i| { + let o = i * 4; + HashEntry { + name_a: words[o], + name_b: words[o + 1], + // words[o+2] packs locale(u16)+platform(u16); 1.12.1 data is locale-neutral, ignore. + block_index: words[o + 3], + } + }) + .collect()) +} + +fn read_block_table( + file: &mut File, + pos: u64, + count: usize, + file_len: u64, +) -> Result> { + // Same treatment as `read_hash_table` above: `count` is an attacker-controllable header u32. + let avail = avail_from(file_len, pos); + let cap = capped(count, 16, avail); + if cap < count { + return Err(Error::Corrupt(format!( + "block table: header claims {count} entries, only room for {cap} in the archive" + ))); + } + let mut bytes = vec![0u8; cap * 16]; // == count * 16, proven <= avail above + file.seek(SeekFrom::Start(pos))?; + file.read_exact(&mut bytes)?; + let mut words = to_u32s(&bytes); + decrypt_block( + &mut words, + hash_string("(block table)", hash_type::FILE_KEY), + ); + Ok((0..count) + .map(|i| { + let o = i * 4; + BlockEntry { + file_pos: words[o], + // words[o + 1] = compressed_size on disk; unused (offset table sizes sectors). + file_size: words[o + 2], + flags: words[o + 3], + } + }) + .collect()) +} + +/// Decompress one sector payload by its MPQ codec mask. 1.12.1 uses ZLIB (and store, handled by the +/// caller); any other mask is an error so the differential pass flags it for us to add deliberately. +fn decompress(method: u8, data: &[u8], expected: usize, name: &str) -> Result> { + match method { + 0x02 => { + use flate2::read::ZlibDecoder; + let mut out = Vec::with_capacity(expected); + ZlibDecoder::new(data) + .read_to_end(&mut out) + .map_err(|e| Error::Decompress(format!("{name}: zlib: {e}")))?; + Ok(out) + } + other => Err(Error::Unsupported(format!( + "{name}: sector codec 0x{other:02X} not yet implemented" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A unique path under the OS temp dir — no `tempfile` dependency (decision 0064 keeps the leaf + /// crates dependency-light), so uniqueness is hand-rolled: pid + a per-process counter, enough to + /// never collide across parallel `cargo test` threads in this crate. + fn temp_path(tag: &str) -> PathBuf { + static COUNTER: AtomicU32 = AtomicU32::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "benilla_mpq_test_{}_{}_{}.mpq", + std::process::id(), + n, + tag + )) + } + + /// Build a minimal 32-byte V1 MPQ header (unused fields zeroed) at the start of a buffer. + fn header( + block_size_shift: u16, + hash_table_pos: u32, + block_table_pos: u32, + hash_table_size: u32, + block_table_size: u32, + ) -> [u8; 32] { + let mut h = [0u8; 32]; + h[0..4].copy_from_slice(&SIGNATURE.to_le_bytes()); + h[14..16].copy_from_slice(&block_size_shift.to_le_bytes()); + h[16..20].copy_from_slice(&hash_table_pos.to_le_bytes()); + h[20..24].copy_from_slice(&block_table_pos.to_le_bytes()); + h[24..28].copy_from_slice(&hash_table_size.to_le_bytes()); + h[28..32].copy_from_slice(&block_table_size.to_le_bytes()); + h + } + + /// Write `bytes` to a fresh temp file, run `Archive::open` on it, and remove the file afterwards + /// regardless of the outcome. + fn open_temp(tag: &str, bytes: &[u8]) -> Result { + let path = temp_path(tag); + std::fs::write(&path, bytes).expect("write temp archive"); + let result = Archive::open(&path); + let _ = std::fs::remove_file(&path); + result + } + + #[test] + fn open_rejects_hash_table_count_bigger_than_the_file() { + // A header claiming u32::MAX hash-table entries in a 40-byte file: pre-0064 this would have + // tried `vec![0u8; u32::MAX as usize * 16]` (~68 GiB) and aborted the allocator. It must now + // return a clean `Error::Corrupt` instead. + let hdr = header(0, 32, 32, u32::MAX, 0); + let mut bytes = hdr.to_vec(); + bytes.extend_from_slice(&[0u8; 8]); // pad past the header so `pos` (32) is in-bounds + match open_temp("hash_overflow", &bytes) { + Err(Error::Corrupt(_)) => {} + Ok(_) => panic!("expected Error::Corrupt, got Ok(_)"), + Err(other) => panic!("expected Error::Corrupt, got {other:?}"), + } + } + + #[test] + fn open_rejects_block_table_count_bigger_than_the_file() { + let hdr = header(0, 32, 32, 0, u32::MAX); + let mut bytes = hdr.to_vec(); + bytes.extend_from_slice(&[0u8; 8]); + match open_temp("block_overflow", &bytes) { + Err(Error::Corrupt(_)) => {} + Ok(_) => panic!("expected Error::Corrupt, got Ok(_)"), + Err(other) => panic!("expected Error::Corrupt, got {other:?}"), + } + } + + #[test] + fn open_accepts_a_valid_archive_with_empty_tables() { + // The boundary case the cap must not reject: zero-entry tables pointing exactly at EOF. Proves + // the new cap logic doesn't disturb a legitimately-shaped (if degenerate) archive. + let hdr = header(0, 32, 32, 0, 0); + let archive = + open_temp("empty_tables", &hdr).expect("a well-formed empty-table archive must open"); + assert!(!archive.contains("anything")); + assert_eq!(archive.file_size("anything"), None); + } + + /// Like [`open_temp`] but leaves the backing file on disk so [`Archive::read_file`] (which reopens + /// the archive by path) works; returns the path for the caller to remove. + fn open_temp_kept(tag: &str, bytes: &[u8]) -> (Archive, PathBuf) { + let path = temp_path(tag); + std::fs::write(&path, bytes).expect("write temp archive"); + let archive = Archive::open(&path).expect("open temp archive"); + (archive, path) + } + + /// Build a minimal V1 archive holding exactly one entry (`name`) with the given block `flags` and + /// stored `data`. Tables are encrypted with the real keys so the reader's decrypt round-trips + /// them — enough to exercise flag handling (delete-marker vs. a plain stored file). + fn archive_with_one_entry(name: &str, flags: u32, data: &[u8]) -> Vec { + use crypto::{encrypt_block, hash_type}; + const HASH_SLOTS: u32 = 4; // power of two — the reader masks with len-1 + let hash_pos = 32u32; + let block_pos = hash_pos + HASH_SLOTS * 16; + let data_pos = block_pos + 16; // one 16-byte block entry + + // Hash table (plaintext): every slot empty (0xFFFFFFFF) except our name's home slot. + let mut hash = vec![0xFFFF_FFFFu32; HASH_SLOTS as usize * 4]; + let slot = (hash_string(name, hash_type::TABLE_OFFSET) & (HASH_SLOTS - 1)) as usize; + hash[slot * 4] = hash_string(name, hash_type::NAME_A); + hash[slot * 4 + 1] = hash_string(name, hash_type::NAME_B); + hash[slot * 4 + 2] = 0; // locale | platform + hash[slot * 4 + 3] = 0; // → block index 0 + encrypt_block(&mut hash, hash_string("(hash table)", hash_type::FILE_KEY)); + + // Block table (plaintext): one entry pointing at the trailing data. + let mut block = vec![data_pos, data.len() as u32, data.len() as u32, flags]; + encrypt_block( + &mut block, + hash_string("(block table)", hash_type::FILE_KEY), + ); + + let mut bytes = header(0, hash_pos, block_pos, HASH_SLOTS, 1).to_vec(); + for w in hash.into_iter().chain(block) { + bytes.extend_from_slice(&w.to_le_bytes()); + } + bytes.extend_from_slice(data); + bytes + } + + /// The builder is honest: a plain stored (EXISTS-only) entry reads its bytes back and is not a + /// delete-marker — so the negative result in the next test can't be a broken-archive artifact. + #[test] + fn stored_entry_reads_its_bytes() { + let (arc, path) = open_temp_kept( + "stored", + &archive_with_one_entry("Interface\\a.txt", FLAG_EXISTS, b"hello"), + ); + assert!(arc.contains("Interface\\a.txt")); + assert!(!arc.is_delete_marker("Interface\\a.txt")); + assert_eq!(arc.read_file("Interface\\a.txt").unwrap(), b"hello"); + let _ = std::fs::remove_file(&path); + } + + /// A delete-marker (`EXISTS | DELETE_MARKER`, size 0) is a tombstone, not an empty file: it is + /// still `contains`ed (the hash entry exists) and flagged `is_delete_marker`, but `read_file` + /// refuses it with `NotFound` rather than handing back an empty buffer (decision 0246 — the silent + /// mislead that made the trainer arc quote a stale, deleted stock file). + #[test] + fn delete_marker_is_not_a_readable_empty_file() { + let name = "Interface\\FrameXML\\ClassTrainerFrame.xml"; + let (arc, path) = open_temp_kept( + "delete_marker", + &archive_with_one_entry(name, FLAG_EXISTS | FLAG_DELETE_MARKER, &[]), + ); + assert!(arc.contains(name), "the hash entry exists"); + assert!(arc.is_delete_marker(name), "flagged as a tombstone"); + // The file is present on disk, so a NotFound here is the tombstone guard firing — not a + // missing backing file. + match arc.read_file(name) { + Err(Error::NotFound(_)) => {} + other => panic!("expected NotFound for a tombstone, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn avail_from_never_underflows_when_pos_exceeds_file_len() { + // `pos` derived from a corrupt header can point past EOF; the helper must saturate to 0 + // rather than wrap `file_len - pos` around to a huge usize. + assert_eq!(avail_from(10, 100), 0); + assert_eq!(avail_from(100, 10), 90); + assert_eq!(avail_from(0, 0), 0); + } +} diff --git a/crates/benilla-protocol/Cargo.toml b/crates/benilla-protocol/Cargo.toml new file mode 100644 index 00000000..07df0a19 --- /dev/null +++ b/crates/benilla-protocol/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "benilla-protocol" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WoW 1.12.1 auth (SRP6) + world wire protocol and the benilla-auth CLI" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +benilla-srp = { path = "../benilla-srp" } +flate2 = "1.0" diff --git a/crates/benilla-protocol/examples/cooldown_probe.rs b/crates/benilla-protocol/examples/cooldown_probe.rs new file mode 100644 index 00000000..7895db1a --- /dev/null +++ b/crates/benilla-protocol/examples/cooldown_probe.rs @@ -0,0 +1,224 @@ +//! Diagnostic probe: measure the server's ACTUAL cooldown window for Charge (spell 100, +//! `recoveryTime 0 / category 44 / categoryRecoveryTime 15000`) against the client-rendered +//! 15 s sweep — the "I can charge before the indicator is up" report. +//! +//! Flow: log in the slot's probe warrior (`Probe`), `.learn 100`, teleport into the Northshire kobold +//! cluster, Battle Stance, pick a Kobold Vermin (entry 6) from the create stream, Charge it, +//! then spam re-casts every 200 ms. vmangos checks the cooldown at the TOP of `CheckCast` +//! (`Spell.cpp:5369`), so the flip from `SPELL_FAILED_NOT_READY` (60) to any other code (or a +//! second `SMSG_SPELL_GO`) timestamps the server's cooldown end relative to the first GO. +//! +//! Run: `cargo run -p benilla-protocol --example cooldown_probe -- probeN pprobeN [host]` — the +//! slot-keyed probe account (method.md "The local vmangos server"; NEVER `one`, the director's +//! account — a probe login there kicks their live session). Two caveats: this is a COMBAT probe, +//! so it runs director-supervised (method.md: no unattended combat probes), and `.learn` is +//! SEC_DEVELOPER (5) while probe accounts are gmlevel 3 — raise the slot account via the vmangos +//! console for the run and restore it after. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use benilla_protocol::{decode, EntityKind, SessionEvent, WorldSession, WORLD_PORT}; + +const CHARGE: u32 = 100; +const BATTLE_STANCE: u32 = 2457; +const KOBOLD_VERMIN: u32 = 6; + +fn fail_name(reason: u8) -> &'static str { + match reason { + 0 => "AFFECTING_COMBAT", + 10 => "BAD_TARGETS", + 23 => "DONT_REPORT", + 42 => "LINE_OF_SIGHT", + 50 => "NOPATH", + 56 => "NOT_KNOWN", + 60 => "NOT_READY", + 86 => "ONLY_SHAPESHIFT", + 89 => "OUT_OF_RANGE", + _ => "?", + } +} + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let user = args + .next() + .context("usage: cooldown_probe -- [host] (slot-keyed account)")?; + let pass = args + .next() + .context("usage: cooldown_probe -- [host] (slot-keyed account)")?; + let host = args.next().unwrap_or_else(|| "localhost".into()); + + let logon = benilla_protocol::logon(&host, &user, &pass)?; + let world_addr = logon + .realms + .first() + .map(|r| r.address.clone()) + .unwrap_or_else(|| format!("{host}:{WORLD_PORT}")); + let mut session = WorldSession::connect(&world_addr, &user, logon.session_key)?; + let characters = session.char_enum()?; + let character = characters + .iter() + .find(|c| c.name == "Tri") + .or_else(|| characters.first()) + .context("no characters")?; + let self_guid = character.guid; + println!("logging in '{}' (guid {self_guid})", character.name); + session.player_login(self_guid)?; + session.set_active_mover(self_guid)?; + session.set_read_timeout(Some(Duration::from_millis(100)))?; + + let mut kobolds: Vec = Vec::new(); + let drain = |session: &mut WorldSession, + kobolds: &mut Vec, + secs: f32, + mut on_event: &mut dyn FnMut(f64, SessionEvent)| + -> Result<()> { + let until = Instant::now() + Duration::from_secs_f32(secs); + let t0 = Instant::now(); + while Instant::now() < until { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match &ev { + SessionEvent::ObjectCreate { + guid, kind, fields, .. + } if matches!(kind, EntityKind::Unit) + && fields.object_entry() == Some(KOBOLD_VERMIN) + && !kobolds.contains(guid) => + { + kobolds.push(*guid); + } + // A `.go` teleport must be acked or the server freezes us and streams nothing. + SessionEvent::Teleport { guid, counter, .. } => { + println!(" teleport → acking (counter {counter})"); + session.teleport_ack(*guid, *counter)?; + } + _ => {} + } + on_event(t0.elapsed().as_secs_f64(), ev); + } + } + let _ = &mut on_event; + Ok(()) + }; + let mut quiet = |_t: f64, ev: SessionEvent| match ev { + SessionEvent::Chat(m) => println!(" chat: {:?}", m.text), + SessionEvent::SpellLearned { spell_id } => println!(" learned: {spell_id}"), + _ => {} + }; + + // World-enter settle, then arm the character: learn Charge, stance, teleport to the kobolds. + drain(&mut session, &mut kobolds, 3.0, &mut quiet)?; + session.set_selection(self_guid)?; + session.send_chat(".learn 100")?; + session.send_chat(".go xyz -8785 -150 82.5")?; + drain(&mut session, &mut kobolds, 3.0, &mut quiet)?; + session.cast_spell(BATTLE_STANCE, None)?; + let mut chatty = |_t: f64, ev: SessionEvent| match ev { + SessionEvent::Chat(m) => println!(" chat: {:?}", m.text), + SessionEvent::CastResult { + spell_id, + success, + reason, + } => println!( + " pre: cast_result spell={spell_id} success={success} reason={reason:?} ({})", + reason.map(fail_name).unwrap_or("-") + ), + _ => {} + }; + drain(&mut session, &mut kobolds, 2.0, &mut chatty)?; + println!("kobolds seen: {}", kobolds.len()); + + // First Charge: try candidates until one GO lands. + let mut target: Option = None; + let mut charge_go_at: Option = None; + for &guid in kobolds.clone().iter() { + println!("charging kobold {guid:#x}…"); + session.set_selection(guid)?; + session.cast_spell(CHARGE, Some(guid))?; + // The anchor is stamped INSIDE the watch, at GO decode — the drain window runs its full + // length regardless, and a window-end stamp would smear the anchor by up to 1.5 s. + let mut verdict: Option<(bool, Instant)> = None; + let mut watch = |_t: f64, ev: SessionEvent| match ev { + SessionEvent::SpellGo { + caster, spell_id, .. + } if caster == self_guid && spell_id == CHARGE => { + verdict = Some((true, Instant::now())); + } + SessionEvent::CastResult { + spell_id, + success: false, + reason, + } if spell_id == CHARGE => { + println!( + " refused: {:?} ({})", + reason, + reason.map(fail_name).unwrap_or("-") + ); + verdict = Some((false, Instant::now())); + } + _ => {} + }; + drain(&mut session, &mut kobolds, 1.5, &mut watch)?; + if let Some((true, at)) = verdict { + println!(" GO — cooldown armed"); + target = Some(guid); + charge_go_at = Some(at); + break; + } + } + let target = target.context("no charge landed — no measurement")?; + let t0 = charge_go_at.unwrap(); + + // The spam loop: recast every 200 ms for 20 s; every result timestamps the server verdict. + println!("t=0.00s — first GO received; spamming recasts…"); + let mut last_not_ready: Option = None; + let mut first_free: Option<(f64, String)> = None; + while t0.elapsed() < Duration::from_secs(20) { + session.cast_spell(CHARGE, Some(target))?; + let mut watch = |_t: f64, ev: SessionEvent| { + let at = t0.elapsed().as_secs_f64(); + match ev { + SessionEvent::CastResult { + spell_id, + success, + reason, + } if spell_id == CHARGE => { + let name = reason.map(fail_name).unwrap_or("-"); + println!( + "t={at:5.2}s cast_result success={success} reason={reason:?} ({name})" + ); + if reason == Some(60) { + last_not_ready = Some(at); + } else if first_free.is_none() { + first_free = Some((at, format!("result {reason:?} ({name})"))); + } + } + SessionEvent::SpellGo { + caster, spell_id, .. + } if caster == self_guid && spell_id == CHARGE => { + println!("t={at:5.2}s SPELL_GO (recast SUCCEEDED)"); + if first_free.is_none() { + first_free = Some((at, "SPELL_GO".into())); + } + } + SessionEvent::SpellCooldowns { cooldowns, .. } => { + println!("t={at:5.2}s SMSG_SPELL_COOLDOWN {cooldowns:?}"); + } + SessionEvent::CooldownEvent { spell_id, .. } => { + println!("t={at:5.2}s SMSG_COOLDOWN_EVENT spell={spell_id}"); + } + SessionEvent::ClearCooldown { spell_id, .. } => { + println!("t={at:5.2}s SMSG_CLEAR_COOLDOWN spell={spell_id}"); + } + _ => {} + } + }; + drain(&mut session, &mut kobolds, 0.2, &mut watch)?; + } + + println!("---"); + println!("last NOT_READY: {last_not_ready:?}"); + println!("first non-NOT_READY after GO: {first_free:?}"); + Ok(()) +} diff --git a/crates/benilla-protocol/examples/faction_probe.rs b/crates/benilla-protocol/examples/faction_probe.rs new file mode 100644 index 00000000..49f55137 --- /dev/null +++ b/crates/benilla-protocol/examples/faction_probe.rs @@ -0,0 +1,81 @@ +//! Diagnostic probe: stream object creates and print each unit's decoded +//! `UNIT_FIELD_FACTIONTEMPLATE` (+ level/display id as sanity anchors), to check the descriptor +//! decode against the server DB's `creature_template.faction`. +//! +//! Run: `cargo run -p benilla-protocol --example faction_probe -- probeN pprobeN [host]` +//! — the slot-keyed probe account (method.md "The local vmangos server"). The account is a +//! REQUIRED arg with no default: a default was once `one`, the director's account, where a +//! probe login kicks their live session (decision 0530). + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use benilla_protocol::{decode, EntityKind, SessionEvent, WorldSession, WORLD_PORT}; + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let user = args + .next() + .context("usage: faction_probe -- [host] (slot-keyed account)")?; + let pass = args + .next() + .context("usage: faction_probe -- [host] (slot-keyed account)")?; + let host = args.next().unwrap_or_else(|| "localhost".into()); + + let logon = benilla_protocol::logon(&host, &user, &pass)?; + let world_addr = logon + .realms + .first() + .map(|r| r.address.clone()) + .unwrap_or_else(|| format!("{host}:{WORLD_PORT}")); + let mut session = WorldSession::connect(&world_addr, &user, logon.session_key)?; + let characters = session.char_enum()?; + let character = characters.first().context("no characters")?; + let self_guid = character.guid; + println!("logging in '{}' (guid {self_guid})", character.name); + session.player_login(self_guid)?; + session.set_active_mover(self_guid)?; + session.set_read_timeout(Some(Duration::from_secs(2)))?; + + let deadline = Instant::now() + Duration::from_secs(8); + while Instant::now() < deadline { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::ObjectCreate { + guid, + kind, + display_id, + fields, + .. + } if matches!(kind, EntityKind::Unit | EntityKind::Player) => { + let me = if guid == self_guid { " (self)" } else { "" }; + println!( + "{kind:?} guid={guid}{me} display={display_id:?} level={:?} faction_tpl={:?}", + fields.unit_level(), + fields.unit_faction_template(), + ); + } + SessionEvent::ObjectValues { guid, fields } => { + if let Some(f) = fields.unit_faction_template() { + println!("Values guid={guid} faction_tpl={f}"); + } + } + SessionEvent::Reputations { standings } => { + let set: Vec<(usize, u8, i32)> = standings + .iter() + .enumerate() + .filter(|(_, &(f, s))| f != 0 || s != 0) + .map(|(i, &(f, s))| (i, f, s)) + .collect(); + println!( + "Reputations: {} slots, non-zero (idx, flags, standing): {set:?}", + standings.len() + ); + } + _ => {} + } + } + } + Ok(()) +} diff --git a/crates/benilla-protocol/examples/horde_chat_probe.rs b/crates/benilla-protocol/examples/horde_chat_probe.rs new file mode 100644 index 00000000..0e72ffd4 --- /dev/null +++ b/crates/benilla-protocol/examples/horde_chat_probe.rs @@ -0,0 +1,144 @@ +//! Live probe: verify the faction-language mechanism end-to-end against the local vmangos — a +//! HORDE character's say must echo back (the server accepted the tongue), a dot-command must +//! answer (it survived the pre-parse `KnowsLanguage` gate), and the split-writer path must carry +//! the tongue too. Run: `cargo run -p benilla-protocol --example horde_chat_probe -- probeN +//! pprobeN [host]` — the slot-keyed probe account (method.md "The local vmangos server"; it +//! once hardcoded the retired shared `three` identity, decision 0530). Creates the orc +//! `Orc` on this account on first run (names are realm-unique, so the orc keys to +//! the slot too). Exists because a hardcoded-Common send silently ate every Horde character's +//! chat and commands (decision 0392) — this is the one-shot regression check for that whole path. + +use anyhow::{bail, Context, Result}; +use benilla_protocol::messages::{CharCreateReq, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS}; +use benilla_protocol::{ServerPacket, WorldSession, WORLD_PORT}; + +/// The slot's Horde character name: `probe4` → `Orcfour` (letters only — digits are not legal +/// in character names, and realm-wide name uniqueness means each slot needs its own orc). +fn orc_name(user: &str) -> Option { + let n: usize = user.strip_prefix("probe")?.parse().ok()?; + let spelled = [ + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + ] + .get(n)?; + Some(format!("Orc{spelled}")) +} + +fn main() -> Result<()> { + let mut args = std::env::args().skip(1); + let user = args + .next() + .context("usage: horde_chat_probe -- [host] (slot-keyed account)")?; + let pass = args + .next() + .context("usage: horde_chat_probe -- [host] (slot-keyed account)")?; + let host = args.next().unwrap_or_else(|| "localhost".into()); + let orc = orc_name(&user) + .context("account must be a slot-keyed probeN (method.md \"The local vmangos server\")")?; + + let logon = benilla_protocol::logon(&host, &user, &pass)?; + let addr = logon + .realms + .first() + .map(|r| r.address.clone()) + .unwrap_or_else(|| format!("{host}:{WORLD_PORT}")); + let mut session = WorldSession::connect(&addr, &user, logon.session_key)?; + + let mut characters = session.char_enum()?; + if !characters.iter().any(|c| c.name == orc) { + // Orc (race 2) warrior male — the Horde case under test. + let req = CharCreateReq { + name: orc.clone(), + race: 2, + class: 1, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }; + match session.create_character(&req)? { + CHAR_CREATE_SUCCESS | CHAR_CREATE_NAME_IN_USE => {} + other => bail!("char create failed: {other:#x}"), + } + characters = session.char_enum()?; + } + let orc = characters + .iter() + .find(|c| c.name == orc) + .context("slot orc exists after create")?; + println!("probe: logging in {} (race {})", orc.name, orc.race); + session.player_login(orc.guid)?; + session.set_active_mover(orc.guid)?; + + // 1. A plain say must be ACCEPTED: the server echoes our own say back (own sends are never + // locally echoed in vanilla). Pre-fix, a Horde say sent Common and the server dropped it + // with only an SMSG_NOTIFICATION. + session.send_chat("orcish probe line")?; + // 2. A dot-command must survive the language gate: `.gps` answers with system messages. + session.send_chat(".gps")?; + + let mut say_echoed = false; + let mut system_reply = false; + let mut notified: Option = None; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline && !(say_echoed && system_reply) { + match session.recv() { + Ok(ServerPacket::MessageChat(m)) => { + println!( + "probe: chat type {} lang {} text {:?}", + m.chat_type, m.language, m.text + ); + if m.text == "orcish probe line" { + say_echoed = true; + println!("probe: SAY ECHOED (language {})", m.language); + } + // CHAT_MSG_SYSTEM = 0x0a on the inbound u8 field. Only the `.gps` answer counts + // (the login MOTD is also SYSTEM — it must not satisfy the command check). + if m.chat_type == 0x0a && m.text.contains("Map:") { + system_reply = true; + println!("probe: .gps REPLY: {:?}", m.text); + } + } + Ok(ServerPacket::Notification { text }) => { + println!("probe: NOTIFICATION: {text:?}"); + notified = Some(text); + } + Ok(_) => {} + Err(e) => bail!("recv: {e:#}"), + } + } + + // 3. The same through the SPLIT writer — the path the benilla app actually sends on + // (`into_split` must carry the tongue across). + let (mut reader, mut writer) = session.into_split()?; + writer.send_chat("orcish writer line")?; + let mut writer_echoed = false; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::time::Instant::now() < deadline && !writer_echoed { + match reader.recv() { + Ok(ServerPacket::MessageChat(m)) if m.text == "orcish writer line" => { + writer_echoed = true; + println!("probe: WRITER SAY ECHOED (language {})", m.language); + } + Ok(ServerPacket::Notification { text }) => { + println!("probe: NOTIFICATION: {text:?}"); + notified = Some(text); + } + Ok(_) => {} + Err(e) => bail!("reader recv: {e:#}"), + } + } + + println!("---"); + println!("say_echoed = {say_echoed}"); + println!("system_reply = {system_reply}"); + println!("writer_echoed = {writer_echoed}"); + println!("notification = {notified:?}"); + if say_echoed && system_reply && writer_echoed && notified.is_none() { + println!("VERDICT: PASS — Horde chat + dot-commands accepted on both send paths"); + Ok(()) + } else { + bail!("VERDICT: FAIL"); + } +} diff --git a/crates/benilla-protocol/examples/make_account.rs b/crates/benilla-protocol/examples/make_account.rs new file mode 100644 index 00000000..442c7052 --- /dev/null +++ b/crates/benilla-protocol/examples/make_account.rs @@ -0,0 +1,35 @@ +//! Dev helper: print SQL to create a vmangos account, computing the SRP6 verifier/salt with +//! `benilla-srp`. vmangos stores `v`/`s` as big-endian hex, while we compute +//! little-endian, so we reverse. Usage: +//! +//! ```text +//! cargo run -p benilla-protocol --example make_account -- \ +//! | docker exec -i mariadb -uroot -ppassword realmd +//! ``` + +use benilla_srp::NormalizedString; + +fn big_endian_hex(little_endian_bytes: &[u8]) -> String { + little_endian_bytes + .iter() + .rev() + .map(|b| format!("{b:02X}")) + .collect() +} + +fn main() { + let args: Vec = std::env::args().collect(); + let user = args.get(1).expect("usage: make_account "); + let pass = args.get(2).expect("usage: make_account "); + + let username = NormalizedString::new(user).expect("invalid username"); + let password = NormalizedString::new(pass).expect("invalid password"); + let (salt, verifier) = benilla_srp::generate_account(&username, &password); + + println!( + "INSERT INTO account (username, v, s, gmlevel, expansion) VALUES ('{}', '{}', '{}', 3, 0);", + user.to_uppercase(), + big_endian_hex(&verifier), + big_endian_hex(&salt), + ); +} diff --git a/crates/benilla-protocol/examples/park_probe.rs b/crates/benilla-protocol/examples/park_probe.rs new file mode 100644 index 00000000..f6ca599b --- /dev/null +++ b/crates/benilla-protocol/examples/park_probe.rs @@ -0,0 +1,66 @@ +//! Live probe (decision 0193): the character-select park behaviors benilla's glue layer rests on. +//! +//! 1) the logout round-trip — login, logout, confirm `SMSG_LOGOUT_COMPLETE`, reconnect, confirm +//! the roster re-serves (the app's drop-and-reconnect relist); +//! 2) idle tolerance — park an authenticated socket at character select for 130 s, then log in +//! (verified 2026-07-07: vmangos does NOT kick a quiet parked socket, so the glue screen needs +//! no keep-alive ping). +//! +//! Needs the local vmangos up; account `two`/`ptwo` (the account-X/password-pX convention). + +use std::time::Duration; + +use benilla_protocol::{logon, WorldSession, WORLD_PORT}; + +fn connect(user: &str, pass: &str) -> anyhow::Result { + let l = logon("localhost", user, pass)?; + let addr = l + .realms + .first() + .map(|r| r.address.clone()) + .unwrap_or_else(|| format!("localhost:{WORLD_PORT}")); + WorldSession::connect(&addr, user, l.session_key) +} + +fn main() -> anyhow::Result<()> { + let (user, pass) = ("two", "ptwo"); + + // ── 1 · logout round-trip ── + let mut s = connect(user, pass)?; + let chars = s.char_enum()?; + println!( + "roster: {:?}", + chars.iter().map(|c| &c.name).collect::>() + ); + let c = &chars[0]; + s.player_login(c.guid)?; + s.set_active_mover(c.guid)?; + println!("logged in as {} — requesting logout in 2s", c.name); + std::thread::sleep(Duration::from_secs(2)); + s.set_read_timeout(Some(Duration::from_secs(1)))?; + s.logout(Duration::from_secs(25))?; + println!("PASS: SMSG_LOGOUT_COMPLETE received"); + drop(s); + + // Fresh cycle re-serves the roster (the app's drop-and-reconnect relist). + let mut s = connect(user, pass)?; + let n = s.char_enum()?.len(); + println!("PASS: post-logout reconnect roster has {n} chars"); + drop(s); + + // ── 2 · idle park ── + let mut s = connect(user, pass)?; + let chars = s.char_enum()?; + println!("parking at character select for 130s…"); + std::thread::sleep(Duration::from_secs(130)); + match s.player_login(chars[0].guid).and_then(|()| { + s.set_active_mover(chars[0].guid)?; + // Prove the world actually streams: wait for any post-login packet. + s.set_read_timeout(Some(Duration::from_secs(5)))?; + s.recv().map(|p| p.name()) + }) { + Ok(pkt) => println!("PASS: idle-parked login works (first packet: {pkt})"), + Err(e) => println!("KICKED: idle park failed login: {e:#} (self-heals via relist)"), + } + Ok(()) +} diff --git a/crates/benilla-protocol/examples/seed_classes.rs b/crates/benilla-protocol/examples/seed_classes.rs new file mode 100644 index 00000000..fa7b1e67 --- /dev/null +++ b/crates/benilla-protocol/examples/seed_classes.rs @@ -0,0 +1,82 @@ +//! Dev helper: seed an account with one character of every vanilla class (`CMSG_CHAR_CREATE` over +//! a live session). Idempotent by **class** — classes the account already has are skipped — so it +//! tops an account up to the full nine. Race per class is a fixed spread that covers all eight +//! races with valid 1.12 pairs; appearance fields ride as zeros (the first options). +//! +//! Both factions on one account needs `AllowTwoSide.Accounts = 1` in mangosd.conf — otherwise the +//! Horde creates (Shaman lives Horde-side) come back `CHAR_CREATE_PVP_TEAMS_VIOLATION` (0x33). +//! +//! ```text +//! cargo run -p benilla-protocol --example seed_classes -- +//! # e.g. … -- one pone One → Onepaladin, Onehunter, … +//! ``` + +use benilla_protocol::{logon, messages, WorldSession, WORLD_PORT}; + +/// (class id, class name, race id, race name, gender): the full vanilla class list with an +/// all-eight-races spread of valid pairs. +const ROSTER: [(u8, &str, u8, &str, u8); 9] = [ + (1, "warrior", 1, "Human", 0), + (2, "paladin", 3, "Dwarf", 0), + (3, "hunter", 4, "Night Elf", 1), + (4, "rogue", 7, "Gnome", 0), + (5, "priest", 8, "Troll", 1), + (7, "shaman", 2, "Orc", 0), + (8, "mage", 5, "Undead", 1), + (9, "warlock", 1, "Human", 1), + (11, "druid", 6, "Tauren", 0), +]; + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + let [user, pass, prefix] = &args[1..] else { + anyhow::bail!("usage: seed_classes "); + }; + + let l = logon("localhost", user, pass)?; + let addr = l + .realms + .first() + .map(|r| r.address.clone()) + .unwrap_or_else(|| format!("localhost:{WORLD_PORT}")); + let mut s = WorldSession::connect(&addr, user, l.session_key)?; + + let have = s.char_enum()?; + println!("account {user}: {} existing character(s)", have.len()); + for (class, class_name, race, race_name, gender) in ROSTER { + if have.iter().any(|c| c.class == class) { + println!(" {class_name}: already present — skipped"); + continue; + } + let name = format!("{prefix}{class_name}"); + let req = messages::CharCreateReq { + name, + race, + class, + gender, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }; + let name = &req.name; + match s.create_character(&req)? { + messages::CHAR_CREATE_SUCCESS => println!(" {class_name}: created {name} ({race_name})"), + messages::CHAR_CREATE_NAME_IN_USE => println!(" {class_name}: name {name} in use — skipped"), + 0x33 => anyhow::bail!( + "{name}: CHAR_CREATE_PVP_TEAMS_VIOLATION — set AllowTwoSide.Accounts = 1 (see module doc)" + ), + other => anyhow::bail!("{name}: create failed, result {other:#x}"), + } + } + + println!("final roster:"); + for c in s.char_enum()? { + println!( + " {} — class {} race {} gender {} level {}", + c.name, c.class, c.race, c.gender, c.level + ); + } + Ok(()) +} diff --git a/crates/benilla-protocol/src/auth.rs b/crates/benilla-protocol/src/auth.rs new file mode 100644 index 00000000..e61fcfba --- /dev/null +++ b/crates/benilla-protocol/src/auth.rs @@ -0,0 +1,245 @@ +//! The realmd (login) wire protocol — 1.12.1 uses **login protocol version 3**. In-repo replacement +//! for `wow_login_messages` (decision 0021), scoped to the three exchanges [`crate::logon`] performs: +//! logon challenge, logon proof, and realm list. Login packets are *not* header-encrypted (unlike the +//! world protocol) and each is self-delimiting, so we read them in request/response order. +//! +//! Opcodes: `CMD_AUTH_LOGON_CHALLENGE = 0x00`, `CMD_AUTH_LOGON_PROOF = 0x01`, `CMD_REALM_LIST = 0x10`. + +use std::io::{Read, Write}; +use std::net::Ipv4Addr; + +use anyhow::{bail, Result}; + +use crate::wire::{read_array, read_cstring, read_f32_le, read_u16_le, read_u32_le, read_u8}; +use crate::RealmInfo; + +const CMD_AUTH_LOGON_CHALLENGE: u8 = 0x00; +const CMD_AUTH_LOGON_PROOF: u8 = 0x01; +const CMD_REALM_LIST: u8 = 0x10; + +const PROTOCOL_VERSION_THREE: u8 = 3; +const GAME_NAME_WOW: u32 = 0x0057_6f57; // "WoW\0" little-endian +const PLATFORM_X86: u32 = 0x0078_3836; // "x86\0" + // The OS/platform tags ride the wire *reversed*: the real client stores them as little-endian u32s, + // so `0x0057696e` writes the bytes `n i W \0`, and realmd reverses them back (`AuthSocket.cpp`: + // `std::reverse(m_os.begin(), m_os.end())`). vmangos accepts exactly "Win" and "OSX" + // (`WorldSocket::HandleAuthSession`) and fails the session on anything else. +const OS_WINDOWS: u32 = 0x0057_696e; // "Win\0" +const OS_MACOS: u32 = 0x004F_5358; // "OSX\0" + +/// The OS tag for the host we are actually running on. We hardcoded Windows on every platform, +/// which is plainly false on a Mac — and servers act on this field: vmangos picks `WardenWin` vs +/// `WardenMac` from it, and realmd validates the build against it (`FindBuildInfo(build, os, +/// platform)`). There was never a 1.12 Linux client, so every non-Mac host keeps saying Windows — +/// the closest true thing we can say. +const fn client_os() -> u32 { + if cfg!(target_os = "macos") { + OS_MACOS + } else { + OS_WINDOWS + } +} +const LOCALE_EN_US: u32 = 0x656e_5553; // "enUS" + +/// A non-success auth result byte from the server (challenge or proof stage), typed so the app can +/// map the code to the client's own `AUTH_*` glue string (decision 0539). Rides [`crate::logon`]'s +/// `anyhow` chain — `err.downcast_ref::()` recovers the code through the contexts. +/// vmangos note (its `AuthCodes.h`, verified): unknown account AND wrong password both arrive as +/// 0x04 (`WOW_FAIL_UNKNOWN_ACCOUNT`) — the client locks out after an 0x05, so the server avoids it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthReject { + /// The grunt result byte (`WOW_FAIL_*`). + pub code: u8, +} + +impl std::fmt::Display for AuthReject { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "server rejected logon: result {:#04x}", self.code) + } +} + +impl std::error::Error for AuthReject {} + +/// The SRP6 inputs from a successful `CMD_AUTH_LOGON_CHALLENGE_Server`. +pub struct ChallengeReply { + pub server_public_key: [u8; 32], + pub generator: u8, + pub large_safe_prime: [u8; 32], + pub salt: [u8; 32], +} + +/// Send `CMD_AUTH_LOGON_CHALLENGE_Client`. `account_name` must already be uppercased (it is what the +/// SRP6 hashes use). `build` is the client build (5875). +pub fn write_logon_challenge( + w: &mut impl Write, + account_name: &str, + build: u16, +) -> std::io::Result<()> { + // Everything after the 2-byte size field, assembled first so we can prefix its length. + let mut body = Vec::with_capacity(34 + account_name.len()); + body.extend_from_slice(&GAME_NAME_WOW.to_le_bytes()); + body.push(1); // version major + body.push(12); // version minor + body.push(1); // version patch + body.extend_from_slice(&build.to_le_bytes()); + body.extend_from_slice(&PLATFORM_X86.to_le_bytes()); + body.extend_from_slice(&client_os().to_le_bytes()); + body.extend_from_slice(&LOCALE_EN_US.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // utc_timezone_offset + body.extend_from_slice(&Ipv4Addr::LOCALHOST.octets()); // client_ip_address (network order) + body.push(account_name.len() as u8); + body.extend_from_slice(account_name.as_bytes()); + + let mut packet = Vec::with_capacity(4 + body.len()); + packet.push(CMD_AUTH_LOGON_CHALLENGE); + packet.push(PROTOCOL_VERSION_THREE); + packet.extend_from_slice(&(body.len() as u16).to_le_bytes()); + packet.extend_from_slice(&body); + w.write_all(&packet) +} + +/// Read the challenge reply, returning the SRP6 inputs (or erroring on a non-success result). +pub fn read_challenge_reply(r: &mut impl Read) -> Result { + let opcode = read_u8(r)?; + if opcode != CMD_AUTH_LOGON_CHALLENGE { + bail!("expected CMD_AUTH_LOGON_CHALLENGE (0x00), got {opcode:#x}"); + } + let _protocol_version = read_u8(r)?; + let result = read_u8(r)?; + if result != 0 { + return Err(AuthReject { code: result }.into()); + } + let server_public_key = read_array::<32>(r)?; + let generator_len = read_u8(r)?; + let mut generator = vec![0u8; generator_len as usize]; + r.read_exact(&mut generator)?; + let generator = match generator.first() { + Some(&g) => g, + None => bail!("server sent an empty generator"), + }; + let prime_len = read_u8(r)? as usize; + let mut prime = vec![0u8; prime_len]; + r.read_exact(&mut prime)?; + let large_safe_prime: [u8; 32] = prime + .as_slice() + .try_into() + .map_err(|_| anyhow::anyhow!("large safe prime was {prime_len} bytes, expected 32"))?; + let salt = read_array::<32>(r)?; + // Consume the rest of the packet so the stream stays aligned for the next message — login packets + // are NOT length-framed, so leftover bytes desync the very next read. crc_salt[16] + security_flag + // (+ the PIN block if set) are unused for the SRP6 calculation but must still be read off. + let _crc_salt = read_array::<16>(r)?; + let security_flag = read_u8(r)?; + if security_flag & 0x01 != 0 { + // PIN: pin_grid_seed (u32) + pin_salt[16]. (vmangos sends security_flag = 0; handled anyway.) + let _pin_grid_seed = read_u32_le(r)?; + let _pin_salt = read_array::<16>(r)?; + } + Ok(ChallengeReply { + server_public_key, + generator, + large_safe_prime, + salt, + }) +} + +/// Send `CMD_AUTH_LOGON_PROOF_Client`. `crc_hash` is the client-binary checksum (vmangos accepts +/// zeros); no telemetry keys, no security flag. +pub fn write_logon_proof( + w: &mut impl Write, + client_public_key: &[u8; 32], + client_proof: &[u8; 20], +) -> std::io::Result<()> { + let mut packet = Vec::with_capacity(1 + 32 + 20 + 20 + 1 + 1); + packet.push(CMD_AUTH_LOGON_PROOF); + packet.extend_from_slice(client_public_key); + packet.extend_from_slice(client_proof); + packet.extend_from_slice(&[0u8; 20]); // crc_hash + packet.push(0); // number_of_telemetry_keys + packet.push(0); // security_flag = None + w.write_all(&packet) +} + +/// Read the proof reply, returning the server's proof `M2` (or erroring on a non-success result). +pub fn read_proof_reply(r: &mut impl Read) -> Result<[u8; 20]> { + let opcode = read_u8(r)?; + if opcode != CMD_AUTH_LOGON_PROOF { + bail!("expected CMD_AUTH_LOGON_PROOF (0x01), got {opcode:#x}"); + } + let result = read_u8(r)?; + if result != 0 { + // A wrong password fails HERE (the server can't verify M1): vmangos answers 0x04. + return Err(AuthReject { code: result }.into()); + } + let server_proof = read_array::<20>(r)?; + let _hardware_survey_id = read_u32_le(r)?; + Ok(server_proof) +} + +/// Send `CMD_REALM_LIST_Client` (opcode + a `u32` padding of 0). +pub fn write_realm_list_request(w: &mut impl Write) -> std::io::Result<()> { + let mut packet = [0u8; 5]; + packet[0] = CMD_REALM_LIST; + w.write_all(&packet) +} + +/// Read `CMD_REALM_LIST_Server` into the advertised realms. +pub fn read_realm_list(r: &mut impl Read) -> Result> { + let opcode = read_u8(r)?; + if opcode != CMD_REALM_LIST { + bail!("expected CMD_REALM_LIST (0x10), got {opcode:#x}"); + } + let _size = read_u16_le(r)?; + let _header_padding = read_u32_le(r)?; + let number_of_realms = read_u8(r)?; + let mut realms = Vec::with_capacity(number_of_realms as usize); + for _ in 0..number_of_realms { + let realm_type = read_u32_le(r)?; + let _flag = read_u8(r)?; + let name = read_cstring(r)?; + let address = read_cstring(r)?; + let population = read_f32_le(r)?; + let characters = read_u8(r)?; + let _category = read_u8(r)?; + let _realm_id = read_u8(r)?; + realms.push(RealmInfo { + name, + address, + population: format!("{population}"), + characters, + realm_type, + }); + } + let _footer_padding = read_u16_le(r)?; // consume so the stream stays aligned (see challenge reply) + Ok(realms) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The OS/platform tags are reversed on the wire (the real client's little-endian u32s), and a + /// server that reads anything but "Win"/"OSX" fails the session — so pin the byte order, not + /// just the constant. Reversing the wire bytes must spell the tag. + #[test] + fn os_tags_reverse_to_the_names_servers_match_on() { + let spelled = |tag: u32| { + let mut b = tag.to_le_bytes(); + b.reverse(); + String::from_utf8(b.iter().copied().filter(|&c| c != 0).collect()).unwrap() + }; + assert_eq!(spelled(OS_WINDOWS), "Win"); + assert_eq!(spelled(OS_MACOS), "OSX"); + assert_eq!(spelled(PLATFORM_X86), "x86"); + } + + /// We advertise the host we actually run on — Windows everywhere except macOS. + #[test] + fn client_os_follows_the_host() { + if cfg!(target_os = "macos") { + assert_eq!(client_os(), OS_MACOS); + } else { + assert_eq!(client_os(), OS_WINDOWS); + } + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-auth.rs b/crates/benilla-protocol/src/bin/benilla-auth.rs new file mode 100644 index 00000000..aac3c120 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-auth.rs @@ -0,0 +1,46 @@ +//! `benilla-auth` — Phase 3 CLI: SRP6 logon against a vanilla realmd, print the realm list. +//! +//! Example: `cargo run --bin benilla-auth -- one pone localhost` + +use anyhow::Result; +use clap::Parser; + +/// Log in to a WoW 1.12.1 auth server and print its realm list. +#[derive(Parser)] +#[command(name = "benilla-auth", version, about)] +struct Cli { + /// Account name. + username: String, + /// Account password. + password: String, + /// Auth (realmd) server host. + #[arg(default_value = "localhost")] + host: String, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let logon = benilla_protocol::logon(&cli.host, &cli.username, &cli.password)?; + + println!( + "authenticated as '{}' (session key {} bytes)", + cli.username, + logon.session_key.len() + ); + if logon.realms.is_empty() { + println!("no realms advertised"); + } + for (i, realm) in logon.realms.iter().enumerate() { + println!( + "[Realm {}] {} — {} @ {} ({} characters)", + i + 1, + realm.name, + realm.population, + realm.address, + realm.characters + ); + } + + Ok(()) +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs new file mode 100644 index 00000000..22fdcf16 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -0,0 +1,547 @@ +//! `benilla-world` — Phase 4 CLI: log in to realmd, connect to the world server (`mangosd`), enter the +//! world as a character, and stream/tally the object updates the server pushes. +//! +//! Example: `cargo run -p benilla-protocol --bin benilla-world -- one pone localhost` +//! +//! If the account has no character yet, one is created (`--create `, Human Warrior) so +//! `CMSG_PLAYER_LOGIN` has something to log in. The realmd host is a positional arg; the world server +//! address defaults to whatever the realm list advertises (override with `--world`). + +mod probes; +mod world; + +use std::collections::BTreeMap; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::messages::{ + CharCreateReq, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS, CLASS_WARRIOR, GENDER_MALE, + RACE_HUMAN, +}; +use benilla_protocol::{decode, EntityKind, WorldSession, WORLD_PORT}; +use clap::Parser; + +use probes::{ + Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, Loot, Probe, QueryNames, Quest, + QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot, Vendor, +}; +use world::{DeathArc, Tracked, World}; + +/// Connect to a WoW 1.12.1 world server, log in a character, and stream object updates. +#[derive(Parser)] +#[command(name = "benilla-world", version, about)] +struct Cli { + /// Account name. + username: String, + /// Account password. + password: String, + /// Auth (realmd) server host. + #[arg(default_value = "localhost")] + host: String, + /// World server address (`host:port`). Defaults to the realm list's advertised address. + #[arg(long)] + world: Option, + /// Character name to create if the account has none. + #[arg(long, default_value = "One")] + create: String, + /// How many seconds to stream packets after entering the world. + #[arg(long, default_value_t = 10)] + seconds: u64, + /// After streaming, walk this many yards forward, then log out and re-enum to confirm the server + /// persisted the new position. Omit to just observe (read-only). + #[arg(long)] + walk: Option, + /// Live-verify the name-query pair: ask our own name (`CMSG_NAME_QUERY`) and the first streamed + /// creature's template name (`CMSG_CREATURE_QUERY`, entry from its guid), and require both answers + /// to arrive and parse. + #[arg(long)] + query_names: bool, + /// Live-verify the spell/action wire: require `SMSG_INITIAL_SPELLS` + `SMSG_ACTION_BUTTONS` to + /// arrive and parse at login, then send one `CMSG_CAST_SPELL` (Battle Shout 6673 if known, else + /// the first known spell) and require an `SMSG_CAST_RESULT` verdict (ok *or* a failure reason — + /// either proves the round trip). + #[arg(long)] + spells: bool, + /// Live-verify the melee-swing wire (decision 0073): GM-teleport (`.go xyz`) onto a Northshire + /// Kobold Vermin spawn, `CMSG_ATTACKSWING` the nearest streamed creature, and require ≥1 + /// `SMSG_ATTACKERSTATEUPDATE` to arrive and decode (attacker/victim/hitInfo/damage). Needs a GM + /// account (the deploy's are gmlevel 3). + #[arg(long)] + attack: bool, + /// Live-verify `CMSG_USE_ITEM`: use the item in this 1-based backpack slot (the wire's bag + /// 255 with player-array slot 23…) and require the server to react — a stack-count values + /// delta or a destroy on that item's guid (consumed), or an explicit cast-result refusal. + #[arg(long)] + use_pack_slot: Option, + /// Send a `/say` line right after entering the world — the GM dot-command channel + /// (`.additem 3732`, `.repairitems`, …) for setting up probe scenarios. Repeatable; the lines go + /// out in order (e.g. `--say ".modify money 5000000" --say ".go creature 1"`). + #[arg(long)] + say: Vec, + /// Live-verify `CMSG_AUTOEQUIP_ITEM`: equip the item in this 1-based backpack slot and + /// require the server to react — a self values delta landing that item's guid in an + /// equipment INV slot, or a decoded `SMSG_INVENTORY_CHANGE_FAILURE` refusal. + #[arg(long)] + equip_pack_slot: Option, + /// Live-verify `CMSG_SWAP_INV_ITEM` (the backpack pick/place/swap wire): swap the two 1-based + /// backpack slots `A:B` (the wire's bag 255, player-array slots 23+A-1 / 23+B-1), await the + /// self values delta, and assert the two slots' item guids exchanged — then swap back and assert + /// the original layout is restored (leaves the character exactly as found). Slot A must be + /// occupied; B may be empty (an empty destination is a move on this wire). + #[arg(long, value_parser = parse_slot_pair)] + swap_pack_slots: Option<(u8, u8)>, + /// Live-verify the vendor wire (decision 0081 phase 4): auto-find the nearest streamed + /// vendor NPC (`UNIT_NPC_FLAG_VENDOR`), `CMSG_LIST_INVENTORY` it and require an + /// `SMSG_LIST_INVENTORY` to arrive and parse (N rows), then `CMSG_BUY_ITEM` the cheapest row and + /// require a reaction — the purchased item arriving (`ItemCreate`/values), the vendor stock + /// updating (`SMSG_BUY_ITEM`), *or* a decoded `SMSG_BUY_FAILED` (all prove the round trip). Also + /// reads our `PLAYER_FIELD_COINAGE` (the money accessor) at login and confirms any coinage delta + /// lands on that field. No GM needed — it uses whatever vendor streams in range. + #[arg(long)] + vendor: bool, + /// Live-verify the solo-loot wire (decision 0084 §1): select a target (nearest streamed + /// creature within range of the `--attack` teleport spot, or `--loot-guid` if given), + /// GM-kill it (`.damage 10000`), wait for `UNIT_DYNFLAG_LOOTABLE`, then `CMSG_LOOT` it, + /// require `SMSG_LOOT_RESPONSE` to arrive and parse, `CMSG_AUTOSTORE_LOOT_ITEM` every row, + /// `CMSG_LOOT_MONEY` if it carried gold, and `CMSG_LOOT_RELEASE` — requiring + /// `SMSG_LOOT_RELEASE_RESPONSE` to close it. Prints every loot-related packet decoded. Needs a + /// GM account (the deploy's `three` is gmlevel 4). + #[arg(long)] + loot: bool, + /// Skip the nearest-creature search for `--loot` and loot this guid directly (decimal or + /// `0x`-prefixed hex). + #[arg(long, value_parser = parse_guid)] + loot_guid: Option, + /// Live-verify the questgiver wire (decision 0088): GM-teleport onto Marshal McBride (Northshire, + /// entry 197), `CMSG_GOSSIP_HELLO` him, `CMSG_QUESTGIVER_QUERY_QUEST` the target quest and require + /// `SMSG_QUESTGIVER_QUEST_DETAILS` to parse, `CMSG_QUESTGIVER_ACCEPT_QUEST` it and confirm the + /// quest id lands in the player descriptor's `PLAYER_QUEST_LOG` fields, GM-complete it + /// (`.quest complete`), then `CMSG_QUESTGIVER_COMPLETE_QUEST` → `_REQUEST_REWARD` → `_CHOOSE_REWARD` + /// requiring `SMSG_QUESTGIVER_QUEST_COMPLETE` (XP/money) + a `PLAYER_FIELD_COINAGE` delta. Uses + /// quest 7 "Kobold Camp Cleanup" (McBride gives + takes it; XP 170, money 25c). Needs a GM + /// account (the deploy's `three` is gmlevel 4). + #[arg(long)] + quest: bool, + /// Live-verify the quest-LOG wire (decision 0109 — the questgiver wire's deferred second + /// slice): GM-teleport onto Marshal McBride (entry 197 — gives *and* takes quest 7 "Kobold + /// Camp Cleanup", VERIFIED live against `mangos.creature_questrelation` / + /// `creature_involvedrelation`, both rows pointing at 197; objective is 10× creature entry 6, + /// `quest_template.ReqCreatureOrGOId1/Count1`), accept quest 7, `CMSG_QUEST_QUERY` it and + /// require `SMSG_QUEST_QUERY_RESPONSE` to parse into the title plus a real + /// (`required_count > 0`) objective — the fat template parser's live golden, distinct from the + /// giver-panel `SMSG_QUESTGIVER_QUEST_DETAILS` [`Self::quest`] already exercises. Then poll the + /// player descriptor for the `PLAYER_QUEST_LOG` slot the accept landed in, GM-complete it + /// (`.quest complete 7`) and require the slot's count-state field to gain the `COMPLETE` state + /// byte, then `CMSG_QUESTLOG_REMOVE_QUEST` that slot and require its id field to clear to `0` + /// (no ack SMSG on this wire — the field clear *is* the confirmation). Needs a GM account (the + /// deploy's `three` is gmlevel 4). + #[arg(long)] + questlog: bool, + /// Live-verify the force-speed-change wire: GM `.modify speed 1.5` (self-targeted), require + /// `SMSG_FORCE_RUN_SPEED_CHANGE` to arrive and parse (flat speed 10.5 = 1.5 × the 7.0 base), + /// ack it (`CMSG_FORCE_RUN_SPEED_CHANGE_ACK` echoing counter + exact speed with our live pose), + /// then `.modify speed 1` and require a SECOND change (counter incremented, speed 7.0) on a + /// still-live stream. A malformed ack body throws in the server's parser and drops the session + /// (the `--charge` precedent), so survival through both round trips is the wire proof. Needs a + /// GM account (the deploy's `three` is gmlevel 4). + #[arg(long)] + speed: bool, + + /// Live-verify the aura wire (decision 0255 phase 1): GM-apply Mark of the Wild (1126, a + /// cancelable buff) and Shadow Word: Pain (589, a periodic-damage DoT, unambiguously negative) + /// to ourselves with explicit durations via `.aura`, then require — from the *live* descriptor + /// — that both land in `UNIT_FIELD_AURA` (proving the field index), in the correct half (buffs + /// 0–31, debuffs 32–47), with the `AURAFLAGS` cancelable nibble bit set only on the buff, the + /// `AURALEVELS` byte equal to our own level, and a stack of 1 (the `count - 1` wire bias). Also + /// requires an `SMSG_UPDATE_AURA_DURATION` for each aura's own slot carrying the duration we + /// asked for, and asserts it arrived **before** the descriptor delta that names the slot (the + /// ordering the aura model depends on). Leaves the character as found (`.unaura` both). Needs a + /// GM account (the deploy's `three` is gmlevel 4). + #[arg(long)] + aura: bool, + + /// Live-verify the questgiver STATUS wire (the overhead `!`/`?` markers' data): + /// teleport onto Marshal McBride, `CMSG_QUESTGIVER_STATUS_QUERY` his guid, and require an + /// `SMSG_QUESTGIVER_STATUS` answer for him — printing the dialog status BEFORE and AFTER a + /// `.quest remove 7` (unaccepted quest 7 should read AVAILABLE=5). Needs a GM account. + #[arg(long)] + giverstatus: bool, + + /// Live-verify the Charge wire (warrior Charge rank 1, spell 100): GM `.learn 100`, teleport to + /// open ground near the Northshire kobold camp ([`CHARGE_TP`]), pick a creature at charge range + /// (8–25 yd), `CMSG_SET_SELECTION` + `CMSG_CAST_SPELL` 100 at it — then require an + /// `SMSG_MONSTER_MOVE` addressed to **our own guid**, proving Charge drives the caster through + /// the same server spline machinery as any creature (not a teleport / not a knockback). Prints + /// the self spline in full (facing kind, duration, waypoints, flying bit) so the ride + the + /// `CMSG_MOVE_SPLINE_DONE` ack it obliges can be built from the real numbers. Needs a GM warrior + /// (the deploy's `three`/Tri, gmlevel 4). + #[arg(long)] + charge: bool, + + /// Live-verify the death-arc slice-1 wire (decision 0308): GM-kill self (`.die`), require the + /// death signals (health force-flushed to 0, the death root), release + /// (`CMSG_REPOP_REQUEST`), require the full ghost transition — unroot, the water-walk grant, + /// `SMSG_CORPSE_RECLAIM_DELAY`, `PLAYER_FLAGS_GHOST`, the corpse object streaming in, the + /// graveyard teleport — query the corpse (`MSG_CORPSE_QUERY`), then GM-revive (`.revive`) to + /// leave the character alive. Recommend `--seconds 45`: the `.die`→repop→revive round trip + /// itself completes in ~10s, but the reclaim delay is only ever observed as a packet value + /// (never awaited), and the slack keeps a slow local server from truncating the ghost phase. + /// Needs a GM account (the deploy's `three` is gmlevel 4). + #[arg(long)] + death: bool, + + /// Live-verify the spirit-healer res + the 25% durability loss's wire (director-reported: + /// "durability still 100% after spirit-healer rez"): GM-repair to a full baseline, die and + /// release (the --death staging), teleport onto the graveyard's Spirit Healer, send + /// `CMSG_SPIRIT_HEALER_ACTIVATE`, and require BOTH the res (the ghost flag clears) and the + /// post-activate `ITEM_FIELD_DURABILITY` values-deltas the loss must push — the exact packets + /// the app's tooltip line feeds from. Recommend `--seconds 45`. Needs a GM account; leaves the + /// character alive and re-repaired. + #[arg(long)] + spirit: bool, +} + +/// Parse a `--loot-guid` value: decimal, or `0x`-prefixed hex (as `benilla-world`'s own `guid +/// {:#x}` printouts read out). +fn parse_guid(s: &str) -> Result { + if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { + u64::from_str_radix(hex, 16).map_err(|e| e.to_string()) + } else { + s.parse::().map_err(|e| e.to_string()) + } +} + +/// Parse a `--swap-pack-slots` value: two 1-based backpack slots as `A:B` (or `A,B`). +fn parse_slot_pair(s: &str) -> Result<(u8, u8), String> { + let (a, b) = s + .split_once([':', ',']) + .ok_or_else(|| format!("expected two slots like '1:2', got '{s}'"))?; + let a: u8 = a.trim().parse().map_err(|e| format!("slot A: {e}"))?; + let b: u8 = b.trim().parse().map_err(|e| format!("slot B: {e}"))?; + if a == 0 || b == 0 || a == b { + return Err("slots are 1-based and must differ".into()); + } + Ok((a, b)) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // 1. realmd logon → session key + realm list (Phase 3). + let logon = benilla_protocol::logon(&cli.host, &cli.username, &cli.password)?; + println!("authenticated as '{}'", cli.username); + + // 2. Resolve the world address: explicit override, else the first advertised realm, else fall + // back to the auth host on the default world port. + let world_addr = match (&cli.world, logon.realms.first()) { + (Some(addr), _) => addr.clone(), + (None, Some(realm)) => { + println!("realm '{}' @ {}", realm.name, realm.address); + realm.address.clone() + } + (None, None) => format!("{}:{}", cli.host, WORLD_PORT), + }; + + // 3. World handshake (CMSG_AUTH_SESSION + header obfuscation). + let mut session = WorldSession::connect(&world_addr, &cli.username, logon.session_key) + .with_context(|| format!("world handshake with {world_addr}"))?; + println!("world handshake OK ({world_addr}) — header obfuscation active"); + + // 4. Character list; create one if empty. + let mut characters = session.char_enum()?; + println!("{} character(s) on the account", characters.len()); + if characters.is_empty() { + println!("creating character '{}' (Human Warrior)…", cli.create); + let req = CharCreateReq { + name: cli.create.clone(), + race: RACE_HUMAN, + class: CLASS_WARRIOR, + gender: GENDER_MALE, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }; + match session.create_character(&req)? { + CHAR_CREATE_SUCCESS | CHAR_CREATE_NAME_IN_USE => {} + other => bail!("character creation failed: result {other:#x}"), + } + characters = session.char_enum()?; + if characters.is_empty() { + bail!("still no characters after creation"); + } + } + let character = &characters[0]; + println!( + "logging in '{}' (guid {}, level {}, map {})", + character.name, character.guid, character.level, character.map, + ); + + // 5. Enter the world, then claim movement control (vmangos ignores MSG_MOVE_* until we're the + // confirmed mover — the real client sends CMSG_SET_ACTIVE_MOVER on login). + session.player_login(character.guid)?; + session.set_active_mover(character.guid)?; + + // 6. Stream packets, tallying opcodes and folding the decoded `SessionEvent`s into a local entity + // map so we can read nearby entities' positions out of the stream. + session.set_read_timeout(Some(Duration::from_secs(2)))?; + let deadline = Instant::now() + Duration::from_secs(cli.seconds); + + // The shared world state every probe reads (identity, entity tracker, item/vendor stores, the + // session-keeping acks); the DeathArc scenario machinery is staged when --death/--spirit is set. + let mut world = World::new(character); + if cli.death || cli.spirit { + world.death_arc = Some(DeathArc::default()); + } + + // The probe registry: one entry per flag, in today's stream-loop/verify block order (so poll, + // on_event, verify, and output order are preserved). Adding a probe is one push here. + let mut probes: Vec> = Vec::new(); + if cli.attack { + probes.push(Box::new(Attack::default())); + } + if cli.charge { + probes.push(Box::new(Charge::default())); + } + if cli.query_names { + probes.push(Box::new(QueryNames::default())); + } + if cli.spells { + probes.push(Box::new(Spells::default())); + } + if let Some(n) = cli.use_pack_slot { + probes.push(Box::new(UsePackSlot { n })); + } + if let Some(n) = cli.equip_pack_slot { + probes.push(Box::new(EquipPackSlot { n })); + } + if let Some((a, b)) = cli.swap_pack_slots { + probes.push(Box::new(SwapPackSlots { a, b })); + } + if cli.vendor { + probes.push(Box::new(Vendor)); + } + if cli.quest { + probes.push(Box::new(Quest)); + } + if cli.aura { + probes.push(Box::new(Aura)); + } + if cli.giverstatus { + probes.push(Box::new(GiverStatus)); + } + if cli.questlog { + probes.push(Box::new(QuestLog)); + } + if cli.loot { + probes.push(Box::new(Loot { + loot_guid: cli.loot_guid, + })); + } + if cli.speed { + probes.push(Box::new(Speed::default())); + } + if cli.death { + probes.push(Box::new(Death::default())); + } + if cli.spirit { + probes.push(Box::new(Spirit::default())); + } + + // Pre-stream staging: the DeathArc first (its `.revive` + teleport lead --spirit's `.repairitems`), + // then every probe's stage in registry order, then the `--say` lines (after all staging, as today). + world.stage(&mut session)?; + for probe in probes.iter_mut() { + let mut cx = Ctx { + session: &mut session, + world: &mut world, + }; + probe.stage(&mut cx)?; + } + for line in &cli.say { + session.send_chat(line)?; + println!("sent chat: {line}"); + } + + println!("streaming world packets for {}s…", cli.seconds); + while Instant::now() < deadline { + world.poll(&mut session)?; + for probe in probes.iter_mut() { + let mut cx = Ctx { + session: &mut session, + world: &mut world, + }; + probe.poll(&mut cx)?; + } + match session.recv() { + Ok(msg) => { + world.tally_packet(&msg); + for ev in decode(msg) { + world.on_event(&ev, &mut session)?; + for probe in probes.iter_mut() { + let mut cx = Ctx { + session: &mut session, + world: &mut world, + }; + probe.on_event(&ev, &mut cx)?; + } + } + } + // Timeout / quiet stream: keep waiting until the deadline. + Err(_) => continue, + } + } + + let total = world.total; + println!("\n--- received {total} packet(s) ---"); + for (opcode, count) in &world.tally { + println!(" {count:>4} {opcode}"); + } + + // Report the entities we decoded, with raw WoW coordinates. + let self_guid = world.self_guid; + println!("\n--- tracked {} entit(ies) ---", world.tracked.len()); + for kind in [ + EntityKind::Player, + EntityKind::Unit, + EntityKind::GameObject, + EntityKind::Other, + ] { + let mut group: Vec<(&u64, &Tracked)> = world + .tracked + .iter() + .filter(|(_, t)| t.kind == kind) + .collect(); + if group.is_empty() { + continue; + } + group.sort_by_key(|(guid, _)| **guid); + println!("{:?} ({}):", kind, group.len()); + for (guid, t) in group.iter().take(8) { + let me = if **guid == self_guid { " (self)" } else { "" }; + println!( + " guid {:<10} pos ({:>9.2}, {:>9.2}, {:>9.2}) o={:>5.2}{}", + guid, t.position[0], t.position[1], t.position[2], t.orientation, me, + ); + } + if group.len() > 8 { + println!(" … and {} more", group.len() - 8); + } + } + + let units = world + .tracked + .values() + .filter(|t| t.kind == EntityKind::Unit) + .count(); + let have_self = world.tracked.contains_key(&self_guid); + if units == 0 && !have_self { + bail!("decoded no positioned entities"); + } + println!( + "\n✅ decode: tracked {} entit(ies) with positions (self {}, {} unit/NPC).", + world.tracked.len(), + if have_self { "found" } else { "missing" }, + units, + ); + + // Post-stream verification: each probe's assertions + follow-up round trips, in registry order. + for probe in probes.iter_mut() { + let mut cx = Ctx { + session: &mut session, + world: &mut world, + }; + probe.verify(&mut cx)?; + } + + // 7. Optional: prove we can drive our own movement server-side. Walk forward, log out (which + // saves the character), then re-enum and compare the persisted position. Terminal — it ends + // the session, so it runs last. + if let Some(yards) = cli.walk { + let self_entity = world.tracked.get(&self_guid); + let start = self_entity.map(|t| t.position).unwrap_or(world.spawn_pos); + let orientation = self_entity.map(|t| t.orientation).unwrap_or(0.0); + println!( + "\nwalking {yards:.1} yd forward from ({:.2}, {:.2}, {:.2})…", + start[0], start[1], start[2] + ); + let end = walk_forward(&mut session, start, orientation, yards)?; + + // Drain a few seconds of server packets to see any reaction (teleport-back / correction). + let drain_until = Instant::now() + Duration::from_secs(3); + let mut reactions: BTreeMap = BTreeMap::new(); + while Instant::now() < drain_until { + if let Ok(msg) = session.recv() { + let name = msg.name(); + if name.contains("MOVE") || name.contains("TELEPORT") || name.contains("FORCE") { + *reactions.entry(name).or_default() += 1; + } + } + } + if reactions.is_empty() { + println!("(no movement reaction packets from server)"); + } else { + println!("server movement reactions: {reactions:?}"); + } + + // Log out (persists the character) and read the saved position back. + session.logout(Duration::from_secs(25))?; + let after = session.char_enum()?; + let saved = after + .iter() + .find(|c| c.guid == self_guid) + .map(|c| c.position) + .context("character vanished after logout")?; + + let moved = ((saved.x - start[0]).powi(2) + + (saved.y - start[1]).powi(2) + + (saved.z - start[2]).powi(2)) + .sqrt(); + println!( + "sent to ({:.2}, {:.2}, {:.2})\nsaved as ({:.2}, {:.2}, {:.2}) → moved {moved:.2} yd", + end[0], end[1], end[2], saved.x, saved.y, saved.z, + ); + if moved < 1.0 { + bail!("server did not persist the move (snapped back?) — moved only {moved:.2} yd"); + } + println!("\n✅ movement: server persisted our movement ({moved:.2} yd)."); + } + + Ok(()) +} + +/// Walk the active player `yards` forward along `orientation`, sending a realistic +/// start→heartbeat→stop sequence at run speed, and return the destination. WoW orientation 0 faces +/// +X; forward is `(cos o, sin o, 0)`. Coordinates are raw WoW yards. +fn walk_forward( + session: &mut WorldSession, + start: [f32; 3], + orientation: f32, + yards: f32, +) -> Result<[f32; 3]> { + const RUN_SPEED: f32 = 7.0; // yd/s, vanilla default run speed + const STEPS: u32 = 8; + + let (dx, dy) = (orientation.cos(), orientation.sin()); + let step_dist = yards / STEPS as f32; + let dt = Duration::from_secs_f32((step_dist / RUN_SPEED).max(0.05)); + + let pos_at = |i: u32| { + [ + start[0] + dx * step_dist * i as f32, + start[1] + dy * step_dist * i as f32, + start[2], + ] + }; + + session.start_forward(start, orientation)?; + for i in 1..STEPS { + std::thread::sleep(dt); + session.heartbeat(pos_at(i), orientation)?; + } + std::thread::sleep(dt); + let end = pos_at(STEPS); + session.stop(end, orientation)?; + Ok(end) +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs new file mode 100644 index 00000000..39e290d2 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/attack.rs @@ -0,0 +1,72 @@ +//! `--attack`: melee-swing wire (decision 0073). GM-teleport onto a Northshire Kobold Vermin, swing +//! the nearest creature, require ≥1 `SMSG_ATTACKERSTATEUPDATE`. + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{guid, EntityKind}; + +use crate::probes::{Ctx, Probe}; +use crate::world::ATTACK_TP; + +#[derive(Default)] +pub(crate) struct Attack { + attack_target: Option, +} + +impl Probe for Attack { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // --loot reuses this teleport spot to guarantee a killable creature in range when no + // explicit --loot-guid was given; avoid sending it twice if both flags are set (the shared + // `attack_tp_staged` flag — attack/loot/DeathArc all key off it). + if !cx.world.attack_tp_staged { + cx.session.send_chat(ATTACK_TP)?; + cx.world.attack_tp_staged = true; + println!("sent GM teleport: {ATTACK_TP}"); + } + Ok(()) + } + + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // Swing at the nearest creature once we've landed (the port repopulates `tracked` around the + // kobold spawn; anything within 20 yd of the landing point is a candidate — the nearest is + // the kobold we landed on, in melee reach). + if self.attack_target.is_none() { + if let Some(pos) = cx.world.attack_pos { + let nearest = cx + .world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .map(|(&g, t)| { + let d = (t.position[0] - pos[0]).hypot(t.position[1] - pos[1]); + (g, d) + }) + .filter(|(_, d)| *d < 20.0) + .min_by(|a, b| a.1.total_cmp(&b.1)); + if let Some((guid, dist)) = nearest { + cx.session.set_selection(guid)?; + cx.session.attack_swing(guid)?; + println!("sent CMSG_ATTACKSWING at {guid:#x} ({dist:.1} yd)"); + self.attack_target = Some(guid); + } + } + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + // --attack verdict: the port must have landed, the swing gone out, and at least one completed + // swing decoded — the full decision-0073 trigger chain against the live server. + if cx.world.attack_pos.is_none() { + bail!("--attack: the GM teleport never arrived (is the account gmlevel ≥ 2?)"); + } + let target = self + .attack_target + .context("--attack: no creature within 20 yd of the landing")?; + let swings_seen = cx.world.swings_seen; + if swings_seen == 0 { + bail!("--attack: swung at {target:#x} but no SMSG_ATTACKERSTATEUPDATE decoded"); + } + println!("✅ attack: {swings_seen} SMSG_ATTACKERSTATEUPDATE swing(s) decoded (target {target:#x})."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs new file mode 100644 index 00000000..1e3a550e --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs @@ -0,0 +1,249 @@ +//! `--aura`: the aura wire (decision 0255 phase 1). GM-apply a buff + a DoT with explicit durations, +//! require both in `UNIT_FIELD_AURA` (correct half, cancelable nibble, level byte, stack bias), and +//! require an `SMSG_UPDATE_AURA_DURATION` per aura ordered BEFORE the descriptor delta that names it. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, ensure, Context, Result}; +use benilla_protocol::{decode, ObjectFields, ServerPacket, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +// --aura: two spells with opposite sign, applied to ourselves by the GM `.aura ` +// command (`ChatHandler::HandleAuraCommand` → `HandleAuraHelper`, `UnitCommands.cpp:997-1051`; +// with no selection it targets the caster — `ChatHandler::GetSelectedUnit`, `Chat.cpp:2621-2622`). +/// Mark of the Wild — positive, and lacks `SPELL_ATTR_NO_AURA_CANCEL`, so the wire's cancelable bit +/// must be set. Lands in the positive half (slots 0–31). +const AURA_BUFF_SPELL: u32 = 1126; +/// Shadow Word: Pain — `SPELL_AURA_PERIODIC_DAMAGE`, unambiguously negative however it is applied. +/// Lands in the negative half (slots 32–47) and must NOT be cancelable. +const AURA_DEBUFF_SPELL: u32 = 589; +const AURA_BUFF_SECONDS: u32 = 300; +/// Short: this one ticks damage on us, and we want it gone even if the cleanup `.unaura` is missed. +const AURA_DEBUFF_SECONDS: u32 = 15; + +pub(crate) struct Aura; + +impl Probe for Aura { + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let self_guid = cx.world.self_guid; + let self_level = cx.world.self_level; + // --aura: live-verify the aura descriptor block + SMSG_UPDATE_AURA_DURATION (decision 0255). + // Nothing here trusts a field index: the probe *searches* the decoded slots for the spell ids it + // asked for, so a wrong `FIELD_UNIT_AURA` fails as "never appeared" rather than passing on + // garbage. + // Start from the LOGIN snapshot, not an empty store: a values delta only carries *changed* + // fields, so a delta-only view would silently hide auras the server restored at login from + // `character_aura` (they persist across logout) — and then misreport which slots are free. + let mut fields = cx.world.self_fields.clone().unwrap_or_default(); + let session = &mut *cx.session; + let dump = |label: &str, f: &ObjectFields| { + println!("\nUNIT_FIELD_AURA {label}:"); + for a in f.unit_auras() { + println!( + " slot {:>2} spell {:>5} flags {:#06b} level {:>2} stacks {} ({}{})", + a.slot, + a.spell_id, + a.flags, + a.level, + a.stacks, + if a.is_helpful() { "buff" } else { "debuff" }, + if a.is_cancelable() { + ", cancelable" + } else { + "" + }, + ); + } + }; + dump("at login (restored from character_aura)", &fields); + + // Clean slate, so a re-run measures a fresh apply and not a leftover refresh. `.unaura` + // zeroes the slot, which reaches us as an explicit `0` in the next delta. + session.send_chat(&format!(".unaura {AURA_BUFF_SPELL}"))?; + session.send_chat(&format!(".unaura {AURA_DEBUFF_SPELL}"))?; + let settle = Instant::now() + Duration::from_secs(3); + while Instant::now() < settle { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid, fields: d } = ev { + if guid == self_guid { + fields.merge(d); + } + } + } + } + dump("after .unaura of both probe spells", &fields); + // Whatever survives is not ours to touch (a warrior's Battle Stance, 2457, permanently owns + // slot 0). Nothing may report a duration for these while we watch: the server sends + // `SMSG_UPDATE_AURA_DURATION` only on apply/refresh, and never at all for a permanent aura — + // which is precisely the reference's "until cancelled" (an occupied slot with no timer). + let untouched: Vec = fields.unit_auras().map(|a| a.slot).collect(); + + println!("\nGM: .aura {AURA_BUFF_SPELL} {AURA_BUFF_SECONDS} (Mark of the Wild)"); + println!("GM: .aura {AURA_DEBUFF_SPELL} {AURA_DEBUFF_SECONDS} (Shadow Word: Pain)"); + session.send_chat(&format!(".aura {AURA_BUFF_SPELL} {AURA_BUFF_SECONDS}"))?; + session.send_chat(&format!(".aura {AURA_DEBUFF_SPELL} {AURA_DEBUFF_SECONDS}"))?; + + // Record the arrival ORDER of the first duration packet against the values delta that first + // names the buff's spell in its slot — both indexed by PACKET arrival, the axis the ordering + // claim is actually about. `SMSG_UPDATE_AURA_DURATION` has no `SessionEvent` yet (nothing in + // the app consumes it until phase 2), so the probe reads the `ServerPacket` directly. + let mut durations: Vec<(u8, u32)> = Vec::new(); + let (mut seq, mut first_duration_at, mut buff_field_at) = (0usize, None, None); + let drain_until = Instant::now() + Duration::from_secs(8); + while Instant::now() < drain_until { + let Ok(msg) = session.recv() else { continue }; + seq += 1; + if let ServerPacket::UpdateAuraDuration { slot, remaining_ms } = &msg { + let (slot, remaining_ms) = (*slot, *remaining_ms); + println!(" SMSG_UPDATE_AURA_DURATION slot {slot} → {remaining_ms} ms"); + first_duration_at.get_or_insert(seq); + durations.push((slot, remaining_ms)); + continue; + } + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid, fields: d } = ev { + if guid == self_guid { + fields.merge(d); + if fields.unit_auras().any(|a| a.spell_id == AURA_BUFF_SPELL) { + buff_field_at.get_or_insert(seq); + } + } + } + } + } + + let auras: Vec<_> = fields.unit_auras().collect(); + dump("after both .aura applies", &fields); + + let buff = auras + .iter() + .find(|a| a.spell_id == AURA_BUFF_SPELL) + .copied() + .context( + "--aura: spell 1126 never appeared in UNIT_FIELD_AURA. Either the descriptor field \ + index is wrong, or the GM `.aura` command didn't land (needs gmlevel >= 2).", + )?; + let debuff = auras + .iter() + .find(|a| a.spell_id == AURA_DEBUFF_SPELL) + .copied() + .context("--aura: spell 589 never appeared in UNIT_FIELD_AURA")?; + + // The halves, the nibble, the level byte, the stack bias — each a distinct packing claim. + ensure!( + buff.is_helpful() && buff.slot < 32, + "buff landed in the debuff half (slot {})", + buff.slot + ); + ensure!( + !debuff.is_helpful() && debuff.slot >= 32, + "debuff landed in the buff half (slot {})", + debuff.slot + ); + ensure!( + buff.is_cancelable(), + "AFLAG_CANCELABLE clear on a positive, cancelable buff (flags {:#x})", + buff.flags + ); + ensure!( + !debuff.is_cancelable(), + "AFLAG_CANCELABLE set on a debuff (flags {:#x})", + debuff.flags + ); + ensure!( + buff.level == self_level && debuff.level == self_level, + "AURALEVELS byte should be the caster's level {}: got buff {} / debuff {}", + self_level, + buff.level, + debuff.level + ); + ensure!( + buff.stacks == 1 && debuff.stacks == 1, + "stack bias wrong (the wire byte is count-1): got buff {} / debuff {}", + buff.stacks, + debuff.stacks + ); + + // Durations: keyed by slot, carrying what we asked for. `.aura` sets an exact duration, and + // the packet is sent immediately, so allow only for a tick of decay. + for (label, aura, asked) in [ + ("buff", buff, AURA_BUFF_SECONDS), + ("debuff", debuff, AURA_DEBUFF_SECONDS), + ] { + let ms = durations + .iter() + .find(|(slot, _)| *slot == aura.slot) + .map(|&(_, ms)| ms) + .with_context(|| { + format!( + "--aura: no SMSG_UPDATE_AURA_DURATION for the {label}'s own slot {} \ + (durations seen: {durations:?})", + aura.slot + ) + })?; + let asked_ms = asked * 1000; + ensure!( + ms <= asked_ms && ms + 2000 >= asked_ms, + "{label} duration {ms} ms is not the {asked_ms} ms we asked for" + ); + println!("✅ {label}: slot {} ⇒ {ms} ms", aura.slot); + } + + // The ordering the aura model rests on: the timer reaches us before the slot is named. + let (d, v) = ( + first_duration_at.context("--aura: no duration packet at all")?, + buff_field_at.context("--aura: the buff never appeared in a values delta")?, + ); + ensure!( + d < v, + "SMSG_UPDATE_AURA_DURATION arrived AFTER the descriptor delta (seq {d} vs {v}) — \ + decision 0255's slot-keyed buffering is built on the opposite order" + ); + println!("✅ duration packet precedes the descriptor delta (event {d} before {v})"); + + // Durations are apply/refresh EDGES, not a stream — the client counts down locally from + // here (the cast bar's model). An untouched slot must never have reported one. + if let Some(&slot) = untouched + .iter() + .find(|s| durations.iter().any(|(d, _)| d == *s)) + { + bail!( + "--aura: slot {slot} reported a duration without being (re)applied — durations are \ + not the apply/refresh edges decision 0255's client-side countdown assumes" + ); + } + println!( + "✅ no duration for the {} untouched slot(s) {untouched:?} — permanent auras are \ + 'until cancelled', and durations are apply/refresh edges, not a stream", + untouched.len() + ); + + // Leave the character as found. The drain matters: `character_aura` persists across logout, + // so exiting before the server processes these would save the probe's buffs onto the char. + session.send_chat(&format!(".unaura {AURA_BUFF_SPELL}"))?; + session.send_chat(&format!(".unaura {AURA_DEBUFF_SPELL}"))?; + let settle = Instant::now() + Duration::from_secs(3); + while Instant::now() < settle { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid, fields: d } = ev { + if guid == self_guid { + fields.merge(d); + } + } + } + } + dump("after cleanup", &fields); + ensure!( + !fields + .unit_auras() + .any(|a| a.spell_id == AURA_BUFF_SPELL || a.spell_id == AURA_DEBUFF_SPELL), + "--aura: cleanup left a probe aura on the character" + ); + + println!("\n✅ --aura PASS: the aura block decodes and the duration wire is slot-keyed."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs new file mode 100644 index 00000000..aad30f08 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/charge.rs @@ -0,0 +1,145 @@ +//! `--charge`: warrior Charge (spell 100). GM `.learn 100`, teleport near the kobold camp, cast +//! Charge at a creature 8–25 yd out, and require an `SMSG_MONSTER_MOVE` for our OWN guid (Charge is +//! a self spline) — then ack the spline (`CMSG_MOVE_SPLINE_DONE`) and prove the stream survives. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{guid, EntityKind, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +/// The `--charge` probe spot: open ground ~18 yd west of the kobold camp ([`crate::world::ATTACK_TP`]), +/// so the camp's kobolds stream in at charge range (8–25 yd) while we land *outside* the immediate +/// aggro cluster — Charge refuses to fire while the caster is in combat, so we must cast before a +/// kobold closes and engages us. +const CHARGE_TP: &str = ".go xyz -8798.71 -164.568 81.94"; + +#[derive(Default)] +pub(crate) struct Charge { + charge_target: Option, + /// The pending self-spline ack: (endpoint, splineId, when-to-send). Captured from the self + /// `SMSG_MONSTER_MOVE`, sent after the ride's duration elapses — the round-trip that proves our + /// `CMSG_MOVE_SPLINE_DONE` wire is right (a malformed body drops the session). + charge_ack: Option<([f32; 3], u32, Instant)>, + charge_acked: bool, + /// `World::total` snapshotted when the ack went out; `verify` reads `total - total_at_ack` as the + /// "packets after the ack" count (the old per-packet `msgs_after_ack` tally, exactly). + total_at_ack: Option, +} + +impl Probe for Charge { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + cx.session.send_chat(".learn 100")?; // Charge rank 1 (GM: teach it to the warrior) + cx.session.send_chat(CHARGE_TP)?; + println!("sent GM: .learn 100 (Charge); teleport {CHARGE_TP}"); + Ok(()) + } + + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // Fire Charge at a creature in range once we've landed. Charge needs the target 8–25 yd out, + // so pick the *farthest* streamed creature inside that band (maximises the odds of a valid + // range, and keeps clear of a kobold already on top of us). One shot — the first packet the + // server sends back for our own guid is the whole proof. + if self.charge_target.is_none() { + if let Some(pos) = cx.world.attack_pos { + let pick = cx + .world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .map(|(&g, t)| { + ( + g, + t.position, + (t.position[0] - pos[0]).hypot(t.position[1] - pos[1]), + ) + }) + .filter(|(_, _, d)| (8.0..=25.0).contains(d)) + .max_by(|a, b| a.2.total_cmp(&b.2)); + if let Some((guid, tpos, dist)) = pick { + // Face the target first — Charge refuses a target that isn't in front (reason 124, + // SPELL_FAILED_UNIT_NOT_INFRONT). Report our facing with a Stop at the landing spot + // (WoW orientation = atan2(Δy, Δx)), then select + cast. + let orientation = (tpos[1] - pos[1]).atan2(tpos[0] - pos[0]); + cx.session.stop(pos, orientation)?; + cx.session.set_selection(guid)?; + cx.session.cast_spell(100, Some(guid))?; + println!( + "sent CMSG_CAST_SPELL 100 (Charge) at {guid:#x} ({dist:.1} yd, faced)" + ); + self.charge_target = Some(guid); + } + } + } + // Ack the self-spline once its ride would have finished (`CMSG_MOVE_SPLINE_DONE` at the + // endpoint). The server holds a player mover as spline-pending until this arrives; a surviving + // stream afterward is the live proof the ack wire parses. + if let Some((endpoint, spline_id, at)) = self.charge_ack { + if !self.charge_acked && Instant::now() >= at { + cx.session.move_spline_done(endpoint, 0.0, spline_id)?; + println!( + "sent CMSG_MOVE_SPLINE_DONE (splineId {spline_id}) at endpoint ({:.1}, {:.1}, {:.1})", + endpoint[0], endpoint[1], endpoint[2] + ); + self.charge_acked = true; + self.total_at_ack = Some(cx.world.total); + } + } + Ok(()) + } + + fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { + if let SessionEvent::MonsterMove { + guid, + spline_id, + path, + stop, + duration_ms, + .. + } = ev + { + // Queue the SPLINE_DONE ack for once the ride would have finished: + // the endpoint is the last waypoint; send after `duration_ms` (+ a + // margin) so we don't ack a spline the server still thinks is running. + if *guid == cx.world.self_guid && self.charge_ack.is_none() && !*stop { + if let Some(&endpoint) = path.last() { + let at = Instant::now() + Duration::from_millis(u64::from(*duration_ms) + 200); + self.charge_ack = Some((endpoint, *spline_id, at)); + } + } + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + // --charge verdict: the port must have landed, a target been found in range and Charge cast, and + // — the whole point — the server must have driven us with an SMSG_MONSTER_MOVE for our OWN guid. + if cx.world.attack_pos.is_none() { + bail!("--charge: the GM teleport never arrived (is the account gmlevel ≥ 2?)"); + } + let target = self + .charge_target + .context("--charge: no creature streamed in charge range [8,25] yd to cast at")?; + let self_moves = cx.world.self_moves; + if self_moves == 0 { + bail!("--charge: cast Charge at {target:#x} but the server sent NO SMSG_MONSTER_MOVE for our guid (check the CAST_RESULT reason above — combat / range / stance)"); + } + println!("✅ charge: {self_moves} self SMSG_MONSTER_MOVE — Charge drives the caster via a server spline (target {target:#x})."); + // The ack half: we sent CMSG_MOVE_SPLINE_DONE at the endpoint; a malformed body throws in the + // server's parser and drops us, so a stream that keeps flowing afterward is the live proof. + if self.charge_acked { + let msgs_after_ack = cx.world.total + - self + .total_at_ack + .expect("total_at_ack is set together with charge_acked"); + if msgs_after_ack == 0 { + bail!("--charge: sent CMSG_MOVE_SPLINE_DONE but the stream went silent afterward — the server may have rejected/dropped us (malformed ack body?)"); + } + println!("✅ charge ack: server accepted CMSG_MOVE_SPLINE_DONE — stream continued ({msgs_after_ack} packets after the ack)."); + } else { + println!("⚠️ charge ack: never sent (no self spline endpoint captured to ack)."); + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs new file mode 100644 index 00000000..d42149dc --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/death.rs @@ -0,0 +1,205 @@ +//! `--death`: the death-arc slice-1 wire (decision 0308). The shared [`crate::world::DeathArc`] runs +//! the die → release → ghost transition; this probe adds the corpse query, the self-revive cleanup, +//! and the post-revive not-found re-query, then asserts the whole arc in wire order. + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{EntityKind, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +#[derive(Default)] +pub(crate) struct Death { + corpse_query_sent: bool, + corpse_answer: Option<(bool, i32, [f32; 3], u32)>, + revive_sent: bool, + /// The post-revive corpse re-query — the app's marker-drop mechanism (the server's own + /// "corpse gone" push is LOOTER-gated, vmangos Map.cpp:3617-3629, so a PvE res never sends + /// one; the client must re-ask and hear not-found). Director-reported: the map tombstone + /// survived a spirit-healer res before this was mirrored. + post_revive_query_sent: bool, + corpse_gone: Option, + corpse_create: Option<(u64, [f32; 3])>, +} + +impl Probe for Death { + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // Once we're a released ghost AND the graveyard teleport has landed, ask where our corpse + // is — the corpse-run marker's own source (decision 0308 §5). + if !self.corpse_query_sent { + if let Some(arc) = &cx.world.death_arc { + if arc.ghost_seen && arc.graveyard_pos.is_some() { + cx.session.corpse_query()?; + println!("sent MSG_CORPSE_QUERY"); + self.corpse_query_sent = true; + } + } + } + // Once the corpse-query answer is in, GM-revive so the round trip leaves the character + // alive (cleanup, not part of the wire under test — a real player would corpse-run + + // CMSG_RECLAIM_CORPSE instead). + if !self.revive_sent && self.corpse_answer.is_some() { + cx.session.send_chat(".revive")?; + println!("sent GM: .revive (self-revive)"); + self.revive_sent = true; + if let Some(arc) = &mut cx.world.death_arc { + arc.revive_initiated = true; + } + } + // Once revived, re-ask where the corpse is: the answer MUST be not-found (the corpse + // unbinds at SpawnCorpseBones) — the round trip the app's unghost-edge marker drop rides. + if !self.post_revive_query_sent { + if let Some(arc) = &cx.world.death_arc { + if arc.revived_seen { + cx.session.corpse_query()?; + println!("sent MSG_CORPSE_QUERY (post-revive — expecting not-found)"); + self.post_revive_query_sent = true; + } + } + } + Ok(()) + } + + fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { + match ev { + SessionEvent::ObjectCreate { + guid, + kind, + position, + .. + } => { + // --death: the corpse streams in as an `Other`-classified create once + // we've released (decision 0308 §4). No HIGHGUID_CORPSE classifier + // exists in `guid.rs` (a corpse carries no template entry to extract + // from its guid, unlike a creature/GameObject), so identify it + // positionally instead: the only other wire type `entity_kind` folds + // into `Other` is a DynamicObject (ground-effect markers), and nothing + // casts one in this scenario — so any `Other` create arriving after + // repop is the corpse. + let repop_sent = cx.world.death_arc.as_ref().is_some_and(|a| a.repop_sent); + if repop_sent && self.corpse_create.is_none() && *kind == EntityKind::Other { + self.corpse_create = Some((*guid, *position)); + println!( + "corpse object streamed: guid {guid:#x} pos ({:.1}, {:.1}, {:.1})", + position[0], position[1], position[2] + ); + } + } + SessionEvent::CorpseQuery { + found, + display_map, + position, + corpse_map, + } => { + if self.post_revive_query_sent { + self.corpse_gone = Some(!*found); + println!("MSG_CORPSE_QUERY (post-revive): found={found}"); + } else { + self.corpse_answer = Some((*found, *display_map, *position, *corpse_map)); + println!( + "MSG_CORPSE_QUERY: found={found} display_map={display_map} pos=({:.1}, {:.1}, {:.1}) corpse_map={corpse_map}", + position[0], position[1], position[2] + ); + } + } + _ => {} + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let self_map = cx.world.self_map; + let arc = cx + .world + .death_arc + .as_ref() + .expect("death_arc present when --death is set"); + + // --death verdict (decision 0308 slice 1): every signal in the release→ghost→corpse arc, in + // wire order — `bail!` on the first one that never arrived, so a partial run points straight + // at the phase that broke. + let death_pos = arc.death_pos.context( + "--death: `.die` never dropped our health to 0 — is the account gmlevel ≥ 2?", + )?; + if !arc.rooted_seen { + bail!("--death: no SMSG_FORCE_MOVE_ROOT at death"); + } + if !arc.unroot_seen { + bail!("--death: no SMSG_FORCE_MOVE_UNROOT after CMSG_REPOP_REQUEST"); + } + if !arc.water_walk_seen { + bail!("--death: no SMSG_MOVE_WATER_WALK — the ghost's walk-on-water grant"); + } + let reclaim_delay_ms = arc + .reclaim_delay_ms + .context("--death: no SMSG_CORPSE_RECLAIM_DELAY at release")?; + if !arc.ghost_seen { + bail!("--death: PLAYER_FLAGS_GHOST (bit 0x10, field 190) never set"); + } + // Full 3D distance: the corpse/graveyard/query checks below all compare against where we + // died, and elevation is as telling as the ground plane (a graveyard directly below/above + // death, or a corpse object mis-z'd, are both real wire bugs — not noise to filter out). + let dist3 = |a: [f32; 3], b: [f32; 3]| { + ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() + }; + let (corpse_guid, corpse_pos) = self + .corpse_create + .context("--death: the corpse object never streamed / streamed at the wrong spot")?; + let corpse_dist = dist3(corpse_pos, death_pos); + if corpse_dist > 10.0 { + bail!( + "--death: the corpse object never streamed / streamed at the wrong spot ({corpse_dist:.1} yd from where we died)" + ); + } + let graveyard_pos = arc + .graveyard_pos + .context("--death: no graveyard teleport after release")?; + let graveyard_dist = dist3(graveyard_pos, death_pos); + if graveyard_dist <= 20.0 { + bail!( + "--death: no graveyard teleport after release (landed only {graveyard_dist:.1} yd from where we died)" + ); + } + let (found, _display_map, query_pos, query_map) = self + .corpse_answer + .context("--death: MSG_CORPSE_QUERY answered not-found / wrong spot")?; + let query_dist = dist3(query_pos, death_pos); + if !found || query_dist > 10.0 || query_map != self_map { + bail!( + "--death: MSG_CORPSE_QUERY answered not-found / wrong spot (found={found}, {query_dist:.1} yd from death, map {query_map} vs {})", + self_map + ); + } + if !arc.revived_seen { + bail!( + "--death: `.revive` never cleared the ghost flag — the character may be left dead: run with --say \".revive\" to clean up" + ); + } + let corpse_gone = self.corpse_gone; + if corpse_gone != Some(true) { + bail!( + "--death: the post-revive corpse query still finds a corpse (got {corpse_gone:?}) — the app's unghost-edge re-query would leave the map tombstone standing" + ); + } + + println!("\n✅ DEATH ARC SLICE-1 WIRE VERIFIED:"); + println!( + " died at ({:.1}, {:.1}, {:.1})", + death_pos[0], death_pos[1], death_pos[2] + ); + println!(" root/unroot SMSG_FORCE_MOVE_ROOT + SMSG_FORCE_MOVE_UNROOT acked"); + println!(" water-walk SMSG_MOVE_WATER_WALK acked"); + println!(" reclaim delay {reclaim_delay_ms} ms"); + println!(" ghost flag PLAYER_FLAGS_GHOST set at release, cleared at revive"); + println!( + " corpse guid {corpse_guid:#x} pos ({:.1}, {:.1}, {:.1}) ({corpse_dist:.1} yd from death)", + corpse_pos[0], corpse_pos[1], corpse_pos[2] + ); + println!( + " graveyard ({:.1}, {:.1}, {:.1}) ({graveyard_dist:.1} yd from death)", + graveyard_pos[0], graveyard_pos[1], graveyard_pos[2] + ); + println!(" corpse query found, map {query_map} ({query_dist:.1} yd from death)"); + println!(" revive PLAYER_FLAGS_GHOST cleared — character alive"); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs new file mode 100644 index 00000000..b2dd2884 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/equip_pack_slot.rs @@ -0,0 +1,100 @@ +//! `--equip-pack-slot`: `CMSG_AUTOEQUIP_ITEM` on a 1-based backpack slot; require a server reaction +//! (the guid landing in an equipment INV slot, or a decoded refusal), then swap it home. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +pub(crate) struct EquipPackSlot { + pub(crate) n: u8, +} + +impl Probe for EquipPackSlot { + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let n = self.n; + + // --equip-pack-slot: auto-equip a real bag item and require a server reaction. + let sf = world + .self_fields + .as_mut() + .context("no self descriptor — can't find the pack slot")?; + let n0 = n.checked_sub(1).context("pack slots are 1-based")?; + let guid = sf + .player_pack_slot(n0) + .filter(|g| *g != 0) + .with_context(|| format!("pack slot {n} is empty/unsent"))?; + let entry = world.item_entries.get(&guid).copied().unwrap_or(0); + println!( + "\nequipping pack slot {n} (guid {guid:#x}, entry {entry}) → CMSG_AUTOEQUIP_ITEM bag 255 slot {}", + 23 + n0 + ); + session.auto_equip_item(255, 23 + n0)?; + let drain_until = Instant::now() + Duration::from_secs(5); + let mut verdict: Option = None; + // The equipment INV slot the item landed in (Some once equipped) — so we can restore it. + let mut equipped_slot: Option = None; + while Instant::now() < drain_until && verdict.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::ObjectValues { guid: g, fields } if g == world.self_guid => { + sf.merge(fields); + if let Some(slot) = (0..19).find(|&i| sf.player_inv_slot(i) == Some(guid)) { + equipped_slot = Some(slot); + verdict = Some(format!( + "equipped — the guid landed in equipment INV slot {slot}" + )); + } + } + SessionEvent::InventoryFailure { + reason, + required_level, + .. + } => { + verdict = Some(format!( + "server refused (InventoryResult {reason:#04x}, level req {required_level:?})" + )); + } + _ => {} + } + } + } + let v = verdict.context("no reaction to CMSG_AUTOEQUIP_ITEM within 5s")?; + println!("✅ auto-equip: {v}."); + + // Restore: if we actually equipped it, SWAP_INV_ITEM the equipment slot back to the source + // backpack slot — moves the item home (and whatever it displaced back into place), leaving + // the character exactly as found. (The wire's both-slots-on-the-player addressing spans + // equipment 0-18 ↔ backpack 23-38.) + if let Some(slot) = equipped_slot { + println!( + "restoring: CMSG_SWAP_INV_ITEM equipment slot {slot} → backpack slot {}", + 23 + n0 + ); + session.swap_inv_item(slot, 23 + n0)?; + let drain_until = Instant::now() + Duration::from_secs(5); + let mut restored = false; + while Instant::now() < drain_until && !restored { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == world.self_guid { + sf.merge(fields); + } + } + } + restored = sf.player_pack_slot(n0) == Some(guid); + } + if !restored { + bail!("equip restore failed: the item didn't return to backpack slot {n}"); + } + println!("✅ equip restore: the item is back in backpack slot {n} (character left as found)."); + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs new file mode 100644 index 00000000..ff8768ca --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/giverstatus.rs @@ -0,0 +1,82 @@ +//! `--giverstatus`: the questgiver STATUS wire (the overhead `!`/`?` markers' data). Teleport onto +//! Marshal McBride, `CMSG_QUESTGIVER_STATUS_QUERY` him, require an `SMSG_QUESTGIVER_STATUS` answer. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use benilla_protocol::{decode, guid, EntityKind, SessionEvent}; + +use crate::probes::{Ctx, Probe, QUESTLOG_ID, QUEST_TURNIN_ENTRY, QUEST_TURNIN_TP}; + +pub(crate) struct GiverStatus; + +impl Probe for GiverStatus { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // Same cleanup-then-teleport pattern as --quest (idempotent across re-runs); McBride is + // both giver and ender for quest 7, so one teleport (already onto him) suffices. Shared with + // --questlog: the `mcbride_staged` flag makes the two GM lines go out exactly once for a + // co-run, matching today's single `cli.questlog || cli.giverstatus` staging block. + if !cx.world.mcbride_staged { + cx.session + .send_chat(&format!(".quest remove {QUESTLOG_ID}"))?; + cx.session.send_chat(QUEST_TURNIN_TP)?; + cx.world.mcbride_staged = true; + println!( + "sent GM: .quest remove {QUESTLOG_ID}; teleport onto McBride {QUEST_TURNIN_TP}" + ); + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + + // --giverstatus: live-verify the questgiver STATUS wire — the overhead `!`/`?` markers' + // data plane (CMSG_QUESTGIVER_STATUS_QUERY → SMSG_QUESTGIVER_STATUS, vmangos + // QuestHandler.cpp:36-77). Quest 7 was `.quest remove`d in the preamble, so McBride should + // answer AVAILABLE(5) for a fresh log. + let mcbride = world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .find(|(g, _)| guid::entry(**g) == Some(QUEST_TURNIN_ENTRY)) + .map(|(&g, _)| g) + .context( + "--giverstatus: Marshal McBride (entry 197) didn't stream — did the GM teleport \ + land? (needs gmlevel >= 2; try a longer --seconds)", + )?; + println!("\nMarshal McBride: guid {mcbride:#x}"); + println!("CMSG_QUESTGIVER_STATUS_QUERY({mcbride:#x})"); + session.questgiver_status_query(mcbride)?; + let mut got: Option = None; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && got.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::QuestGiverStatus { npc, status } = ev { + if npc == mcbride { + got = Some(status); + } + } + } + } + let status = got.context("--giverstatus: no SMSG_QUESTGIVER_STATUS within 5s")?; + println!( + "✅ dialog status: {status} ({})", + match status { + 0 => "NONE", + 1 => "UNAVAILABLE", + 2 => "CHAT", + 3 => "INCOMPLETE — white ?", + 4 => "REWARD_REP — gold ?", + 5 => "AVAILABLE — gold !", + 6 => "REWARD_OLD — gold ?", + 7 => "REWARD2 — gold ?", + _ => "unknown", + } + ); + println!("\n✅ --giverstatus PASS: the marker wire answers."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs new file mode 100644 index 00000000..b99d87e8 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/loot.rs @@ -0,0 +1,386 @@ +//! `--loot`: the solo-loot wire (decision 0084 §1). Select a target, GM-kill it, wait for the +//! lootable dynamic flag, teleport onto the corpse, `CMSG_LOOT`, AUTOSTORE every row, LOOT_MONEY if +//! it carried gold, then LOOT_RELEASE — printing every loot-related packet decoded. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, guid, EntityKind, SessionEvent}; + +use crate::probes::{Ctx, Probe}; +use crate::world::ATTACK_TP; + +pub(crate) struct Loot { + pub(crate) loot_guid: Option, +} + +impl Probe for Loot { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // --loot reuses the --attack teleport spot to guarantee a killable creature in range when no + // explicit --loot-guid was given; avoid sending it twice if both flags are set (the shared + // `attack_tp_staged` flag — attack/loot/DeathArc all key off it). + if self.loot_guid.is_none() && !cx.world.attack_tp_staged { + cx.session.send_chat(ATTACK_TP)?; + cx.world.attack_tp_staged = true; + println!("sent GM teleport: {ATTACK_TP}"); + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let self_guid = world.self_guid; + + // --loot: kill + loot a creature end-to-end (decision 0084 §1). Select the target (explicit + // --loot-guid, or the nearest streamed creature to the --attack teleport spot), GM-kill it + // (a GM `.damage` acts on the selected unit regardless of range), wait for the corpse's + // lootable dynamic flag, GM-teleport directly onto the (now-stationary) corpse — `--attack`'s + // ~20yd melee-swing search is far looser than the actual loot-range check + // (`Player::GetMaxLootDistance`, VERIFIED vmangos `Player.cpp:15472-15476`, a tight + // combat-reach distance), and Northshire's mobs wander enough that "nearest at landing" is + // routinely just outside it by the time the kill lands — then CMSG_LOOT it, decode the + // response, AUTOSTORE every row, LOOT_MONEY if it carried gold, then LOOT_RELEASE. Prints + // every loot-related packet decoded. + // Candidate targets: the explicit --loot-guid, or the nearest few streamed creatures. A + // list, not one — the nearest creature can be a corpse a previous run already looted + // (dead until respawn, so `.damage` does nothing and it never goes lootable); trying the + // next-nearest instead makes back-to-back probe runs reliable. + let candidates: Vec = if let Some(g) = self.loot_guid { + vec![g] + } else { + let pos = world + .attack_pos + .context("--loot: the GM teleport never landed (is the account gmlevel >= 2?)")?; + let mut by_distance: Vec<(u64, f32)> = world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .map(|(&g, t)| { + let d = (t.position[0] - pos[0]).hypot(t.position[1] - pos[1]); + (g, d) + }) + .collect(); + by_distance.sort_by(|a, b| a.1.total_cmp(&b.1)); + by_distance.truncate(3); + by_distance.iter().map(|(g, _)| *g).collect() + }; + if candidates.is_empty() { + bail!("--loot: no creature streamed in (try --loot-guid)"); + } + + // Kill candidates until one shows the corpse's lootable dynamic flag (UNIT_DYNFLAG_LOOTABLE, + // bit 0x1 — VERIFIED vmangos `SharedDefines.h:1153`). + let mut target = 0u64; + let mut lootable = false; + for &candidate in &candidates { + println!( + "\n--loot target: guid {candidate:#x} — selecting + GM-killing (.damage 10000)" + ); + session.set_selection(candidate)?; + session.send_chat(".damage 10000")?; + let drain_until = Instant::now() + Duration::from_secs(10); + while Instant::now() < drain_until && !lootable { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = &ev { + if *g == candidate && fields.unit_dynamic_flags() & 0x1 != 0 { + lootable = true; + } + } + } + } + if lootable { + target = candidate; + break; + } + println!( + "{candidate:#x} never went lootable within 10s (already-looted corpse, or no loot \ + table) — trying the next candidate…" + ); + } + if !lootable { + bail!( + "--loot: none of {} candidate(s) went lootable (all already-looted corpses, or the \ + .damage command didn't land — is the account a GM?)", + candidates.len() + ); + } + println!("✅ {target:#x} is lootable (UNIT_DYNFLAG_LOOTABLE set)"); + + // Reposition directly onto the (now-stationary) corpse before looting — see the block + // comment above for why "nearest at landing" isn't reliably in loot range. The tracked + // position itself can lag where the creature actually died (a mid-spline kill reports the + // spline's last waypoint, not the death spot), and `GetMaxLootDistance` is combat-reach + // tight — so on a TOO_FAR refusal, drain (refreshing tracked positions) and retry with the + // updated spot instead of failing the run on instrument flakiness. + let mut response: Option<(u8, u32, Vec)> = None; + let mut loot_refusal: Option = None; + for attempt in 1..=3 { + if let Some(corpse_pos) = world.tracked.get(&target).map(|t| t.position) { + let tp = format!( + ".go xyz {:.2} {:.2} {:.2}", + corpse_pos[0], corpse_pos[1], corpse_pos[2] + ); + session.send_chat(&tp)?; + println!("sent GM teleport onto the corpse: {tp}"); + let mut landed = false; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && !landed { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::Teleport { + guid: g, counter, .. + } = ev + { + if g == self_guid { + session.teleport_ack(g, counter)?; + landed = true; + } + } + } + } + if !landed { + bail!("--loot: the corpse-repositioning teleport never landed"); + } + } + + println!("sending CMSG_LOOT"); + loot_refusal = None; + session.loot(target)?; + + // Drain for SMSG_LOOT_RESPONSE (either shape), keeping tracked positions fresh so a + // retry teleports to the corpse's *corrected* spot. + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && response.is_none() && loot_refusal.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::LootResponse { + guid: g, + loot_type, + gold, + items, + } if g == target => { + println!( + "SMSG_LOOT_RESPONSE: guid {g:#x} type {loot_type} gold {gold} {} item(s)", + items.len() + ); + for it in &items { + println!( + " slot {:>2} item {:>6} x{:<3} display {:>6} randProp {:>4} slotType {}", + it.slot, + it.item_id, + it.count, + it.display_info_id, + it.random_property_id, + it.slot_type + ); + } + response = Some((loot_type, gold, items)); + } + SessionEvent::LootError { guid: g, error } if g == target => { + println!("SMSG_LOOT_RESPONSE (error): guid {g:#x} error {error}"); + loot_refusal = Some(error); + } + SessionEvent::ObjectMove { + guid: g, + position, + orientation, + } => { + if let Some(t) = world.tracked.get_mut(&g) { + t.position = position; + t.orientation = orientation; + } + } + _ => {} + } + } + } + match loot_refusal { + Some(error) + if error == benilla_protocol::messages::loot_error::TOO_FAR && attempt < 3 => + { + println!("LOOT_ERROR TOO_FAR — refreshing the corpse position and retrying…"); + } + _ => break, + } + } + if let Some(error) = loot_refusal { + bail!("--loot: {target:#x} answered LOOT_ERROR {error} instead of opening the window"); + } + let (loot_type, gold, items) = + response.context("--loot: no SMSG_LOOT_RESPONSE arrived within 5s")?; + + // AUTOSTORE every row, watching for the LOOT_REMOVED + ITEM_PUSH_RESULT + bag ItemCreate + // each one drives. + let mut removed_slots: Vec = Vec::new(); + let mut pushes: Vec = Vec::new(); + let mut items_created: Vec = Vec::new(); + for it in &items { + session.autostore_loot_item(it.slot)?; + println!( + "sent CMSG_AUTOSTORE_LOOT_ITEM slot {} (item {})", + it.slot, it.item_id + ); + let drain_until = Instant::now() + Duration::from_secs(5); + let mut this_removed = false; + let mut this_pushed = false; + while Instant::now() < drain_until && !(this_removed && this_pushed) { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::LootRemoved { slot } if slot == it.slot => { + println!("SMSG_LOOT_REMOVED: slot {slot}"); + removed_slots.push(slot); + this_removed = true; + } + SessionEvent::ItemPushResult(p) if p.item_entry == it.item_id => { + println!( + "SMSG_ITEM_PUSH_RESULT: entry {} x{} bagSlot {} itemSlot {:#x} from_npc {} created {}", + p.item_entry, p.count, p.bag_slot, p.item_slot, p.from_npc, p.created + ); + pushes.push(p); + this_pushed = true; + } + SessionEvent::ItemCreate { + guid: g, fields, .. + } if fields.object_entry() == Some(it.item_id) => { + println!("ItemCreate: guid {g:#x} entry {}", it.item_id); + items_created.push(g); + } + _ => {} + } + } + } + if !this_removed { + bail!( + "--loot: no SMSG_LOOT_REMOVED for slot {} within 5s", + it.slot + ); + } + if !this_pushed { + bail!( + "--loot: no SMSG_ITEM_PUSH_RESULT for item {} within 5s", + it.item_id + ); + } + } + println!( + "✅ loot items: {} row(s) taken, {} ITEM_PUSH_RESULT(s), {} bag ItemCreate(s).", + removed_slots.len(), + pushes.len(), + items_created.len() + ); + + // LOOT_MONEY, only if the response actually carried gold. + // + // Live-verified departure from the pin: this vmangos build never sends + // SMSG_LOOT_MONEY_NOTIFY for a solo (ungrouped) looter — `LootHandler.cpp`'s + // `HandleLootMoneyOpcode` comments out `player->SendLootMoneyNotify(pLoot->gold)` for the + // non-group branch ("in wotlk and after this should be sent for solo looting too") and + // applies the money silently via `Player::LootMoney` (a `PLAYER_FIELD_COINAGE` delta) + // instead. Only `SMSG_LOOT_CLEAR_MONEY` fires. So the pass criterion here is CLEAR_MONEY + + // a coinage delta on our own guid, with the notify event accepted too (group loot, or a + // future server that un-comments it). + // + // `NotifyMoneyRemoved` (CLEAR_MONEY) sends immediately; `LootMoney`'s + // `PLAYER_FIELD_COINAGE` write reaches us on the ordinary dirty-field broadcast tick, not + // synchronously with CLEAR_MONEY. NOTE the loot flow guarantees *unrelated* self values + // updates in the same window (the server toggles `UNIT_FLAG_LOOTING` on us at + // `SendLoot`/release) — so "a self values update arrived" is NOT "the coinage delta + // arrived"; only an actual change of `player_money()` counts (the original drain exited on + // the flags-only update, read an unchanged purse, and mis-reported the money as lost — + // the DB was always correct). + if gold > 0 { + let money_before = world.self_fields.as_ref().and_then(|sf| sf.player_money()); + println!( + "sending CMSG_LOOT_MONEY (gold {gold}); PLAYER_FIELD_COINAGE before = {money_before:?}" + ); + session.loot_money()?; + let mut notify: Option = None; + let mut cleared = false; + let mut money_after: Option = None; + let drain_until = Instant::now() + Duration::from_secs(15); + while Instant::now() < drain_until + && !(cleared && (notify.is_some() || money_after.is_some())) + { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::LootMoneyNotify { amount } => { + println!("SMSG_LOOT_MONEY_NOTIFY: amount {amount}"); + notify = Some(amount); + } + SessionEvent::LootClearMoney => { + println!("SMSG_LOOT_CLEAR_MONEY"); + cleared = true; + } + SessionEvent::ObjectValues { guid: g, fields } if g == self_guid => { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields); + // Only a *changed* purse is the coinage delta — the loot flow also + // pushes flags-only self updates (UNIT_FLAG_LOOTING), and treating + // those as "the money arrived" mis-reads an unchanged purse as lost. + if sf.player_money() != money_before { + money_after = sf.player_money(); + } + } + } + _ => {} + } + } + } + if !cleared { + bail!("--loot: no SMSG_LOOT_CLEAR_MONEY within 5s"); + } + match (money_before, money_after) { + (Some(before), Some(after)) if after > before => { + println!( + "✅ loot money: PLAYER_FIELD_COINAGE {before} → {after} copper (+{}), coin line cleared{}.", + after - before, + notify + .map(|n| format!(", SMSG_LOOT_MONEY_NOTIFY amount {n}")) + .unwrap_or_default() + ); + } + _ => { + let amount = notify.context( + "--loot: no PLAYER_FIELD_COINAGE delta and no SMSG_LOOT_MONEY_NOTIFY \ + within 15s — the money never reached us", + )?; + println!( + "✅ loot money: SMSG_LOOT_MONEY_NOTIFY amount {amount}, coin line cleared." + ); + } + } + } else { + println!("(no gold on this corpse — skipping CMSG_LOOT_MONEY)"); + } + + // RELEASE. + println!("sending CMSG_LOOT_RELEASE"); + session.loot_release(target)?; + let mut released = false; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && !released { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::LootReleaseResponse { guid: g } = ev { + if g == target { + println!("SMSG_LOOT_RELEASE_RESPONSE: guid {g:#x}"); + released = true; + } + } + } + } + if !released { + bail!("--loot: no SMSG_LOOT_RELEASE_RESPONSE within 5s"); + } + println!( + "\n✅ loot: killed {target:#x}, loot_type {loot_type}, {} item(s) stored, gold {gold}, window released.", + items.len() + ); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs new file mode 100644 index 00000000..4a60f509 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/mod.rs @@ -0,0 +1,94 @@ +//! The probe registry: one file per `--flag` scenario, each a self-contained [`Probe`] with a +//! uniform lifecycle. Registration order (in `main`) = today's stream-loop/verify block order, so +//! output and execution order are preserved. + +use anyhow::Result; +use benilla_protocol::{SessionEvent, WorldSession}; + +use crate::world::World; + +/// What a [`Probe`]'s lifecycle methods borrow: the live session (to send) + the shared [`World`]. +pub(crate) struct Ctx<'a> { + pub session: &'a mut WorldSession, + pub world: &'a mut World, +} + +/// One scripted wire-verification scenario. Registered from a `--flag`; the pump drives every +/// registered probe through this lifecycle in registration order. +pub(crate) trait Probe { + /// One-time pre-stream staging (GM teleports, cleanup commands). + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + let _ = cx; + Ok(()) + } + /// Every pump iteration, before recv. + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + let _ = cx; + Ok(()) + } + /// Every decoded event, AFTER [`World::on_event`] has processed it. + fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { + let _ = (ev, cx); + Ok(()) + } + /// Post-stream: assertions + follow-up round trips (each block's bespoke drains move verbatim). + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let _ = cx; + Ok(()) + } +} + +// Quest consts shared by more than one probe (put here rather than `pub(super)` in quest.rs +// because giverstatus/questlog re-use them and neither is quest.rs's child). + +/// Marshal McBride's teleport spot — the `--quest` turn-in NPC, and the `--questlog`/`--giverstatus` +/// stage target (McBride is quest 7's giver *and* ender). +pub(crate) const QUEST_TURNIN_TP: &str = ".go xyz -8902.59 -162.606 82.0223"; // onto Marshal McBride +pub(crate) const QUEST_TURNIN_ENTRY: u32 = 197; // Marshal McBride — takes 783 +/// `PLAYER_QUEST_LOG_1_1` UpdateField index for 1.12.1 (UNIT_END 188 + 0xA), 3 fields per slot × +/// 20 slots (vmangos `UpdateFields_1_12_1.h:128`). +pub(crate) const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198; + +/// The `--questlog` probe target: quest 7 "Kobold Camp Cleanup" — Marshal McBride (entry 197, the +/// same NPC [`QUEST_TURNIN_TP`]/[`QUEST_TURNIN_ENTRY`] already land on) is *both* giver and ender +/// (VERIFIED live against the running vmangos: `mangos.creature_questrelation` and +/// `creature_involvedrelation` each carry exactly one row for quest 7, both `197`). Its one +/// objective — kill 10 of creature entry 6 (Kobold) — is a real `required_count > 0` slot, unlike +/// 783's report-only "A Threat Within" the `--quest` probe turns in; a GM `.quest complete` +/// substitutes for the grind, same as `--quest`. Shared with `--giverstatus`, which `.quest remove`s +/// it in staging so McBride reads AVAILABLE for a fresh log. +pub(crate) const QUESTLOG_ID: u32 = 7; + +mod attack; +mod aura; +mod charge; +mod death; +mod equip_pack_slot; +mod giverstatus; +mod loot; +mod query_names; +mod quest; +mod questlog; +mod speed; +mod spells; +mod spirit; +mod swap_pack_slots; +mod use_pack_slot; +mod vendor; + +pub(crate) use attack::Attack; +pub(crate) use aura::Aura; +pub(crate) use charge::Charge; +pub(crate) use death::Death; +pub(crate) use equip_pack_slot::EquipPackSlot; +pub(crate) use giverstatus::GiverStatus; +pub(crate) use loot::Loot; +pub(crate) use query_names::QueryNames; +pub(crate) use quest::Quest; +pub(crate) use questlog::QuestLog; +pub(crate) use speed::Speed; +pub(crate) use spells::Spells; +pub(crate) use spirit::Spirit; +pub(crate) use swap_pack_slots::SwapPackSlots; +pub(crate) use use_pack_slot::UsePackSlot; +pub(crate) use vendor::Vendor; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs new file mode 100644 index 00000000..64277e86 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/query_names.rs @@ -0,0 +1,70 @@ +//! `--query-names`: the name-query pair. Ask our own name (`CMSG_NAME_QUERY`) at staging and the +//! first streamed creature's template name (`CMSG_CREATURE_QUERY`), require both to arrive and parse. + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{guid, EntityKind}; + +use crate::probes::{Ctx, Probe}; + +#[derive(Default)] +pub(crate) struct QueryNames { + creature_asked: bool, +} + +impl Probe for QueryNames { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + cx.session.name_query(cx.world.self_guid)?; + println!( + "sent CMSG_NAME_QUERY for self (guid {})", + cx.world.self_guid + ); + Ok(()) + } + + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // Ask the first streamed creature for its template name, once. + if !self.creature_asked { + if let Some((&guid, _)) = cx + .world + .tracked + .iter() + .find(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + { + let entry = guid::entry(guid).expect("creature guid carries its entry"); + cx.session.creature_query(entry, guid)?; + println!("sent CMSG_CREATURE_QUERY for entry {entry} (guid {guid:#x})"); + self.creature_asked = true; + } + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + // --query-names verdict: both answers must have arrived and parsed (the creature one only if a + // creature streamed in at all). + let own = cx + .world + .player_name_answer + .clone() + .context("no SMSG_NAME_QUERY_RESPONSE for our own guid")?; + if !own.eq_ignore_ascii_case(&cx.world.self_name) { + bail!( + "name query answered '{own}', expected '{}'", + cx.world.self_name + ); + } + match (self.creature_asked, &cx.world.creature_name_answer) { + (true, Some((entry, Some(name)))) => { + println!("✅ query: self = '{own}', creature entry {entry} = '{name}'."); + } + (true, Some((entry, None))) => { + bail!("creature query for entry {entry} answered 'unknown entry'") + } + (true, None) => bail!("no SMSG_CREATURE_QUERY_RESPONSE arrived"), + (false, _) => { + println!("✅ query: self = '{own}' (no creature streamed in range to ask about)."); + } + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs new file mode 100644 index 00000000..7142bd87 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/quest.rs @@ -0,0 +1,281 @@ +//! `--quest`: accept + turn in a real quest end-to-end (decision 0088). GM-teleport onto Deputy +//! Willem, run the full questgiver wire (details → accept → complete → request/offer reward → choose +//! reward → quest-complete), verified against `PLAYER_QUEST_LOG` + `PLAYER_XP`. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use benilla_protocol::{decode, guid, EntityKind, SessionEvent, WorldSession}; + +use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1, QUEST_TURNIN_ENTRY, QUEST_TURNIN_TP}; + +/// A per-event handler passed to the `--quest` probe's `drain_quest` pump: it inspects each decoded +/// [`SessionEvent`] and returns `Some(msg)` to stop the drain early (the match landed) or `None` to +/// keep pumping. +type QuestEventHandler = Box Option>; + +/// The `--quest` probe target: Northshire's opening quest 783 "A Threat Within" — no prerequisite, +/// no kill/collect objective (a "report to McBride" quest, so it's completable the instant it's +/// accepted, no GM objective-grind needed). Given by Deputy Willem (entry 823, at +/// -8933.5 -136.5 83.4) and turned in at Marshal McBride (entry 197, at -8902.6 -162.6 82.0) — both +/// a few yards apart in the Abbey, so one teleport streams both. Rewards 40 XP (no money), so the +/// grant is verified on `PLAYER_XP`. +const QUEST_GIVER_TP: &str = ".go xyz -8933.54 -136.523 83.4466"; // onto Deputy Willem +const QUEST_GIVER_ENTRY: u32 = 823; // Deputy Willem — gives 783 +const QUEST_ID: u32 = 783; + +pub(crate) struct Quest; + +impl Probe for Quest { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // Clean slate so the accept is a real fresh accept (idempotent across probe re-runs), then + // teleport onto the giver (Deputy Willem) so he streams AND is in interaction range (the + // accept's CanInteractWithQuestGiver distance gate — ~5yd — is stricter than QUERY_QUEST). + // McBride streams too (~30yd away, within object-stream range); the turn-in teleports onto + // him later. + cx.session.send_chat(&format!(".quest remove {QUEST_ID}"))?; + cx.session.send_chat(QUEST_GIVER_TP)?; + println!("sent GM: .quest remove {QUEST_ID}; teleport onto Willem {QUEST_GIVER_TP}"); + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let self_guid = world.self_guid; + + // --quest: accept + turn in a real quest end-to-end (decision 0088). McBride streamed in after + // the QUEST_TP; run the full questgiver wire against him. + // A short blocking drain helper: pump packets up to `secs`, folding self-descriptor deltas + // into `self_fields`, and hand each decoded event to `f`; stop early when `f` returns Some. + let drain_quest = |session: &mut WorldSession, + sf: &mut Option, + secs: u64, + mut f: QuestEventHandler| + -> Option { + let until = Instant::now() + Duration::from_secs(secs); + while Instant::now() < until { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = &ev { + if *g == self_guid { + if let Some(sf) = sf.as_mut() { + sf.merge(fields.clone()); + } + } + } + if let Some(done) = f(&ev) { + return Some(done); + } + } + } + None + }; + + // Find the giver (Deputy Willem) + the turn-in NPC (Marshal McBride) among the streamed + // creatures (each guid carries its template entry in bits 24–47). Both stand in the Abbey a + // few yards from the teleport spot. + let find_npc = |entry: u32| { + world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .find(|(g, _)| guid::entry(**g) == Some(entry)) + .map(|(&g, _)| g) + }; + let willem = find_npc(QUEST_GIVER_ENTRY).context( + "--quest: Deputy Willem (entry 823) didn't stream — did the GM teleport land? \ + (needs gmlevel >= 2; try a longer --seconds)", + )?; + let mcbride = find_npc(QUEST_TURNIN_ENTRY) + .context("--quest: Marshal McBride (entry 197) didn't stream in range")?; + println!("\nDeputy Willem: guid {willem:#x}; Marshal McBride: guid {mcbride:#x}"); + + // 1) Look at the quest at the giver: QUERY_QUEST → DETAILS. + println!("giver: CMSG_QUESTGIVER_HELLO + CMSG_QUESTGIVER_QUERY_QUEST({QUEST_ID})"); + session.questgiver_hello(willem)?; + session.questgiver_query_quest(willem, QUEST_ID)?; + let details = drain_quest( + session, + &mut world.self_fields, + 5, + Box::new(|ev| match ev { + SessionEvent::QuestDetail(d) if d.quest_id == QUEST_ID => Some(format!( + "SMSG_QUESTGIVER_QUEST_DETAILS: \"{}\" — {} choice / {} fixed reward(s), {}c money", + d.title, + d.choices.len(), + d.rewards.len(), + d.money + )), + _ => None, + }), + ) + .context("--quest: no SMSG_QUESTGIVER_QUEST_DETAILS for quest 783 within 5s")?; + println!("✅ details: {details}"); + + // 2) Accept at the giver, then confirm the quest id landed in a PLAYER_QUEST_LOG slot of our + // descriptor (id field 198 + 3·i, over 20 slots). Poll ~6s — the values delta can trail the + // GOSSIP_COMPLETE that closes the interaction. + println!("giver: CMSG_QUESTGIVER_ACCEPT_QUEST"); + session.questgiver_accept_quest(willem, QUEST_ID)?; + let quest_in_log = |sf: &Option| { + sf.as_ref().is_some_and(|sf| { + (0..20).any(|i| { + sf.raw_fields().any(|(idx, val)| { + idx == FIELD_PLAYER_QUEST_LOG_1_1 + 3 * i && val == QUEST_ID + }) + }) + }) + }; + let mut in_log = false; + for _ in 0..6 { + drain_quest(session, &mut world.self_fields, 1, Box::new(|_| None)); + if quest_in_log(&world.self_fields) { + in_log = true; + break; + } + } + if in_log { + println!("✅ accept: quest {QUEST_ID} is in the player descriptor's PLAYER_QUEST_LOG (field-confirmed)"); + } else { + // The quest-log UpdateFields are the deferred log slice; a solo accept's low-priority + // values delta may not stream inside the poll window. Not fatal — the turn-in below can + // only produce QUEST_COMPLETE if the accept took, so it's the authoritative check. + println!( + "ℹ️ accept: quest {QUEST_ID} not observed in a PLAYER_QUEST_LOG field within the poll \ + window (the log UpdateFields are a deferred slice); the turn-in below is authoritative" + ); + } + + // 3) Mark the quest COMPLETE server-side. "A Threat Within" is a talk-to-McBride quest — its + // status stays INCOMPLETE until the ender interaction, and CHOOSE_REWARD's CanRewardQuest + // gate requires COMPLETE — so GM-complete it (fair per the brief; grinding is impractical + // and this one has no grindable objective anyway). Print the server's reply and let it apply. + session.send_chat(&format!(".quest complete {QUEST_ID}"))?; + println!("GM: .quest complete {QUEST_ID} (letting it apply…)"); + drain_quest( + session, + &mut world.self_fields, + 2, + Box::new(|ev| { + if let SessionEvent::Chat(m) = ev { + println!(" server: {}", m.text); + } + None + }), + ); + + // Teleport onto McBride so the turn-in is in interaction range (his guid is already known + // from the giver-spot stream; only our position needs to change). Drain until the port acks. + session.send_chat(QUEST_TURNIN_TP)?; + println!("moving to McBride: {QUEST_TURNIN_TP}"); + drain_quest(session, &mut world.self_fields, 3, Box::new(|_| None)); + + // Turn in at McBride: COMPLETE_QUEST → REQUEST_ITEMS(complete) or straight to OFFER_REWARD. + println!("turn-in: CMSG_QUESTGIVER_HELLO + CMSG_QUESTGIVER_COMPLETE_QUEST({QUEST_ID})"); + session.questgiver_hello(mcbride)?; + session.questgiver_complete_quest(mcbride, QUEST_ID)?; + let progress = drain_quest( + session, + &mut world.self_fields, + 5, + Box::new(|ev| match ev { + SessionEvent::QuestProgress(p) if p.quest_id == QUEST_ID => Some(format!( + "SMSG_QUESTGIVER_REQUEST_ITEMS: \"{}\" — {} required item(s), complete={}", + p.title, + p.required_items.len(), + p.is_complete + )), + SessionEvent::QuestOffer(o) if o.quest_id == QUEST_ID => Some(format!( + "SMSG_QUESTGIVER_OFFER_REWARD direct (no required items) for \"{}\"", + o.title + )), + _ => None, + }), + ) + .context("--quest: no REQUEST_ITEMS/OFFER_REWARD after COMPLETE_QUEST within 5s")?; + println!("✅ progress: {progress}"); + + // 4) Reach the reward panel. When the quest has required items the progress panel came back + // above; its Continue → REQUEST_REWARD → OFFER_REWARD. When it has none (783), COMPLETE_QUEST + // *already* answered with OFFER_REWARD, so the reward panel is reached — skip REQUEST_REWARD. + if !progress.contains("OFFER_REWARD") { + println!("turn-in: CMSG_QUESTGIVER_REQUEST_REWARD"); + session.questgiver_request_reward(mcbride, QUEST_ID)?; + let offer = drain_quest( + session, + &mut world.self_fields, + 5, + Box::new(|ev| match ev { + SessionEvent::QuestOffer(o) if o.quest_id == QUEST_ID => Some(format!( + "SMSG_QUESTGIVER_OFFER_REWARD: \"{}\" — {} choice / {} fixed reward(s), {}c money", + o.title, + o.choices.len(), + o.rewards.len(), + o.money + )), + _ => None, + }), + ) + .context("--quest: no SMSG_QUESTGIVER_OFFER_REWARD after REQUEST_REWARD within 5s")?; + println!("✅ offer: {offer}"); + } else { + println!( + "✅ offer: reward panel already reached (OFFER_REWARD direct — no required items)" + ); + } + + // 5) Finish: CHOOSE_REWARD (index 0 — no choice rewards) → QUEST_COMPLETE + the XP grant via + // UPDATE_OBJECT. Quest 783 rewards 40 XP (no money), so the grant is verified on PLAYER_XP. + let xp_before = world.self_fields.as_ref().and_then(|sf| sf.player_xp()); + println!("turn-in: CMSG_QUESTGIVER_CHOOSE_REWARD (choice 0)"); + session.questgiver_choose_reward(mcbride, QUEST_ID, 0)?; + let complete = drain_quest( + session, + &mut world.self_fields, + 6, + Box::new(|ev| match ev { + SessionEvent::QuestComplete(c) if c.quest_id == QUEST_ID => Some(format!( + "SMSG_QUESTGIVER_QUEST_COMPLETE: quest {} — {} XP, {} copper, {} item(s)", + c.quest_id, + c.xp, + c.money, + c.items.len() + )), + // Diagnostics: the server re-offers the reward if it rejected the choice, or offers + // the next quest in the chain (783 → 7) after a successful reward. + SessionEvent::QuestOffer(o) => { + eprintln!(" (diag) OFFER_REWARD again for quest {}", o.quest_id); + None + } + SessionEvent::QuestDetail(d) => { + eprintln!(" (diag) next-quest DETAILS for quest {}", d.quest_id); + None + } + SessionEvent::QuestGiverStatus { status, .. } => { + eprintln!(" (diag) QUESTGIVER_STATUS {status}"); + None + } + _ => None, + }), + ) + .context("--quest: no SMSG_QUESTGIVER_QUEST_COMPLETE after CHOOSE_REWARD within 6s")?; + println!("✅ complete: {complete}"); + + // Pump a moment more so the XP grant's values delta lands, then verify PLAYER_XP moved. + drain_quest(session, &mut world.self_fields, 2, Box::new(|_| None)); + let xp_after = world.self_fields.as_ref().and_then(|sf| sf.player_xp()); + match (xp_before, xp_after) { + (Some(b), Some(a)) if a != b => println!( + "✅ reward XP: PLAYER_XP {b} → {a} (+{}) — the grant landed on the descriptor", + a as i64 - b as i64 + ), + (before, after) => println!( + "ℹ️ reward XP: PLAYER_XP {before:?} → {after:?} (a level-up resets XP to the \ + into-level remainder, so an equal/lower value can still be a real grant)" + ), + } + println!("\n✅ --quest PASS: full accept (Willem) → turn-in (McBride) loop over the questgiver wire."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs new file mode 100644 index 00000000..cb3bad08 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/questlog.rs @@ -0,0 +1,278 @@ +//! `--questlog`: the quest-LOG wire (decision 0109). Accept quest 7 at McBride, `CMSG_QUEST_QUERY` +//! it (the fat template parser's live golden), poll the `PLAYER_QUEST_LOG` slot, GM-complete it and +//! require the COMPLETE state byte, then `CMSG_QUESTLOG_REMOVE_QUEST` and require the id field to clear. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, guid, EntityKind, SessionEvent}; + +use crate::probes::{ + Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1, QUESTLOG_ID, QUEST_TURNIN_ENTRY, QUEST_TURNIN_TP, +}; + +const QUESTLOG_TITLE: &str = "Kobold Camp Cleanup"; + +pub(crate) struct QuestLog; + +impl Probe for QuestLog { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // Same cleanup-then-teleport pattern as --quest (idempotent across re-runs); McBride is + // both giver and ender for quest 7, so one teleport (already onto him) suffices. Shared with + // --giverstatus: the `mcbride_staged` flag makes the two GM lines go out exactly once for a + // co-run, matching today's single `cli.questlog || cli.giverstatus` staging block. + if !cx.world.mcbride_staged { + cx.session + .send_chat(&format!(".quest remove {QUESTLOG_ID}"))?; + cx.session.send_chat(QUEST_TURNIN_TP)?; + cx.world.mcbride_staged = true; + println!( + "sent GM: .quest remove {QUESTLOG_ID}; teleport onto McBride {QUEST_TURNIN_TP}" + ); + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let self_guid = world.self_guid; + + // --questlog: live-verify the quest-LOG wire (decision 0109) against quest 7 "Kobold Camp + // Cleanup" — McBride streamed in and we're in interaction range from the preamble teleport. + let mcbride = world + .tracked + .iter() + .filter(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + .find(|(g, _)| guid::entry(**g) == Some(QUEST_TURNIN_ENTRY)) + .map(|(&g, _)| g) + .context( + "--questlog: Marshal McBride (entry 197) didn't stream — did the GM teleport \ + land? (needs gmlevel >= 2; try a longer --seconds)", + )?; + println!("\nMarshal McBride: guid {mcbride:#x}"); + + // 1) Query + accept quest 7 at McBride (he's both giver and ender — copies --quest's + // hello → query → accept flow, minus the separate turn-in NPC). + println!("giver: CMSG_QUESTGIVER_HELLO + CMSG_QUESTGIVER_QUERY_QUEST({QUESTLOG_ID})"); + session.questgiver_hello(mcbride)?; + session.questgiver_query_quest(mcbride, QUESTLOG_ID)?; + let mut giver_title: Option = None; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && giver_title.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::QuestDetail(d) = ev { + if d.quest_id == QUESTLOG_ID { + giver_title = Some(d.title); + } + } + } + } + let giver_title = giver_title + .context("--questlog: no SMSG_QUESTGIVER_QUEST_DETAILS for quest 7 within 5s")?; + println!("✅ giver details: \"{giver_title}\""); + + println!("giver: CMSG_QUESTGIVER_ACCEPT_QUEST"); + session.questgiver_accept_quest(mcbride, QUESTLOG_ID)?; + // Let the accept settle (its values delta can trail the GOSSIP_COMPLETE that closes the + // interaction) before asking for the template/slot below. + let drain_until = Instant::now() + Duration::from_secs(3); + while Instant::now() < drain_until { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == self_guid { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields); + } + } + } + } + } + + // 2) The template: CMSG_QUEST_QUERY → SMSG_QUEST_QUERY_RESPONSE — the fat parser's live + // golden (hand-built fixtures already cover the wire traps; this proves the real byte + // stream). Require the title and at least one real (required_count > 0) objective. + println!("CMSG_QUEST_QUERY({QUESTLOG_ID})"); + session.quest_query(QUESTLOG_ID)?; + let mut template: Option = None; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && template.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = &ev { + if *g == self_guid { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields.clone()); + } + } + } + if let SessionEvent::QuestTemplate(t) = ev { + if t.quest_id == QUESTLOG_ID { + template = Some(*t); + } + } + } + } + let template = + template.context("--questlog: no SMSG_QUEST_QUERY_RESPONSE for quest 7 within 5s")?; + println!( + "SMSG_QUEST_QUERY_RESPONSE: \"{}\" (level {}), details {} char(s), {} reward(s) / \ + {} choice(s) non-zero, {}c money", + template.title, + template.level, + template.details.len(), + template.rewards.iter().filter(|(id, _)| *id != 0).count(), + template.choices.iter().filter(|(id, _)| *id != 0).count(), + template.money, + ); + for (i, o) in template.objectives.iter().enumerate() { + if o.required_count > 0 || o.item_count > 0 { + println!( + " objective {i}: creature_or_go {} required_count {} item {}x{} — \"{}\"", + o.creature_or_go, o.required_count, o.item_id, o.item_count, o.text + ); + } + } + if template.title != QUESTLOG_TITLE { + bail!( + "--questlog: SMSG_QUEST_QUERY_RESPONSE title '{}', expected '{QUESTLOG_TITLE}'", + template.title + ); + } + if !template.objectives.iter().any(|o| o.required_count > 0) { + bail!("--questlog: no objective with required_count > 0 in the parsed template"); + } + println!( + "✅ template: title matches '{QUESTLOG_TITLE}', ≥1 objective with required_count > 0." + ); + + // 3) Slot state: find which PLAYER_QUEST_LOG slot the accept landed in (same poll pattern + // --quest uses for its soft accept check, made a hard requirement here), then GM-complete + // it and require the slot's count-state field (id-field + 1) to gain the COMPLETE state + // byte (bit 0x01 at byte 3 — `count_state & 0xFF00_0000` gains `0x01 << 24`). + let find_slot = |sf: &Option| { + sf.as_ref().and_then(|sf| { + (0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS) + .find(|&i| sf.player_quest_log(i).map(|s| s.quest_id) == Some(QUESTLOG_ID)) + }) + }; + let mut slot = find_slot(&world.self_fields); + for _ in 0..6 { + if slot.is_some() { + break; + } + let drain_until = Instant::now() + Duration::from_secs(1); + while Instant::now() < drain_until { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == self_guid { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields); + } + } + } + } + } + slot = find_slot(&world.self_fields); + } + let slot = slot.context( + "--questlog: quest 7 never landed in a PLAYER_QUEST_LOG slot within the poll window", + )?; + println!("quest {QUESTLOG_ID} occupies PLAYER_QUEST_LOG slot {slot}"); + + // The count-state word itself (id-field + 1) — read raw so we can print the exact + // before/after bytes, not just the decoded [`QuestLogSlot::state`]. + let count_state_word = |sf: &Option| { + sf.as_ref().and_then(|sf| { + sf.raw_fields() + .find(|&(idx, _)| idx == FIELD_PLAYER_QUEST_LOG_1_1 + 3 * u16::from(slot) + 1) + .map(|(_, v)| v) + }) + }; + let before_word = count_state_word(&world.self_fields).unwrap_or(0); + println!("count-state word before: {before_word:#010x}"); + + session.send_chat(&format!(".quest complete {QUESTLOG_ID}"))?; + println!("GM: .quest complete {QUESTLOG_ID} (letting it apply…)"); + let mut after_word = None; + for _ in 0..8 { + if after_word.is_some() { + break; + } + let drain_until = Instant::now() + Duration::from_secs(1); + while Instant::now() < drain_until { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::ObjectValues { guid: g, fields } if g == self_guid => { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields); + } + } + SessionEvent::Chat(m) => println!(" server: {}", m.text), + _ => {} + } + } + } + if let Some(w) = count_state_word(&world.self_fields) { + if w & (0x01 << 24) != 0 { + after_word = Some(w); + } + } + } + let after_word = after_word.context( + "--questlog: the slot's count-state field never gained the COMPLETE bit \ + (0x01 << 24) after GM .quest complete", + )?; + println!("count-state word after: {after_word:#010x}"); + println!( + "✅ slot state: quest {QUESTLOG_ID} in slot {slot}; COMPLETE bit set after GM \ + complete ({before_word:#010x} → {after_word:#010x})." + ); + + // 4) Abandon: CMSG_QUESTLOG_REMOVE_QUEST(slot) — no ack SMSG exists on this wire (decision + // 0109); the descriptor's id field clearing to 0 IS the confirmation. + println!("CMSG_QUESTLOG_REMOVE_QUEST(slot {slot})"); + session.questlog_remove_quest(slot)?; + let mut cleared = false; + let drain_until = Instant::now() + Duration::from_secs(8); + while Instant::now() < drain_until && !cleared { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == self_guid { + if let Some(sf) = &mut world.self_fields { + sf.merge(fields); + } + } + } + } + cleared = world + .self_fields + .as_ref() + .and_then(|sf| sf.player_quest_log(slot)) + .map(|qs| qs.quest_id) + == Some(0); + } + if !cleared { + bail!( + "--questlog: slot {slot}'s PLAYER_QUEST_LOG id field never cleared to 0 within \ + 8s after CMSG_QUESTLOG_REMOVE_QUEST (no ack SMSG exists on this wire — the field \ + clear IS the confirmation, decision 0109)" + ); + } + println!( + "✅ abandon: slot {slot}'s PLAYER_QUEST_LOG id field cleared to 0 (no ack SMSG on \ + this wire — the field update is the confirmation, decision 0109)." + ); + + println!( + "\n✅ --questlog PASS: template parsed, slot {slot} tracked → COMPLETE → abandoned \ + (id field cleared) — the quest-log wire verified end to end (decision 0109)." + ); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs new file mode 100644 index 00000000..6e807a4d --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/speed.rs @@ -0,0 +1,69 @@ +//! `--speed`: the force-speed-change wire. GM `.modify speed 1.5`, require + ack +//! `SMSG_FORCE_RUN_SPEED_CHANGE`, then `.modify speed 1` and require a SECOND change on a still-live +//! stream (a malformed ack drops the session, so surviving both round trips is the proof). + +use anyhow::{ensure, Result}; +use benilla_protocol::{SessionEvent, SpeedKind}; + +use crate::probes::{Ctx, Probe}; + +#[derive(Default)] +pub(crate) struct Speed { + second_sent: bool, +} + +impl Probe for Speed { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + cx.session.send_chat(".modify speed 1.5")?; + println!("sent GM: .modify speed 1.5 (self) — expecting SMSG_FORCE_RUN_SPEED_CHANGE"); + Ok(()) + } + + fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { + if let SessionEvent::ForceSpeedChange { guid, .. } = ev { + // Fires after World has acked + recorded the change. World acks only when our pose is + // known (`tracked.get(guid)` Some, else it skips the arm entirely and never records) — + // mirror that guard (self + tracked-present) here so the second `.modify` is suppressed + // in exactly the same pose-missing case the old in-arm `continue` suppressed it. + if *guid == cx.world.self_guid + && cx.world.tracked.contains_key(guid) + && !self.second_sent + { + cx.session.send_chat(".modify speed 1")?; + self.second_sent = true; + println!("sent GM: .modify speed 1 — expecting the second change"); + } + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + // --speed verdict: two changes, both Run, the flat speeds the GM rates imply (1.5×7.0 then + // 1.0×7.0), and an incremented counter — proof both acks were parsed (a malformed body drops + // the session before the second round trip could complete). + let speed_changes_seen = &cx.world.speed_changes_seen; + ensure!( + speed_changes_seen.len() >= 2, + "--speed: expected 2 force-speed changes (got {}) — did the first ack drop the session, or is the account not GM?", + speed_changes_seen.len() + ); + let (k1, c1, s1) = speed_changes_seen[0]; + let (k2, c2, s2) = speed_changes_seen[1]; + ensure!( + k1 == SpeedKind::Run && k2 == SpeedKind::Run, + "--speed: expected Run changes, got {k1:?} then {k2:?}" + ); + ensure!( + (s1 - 10.5).abs() < 0.01 && (s2 - 7.0).abs() < 0.01, + "--speed: expected flat speeds 10.5 then 7.0 (rates 1.5/1.0 × base 7.0), got {s1} then {s2}" + ); + ensure!( + c2 > c1, + "--speed: movement counter must increment across changes (got {c1} then {c2})" + ); + println!( + "\n--speed PASS: {k1:?} 7.0->10.5->7.0 yd/s, counters {c1}->{c2}, both acks accepted (stream survived)" + ); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs new file mode 100644 index 00000000..81522cbf --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/spells.rs @@ -0,0 +1,215 @@ +//! `--spells`: the spell/action wire. Require `SMSG_INITIAL_SPELLS` + `SMSG_ACTION_BUTTONS` at +//! login, cast one spell (self, then packed-guid targeted), require a `SMSG_CAST_RESULT` verdict, +//! and read our inventory back out of the descriptor as the round-trip evidence. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, guid, EntityKind, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +#[derive(Default)] +pub(crate) struct Spells { + cast_sent: Option, + targeted_cast_sent: Option<(u32, u64)>, +} + +impl Probe for Spells { + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // Cast once both the book and the bar are known. The pick must be an *active* spell — + // vmangos silently drops a cast of a passive (HandleCastSpellOpcode returns before any + // CAST_RESULT), and the book mixes proficiencies/passives in. A spell the player slotted + // on the action bar is active by construction (6603 auto-attack excluded: it's the + // attack toggle, not a cast); Battle Shout 6673 is the fallback. Either verdict (ok or a + // failure reason like no-rage/bad-target) proves the round trip. + if self.cast_sent.is_none() { + if let (Some(book), Some(bar)) = (&cx.world.spell_book, &cx.world.bar_spells) { + let spell = bar + .iter() + .find(|&&s| s != 6603 && book.contains(&s)) + .copied() + .or_else(|| book.contains(&6673).then_some(6673)); + if let Some(spell) = spell { + cx.session.cast_spell(spell, None)?; + println!("sent CMSG_CAST_SPELL for spell {spell} (self)"); + self.cast_sent = Some(spell); + } else { + bail!("no castable spell to probe with (bar has no known active spell)"); + } + } + } + // Phase 2: once the self-cast is answered, cast the same spell AT the first streamed + // creature — this exercises the mask-2 + PACKED-guid target block, the path the action + // bar uses with a selection (the self-cast only proves mask 0). + if self.targeted_cast_sent.is_none() && cx.world.cast_verdict.is_some() { + if let Some(spell) = self.cast_sent { + if let Some((&guid, _)) = cx + .world + .tracked + .iter() + .find(|(g, t)| t.kind == EntityKind::Unit && guid::is_creature_or_pet(**g)) + { + cx.session.set_selection(guid)?; + cx.session.cast_spell(spell, Some(guid))?; + println!("sent CMSG_CAST_SPELL for spell {spell} at {guid:#x} (packed target)"); + self.targeted_cast_sent = Some((spell, guid)); + } + } + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + + // --spells inventory readout: what the server says One is actually holding. + if let Some(sf) = &world.self_fields { + let slot_entry = |guid: Option| { + guid.filter(|g| *g != 0) + .and_then(|g| world.item_entries.get(&g).copied()) + }; + let mut wanted: Vec = (0..23) + .filter_map(|i| slot_entry(sf.player_inv_slot(i))) + .chain((0..16).filter_map(|i| slot_entry(sf.player_pack_slot(i)))) + .collect(); + wanted.sort_unstable(); + wanted.dedup(); + for e in &wanted { + if !world.item_names.contains_key(e) { + session.item_query(*e, 0)?; + } + } + let drain_until = Instant::now() + Duration::from_secs(3); + while Instant::now() < drain_until + && wanted.iter().any(|e| !world.item_names.contains_key(e)) + { + if let Ok(msg) = session.recv() { + for ev in decode(msg) { + if let SessionEvent::ItemTemplate { + entry, + info: Some(i), + } = ev + { + world.item_names.insert( + entry, + format!("{} [class {} subclass {}]", i.name, i.class, i.subclass), + ); + } + } + } + } + let show_equipped = |label: &str, slot: u8| { + let text = match (sf.player_inv_slot(slot), sf.player_visible_item_entry(slot)) { + (_, Some(e)) => world + .item_names + .get(&e) + .cloned() + .unwrap_or_else(|| format!("entry {e}")), + (Some(g), None) if g != 0 => format!("guid {g:#x} (no visible entry)"), + _ => "EMPTY".to_string(), + }; + println!(" {label:<10} {text}"); + }; + let show = |label: &str, guid: Option| { + let text = match guid { + None => "(not sent)".to_string(), + Some(0) => "EMPTY".to_string(), + Some(g) => match world.item_entries.get(&g) { + Some(e) => world + .item_names + .get(e) + .cloned() + .unwrap_or_else(|| format!("entry {e}")), + None => format!("guid {g:#x} (no item object streamed)"), + }, + }; + println!(" {label:<10} {text}"); + }; + // Equipment names resolve through the PUBLIC visible-item entries (item objects + // have no decode path yet — a named gap; the private INV guids prove presence). + for slot in [15u8, 16, 17] { + if let Some(e) = sf.player_visible_item_entry(slot) { + if !world.item_names.contains_key(&e) { + session.item_query(e, 0)?; + } + } + } + println!( + " +--- One's hands + pack (server truth) ---" + ); + show_equipped("mainhand", 15); + show_equipped("offhand", 16); + show_equipped("ranged", 17); + for i in 0..16 { + if let Some(g) = sf.player_pack_slot(i).filter(|g| *g != 0) { + show(&format!("pack {i}"), Some(g)); + } + } + } else { + println!("(no self descriptor captured — inventory readout skipped)"); + } + + // --spells verdict: book + bar must have arrived and parsed; the cast must have been answered. + let book = world + .spell_book + .clone() + .context("no SMSG_INITIAL_SPELLS arrived")?; + if book.is_empty() { + bail!("SMSG_INITIAL_SPELLS parsed to an empty spell book"); + } + let bar = world + .bar_spells + .clone() + .context("no SMSG_ACTION_BUTTONS arrived")? + .len(); + let sent = self.cast_sent.context("cast never sent (empty book?)")?; + let (spell, success, reason) = world + .cast_verdict + .context("no SMSG_CAST_RESULT for our CMSG_CAST_SPELL")?; + if spell != sent { + bail!("cast result names spell {spell}, we cast {sent}"); + } + match (world.item_asked, &world.item_answer) { + (Some(e), Some((entry, Some(name)))) if *entry == e => { + println!("✅ item query: entry {e} → '{name}'."); + } + (Some(e), Some((entry, None))) if *entry == e => { + println!("✅ item query: entry {e} → unknown (miss shape parsed)."); + } + (Some(e), _) => bail!("no SMSG_ITEM_QUERY_SINGLE_RESPONSE for entry {e}"), + (None, _) => println!("(no item-kind button on the bar to item-query)"), + } + match (self.targeted_cast_sent, &world.targeted_verdict) { + (Some((spell, guid)), Some((s2, ok2, r2))) if *s2 == spell => { + println!( + "✅ targeted cast (packed guid): spell {spell} at {guid:#x} → {}.", + if *ok2 { + "ok".to_string() + } else { + format!("failed (reason {:#04x})", r2.unwrap_or(0)) + } + ); + } + (Some((spell, _)), _) => { + bail!("no SMSG_CAST_RESULT for the TARGETED cast of {spell} — the mask-2 packed-guid body desyncs the server") + } + (None, _) => println!("(no creature in range for the targeted-cast phase)"), + } + println!( + "\n✅ spells: {} known, {bar} bar slot(s), cast {sent} → {}.", + book.len(), + if success { + "ok".to_string() + } else { + format!( + "failed (reason {:#04x}) — round trip proven", + reason.unwrap_or(0) + ) + } + ); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs new file mode 100644 index 00000000..beb48c6b --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/spirit.rs @@ -0,0 +1,244 @@ +//! `--spirit`: the spirit-healer res + the 25% durability loss's wire (decision 0318). Repair to a +//! full baseline (the [`crate::world::DeathArc`] dies + releases), teleport onto the graveyard's +//! Spirit Healer, `CMSG_SPIRIT_HEALER_ACTIVATE`, and require BOTH the res (ghost flag clears) and +//! the post-activate `ITEM_FIELD_DURABILITY` deltas the loss must push. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Result}; +use benilla_protocol::{guid, EntityKind, ObjectFields, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +/// The merged per-item descriptor stores (create seed + every values-delta — the same shape the +/// app's `Items` store holds), the durability baseline snapshotted at activate time, and the raw +/// post-activate durability deltas the verdict requires. +#[derive(Default)] +pub(crate) struct Spirit { + healer: Option<(u64, [f32; 3])>, + healer_tp_sent: bool, + healer_tp_landed: Option, + activate_sent: bool, + dur_baseline: HashMap, + dur_deltas: Vec<(u64, u32)>, + item_fields: HashMap, +} + +impl Probe for Spirit { + fn stage(&mut self, cx: &mut Ctx) -> Result<()> { + // A full-durability baseline (`.repairitems`, vmangos `Chat.cpp:1297` SEC_GAMEMASTER): + // every post-activate delta must then read as a DROP, idempotent across probe re-runs + // (each spirit res costs a real 25%). + cx.session.send_chat(".repairitems")?; + println!("sent GM: .repairitems (full-durability baseline)"); + Ok(()) + } + + fn poll(&mut self, cx: &mut Ctx) -> Result<()> { + // --spirit: once we're a released ghost and the graveyard's Spirit Healer has streamed, + // teleport onto it — the repop spot can land outside the activate's interaction gate + // (vmangos `GetNPCIfCanInteractWith`, INTERACTION_DISTANCE 5 yd). + if !self.healer_tp_sent + && cx + .world + .death_arc + .as_ref() + .is_some_and(|a| a.ghost_seen && a.graveyard_pos.is_some()) + { + if let Some((hg, hp)) = self.healer { + cx.session + .send_chat(&format!(".go xyz {} {} {}", hp[0], hp[1], hp[2]))?; + println!("sent GM teleport onto Spirit Healer (guid {hg:#x})"); + self.healer_tp_sent = true; + } + } + // --spirit: standing on the healer, snapshot the durability baseline (the merged item + // stores: create seed + the `.repairitems` deltas) and ask for the res — the XP_LOSS + // popup's accept. The 2s settle after the teleport ack lets the server PROCESS the ack + // first: the movement-ack and world-packet queues are separate, and an activate racing + // its own teleport ack is range-checked from the PRE-teleport spot (>5 yd → silently + // refused; live-observed on the first probe run). + if !self.activate_sent { + if let (Some((hg, hp)), Some(landed)) = (self.healer, self.healer_tp_landed) { + let (pos, _) = cx.world.self_pose(); + let d2 = + (pos[0] - hp[0]).powi(2) + (pos[1] - hp[1]).powi(2) + (pos[2] - hp[2]).powi(2); + if d2 < 25.0 && landed.elapsed() > Duration::from_secs(2) { + self.dur_baseline = self + .item_fields + .iter() + .filter_map(|(&g, f)| { + f.item_durability() + .zip(f.item_max_durability()) + .filter(|&(_, max)| max > 0) + .map(|p| (g, p)) + }) + .collect(); + cx.session.spirit_healer_activate(hg)?; + println!( + "sent CMSG_SPIRIT_HEALER_ACTIVATE (guid {hg:#x}) — baseline: {} durable item(s)", + self.dur_baseline.len() + ); + self.activate_sent = true; + if let Some(arc) = &mut cx.world.death_arc { + arc.revive_initiated = true; + } + } + } + } + Ok(()) + } + + fn on_event(&mut self, ev: &SessionEvent, cx: &mut Ctx) -> Result<()> { + match ev { + // --spirit: the graveyard's Spirit Healer advertises UNIT_NPC_FLAG_SPIRITHEALER (0x20 — + // vmangos `UnitDefines.h:662`, the flag `GetNPCIfCanInteractWith` gates the activate on). + // It only streams to ghosts, so it can't appear before release. (The capture condition is + // the arm guard so the whole match reads as one dispatch — clippy's collapsible_match.) + SessionEvent::ObjectCreate { + guid, + kind, + position, + fields, + .. + } if *kind == EntityKind::Unit && fields.unit_npc_flags() & 0x20 != 0 => { + if self.healer.is_none() { + println!( + "Spirit Healer streamed: guid {guid:#x} at ({:.1}, {:.1}, {:.1})", + position[0], position[1], position[2] + ); + } + self.healer = Some((*guid, *position)); + } + SessionEvent::ItemCreate { guid, fields, .. } => { + // --spirit: seed/overlay the item's merged store (the app's `Items` + // discipline) — the durability baseline reads off these. The print + // is the created-semantics live proof: a broken item's create OMITS + // its zero `DURABILITY` word, and the pair must still read `0/max` + // (the director's "100% on broken gear" bug). + if let Some((d, m)) = fields + .item_durability() + .zip(fields.item_max_durability()) + .filter(|&(_, m)| m > 0) + { + println!( + "item create: guid {guid:#x} entry {} durability {d}/{m}", + fields.object_entry().unwrap_or(0) + ); + } + match self.item_fields.entry(*guid) { + std::collections::hash_map::Entry::Occupied(mut e) => { + e.get_mut().merge(fields.clone()) + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(fields.clone()); + } + } + } + // --spirit: an item values-delta — the durability wire under test. Log + // every `ITEM_FIELD_DURABILITY` it carries (post-activate ones are the + // verdict's evidence) and merge into the item's store. + SessionEvent::ObjectValues { guid, fields } if guid::is_item(*guid) => { + if let Some(d) = fields.item_durability() { + if self.activate_sent { + self.dur_deltas.push((*guid, d)); + } + println!( + "item durability delta: guid {guid:#x} → {d}{}", + if self.activate_sent { + " (post-activate)" + } else { + "" + } + ); + } + match self.item_fields.entry(*guid) { + std::collections::hash_map::Entry::Occupied(mut e) => { + e.get_mut().merge(fields.clone()) + } + std::collections::hash_map::Entry::Vacant(e) => { + e.insert(fields.clone()); + } + } + } + // The old Teleport arm's `else if cli.spirit … healer_tp_landed` branch. Its condition is + // equivalent to the else-if: `healer_tp_sent` can only become true after `graveyard_pos` + // is Some (the healer-TP poll requires it), so this and World's graveyard capture (the + // other branch) are temporally disjoint. (Folded into the arm guard — collapsible_match.) + SessionEvent::Teleport { guid, .. } + if *guid == cx.world.self_guid + && self.healer_tp_sent + && self.healer_tp_landed.is_none() => + { + self.healer_tp_landed = Some(Instant::now()); + } + _ => {} + } + Ok(()) + } + + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let ghost_seen = cx.world.death_arc.as_ref().is_some_and(|a| a.ghost_seen); + let revived_seen = cx.world.death_arc.as_ref().is_some_and(|a| a.revived_seen); + let healer_tp_sent = self.healer_tp_sent; + let session = &mut *cx.session; + + // Cleanup before judging: the res left the character alive at 75% — a GM repair keeps + // re-runs (and the shared GM character's gear) clean. Fire-and-forget; the stream is done. + session.send_chat(".repairitems")?; + + if !self.activate_sent { + bail!( + "--spirit: never reached the activate (ghost={ghost_seen}, healer streamed={}, tp sent={healer_tp_sent})", + self.healer.is_some() + ); + } + if !revived_seen { + // Don't leave the shared GM character a ghost (live-observed: the first run did). + session.send_chat(".revive")?; + bail!( + "--spirit: CMSG_SPIRIT_HEALER_ACTIVATE never cleared the ghost flag — the res didn't land (a cleanup .revive was sent)" + ); + } + if self.dur_baseline.is_empty() { + bail!( + "--spirit: no durable items in the baseline — equip the character with gear that has MaxDurability" + ); + } + if self.dur_deltas.is_empty() { + bail!( + "--spirit: the res landed but the wire carried NO post-activate ITEM_FIELD_DURABILITY delta ({} durable item(s) at baseline) — the 25% loss never reached the client; a tooltip can only show 100%", + self.dur_baseline.len() + ); + } + println!("\n✅ SPIRIT-HEALER RES + DURABILITY WIRE VERIFIED:"); + println!(" res ghost flag cleared after CMSG_SPIRIT_HEALER_ACTIVATE"); + println!( + " deltas {} post-activate durability delta(s) over {} durable item(s):", + self.dur_deltas.len(), + self.dur_baseline.len() + ); + let mut dropped = 0u32; + for (guid, after) in &self.dur_deltas { + let entry = cx.world.item_entries.get(guid).copied().unwrap_or(0); + match self.dur_baseline.get(guid) { + Some(&(before, max)) => { + if *after < before { + dropped += 1; + } + println!( + " item {entry:>5} (guid {guid:#x}) {before}/{max} → {after}/{max}" + ); + } + None => println!(" item {entry:>5} (guid {guid:#x}) ?/? → {after}"), + } + } + if dropped == 0 { + bail!( + "--spirit: durability deltas arrived but none DROPPED below its baseline — the loss is not visible in the values" + ); + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs new file mode 100644 index 00000000..12f84f7f --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/swap_pack_slots.rs @@ -0,0 +1,93 @@ +//! `--swap-pack-slots`: `CMSG_SWAP_INV_ITEM` (the backpack pick/place/swap wire). Swap two 1-based +//! backpack slots, assert the exchange, then swap back and assert the original layout (leaves the +//! character as found). + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +pub(crate) struct SwapPackSlots { + pub(crate) a: u8, + pub(crate) b: u8, +} + +impl Probe for SwapPackSlots { + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let (a, b) = (self.a, self.b); + + // --swap-pack-slots: swap two backpack slots, assert the exchange landed, then swap back and + // assert the original layout is restored (leaves the character as found). + let sf = world + .self_fields + .as_mut() + .context("no self descriptor — can't read the pack slots")?; + let (a0, b0) = (a - 1, b - 1); + let (wire_a, wire_b) = (23 + a0, 23 + b0); + let guid_a = sf + .player_pack_slot(a0) + .filter(|g| *g != 0) + .with_context(|| format!("pack slot {a} is empty/unsent — need an item to swap"))?; + let guid_b = sf.player_pack_slot(b0).unwrap_or(0); + println!( + "\nswapping backpack slots {a}↔{b} (guid {guid_a:#x} ↔ {guid_b:#x}) → \ + CMSG_SWAP_INV_ITEM src {wire_a} dst {wire_b}" + ); + + // A closure would borrow `session` + `sf` at once; inline the drain so the borrows don't + // overlap. Await until both slots read their expected guids (or 5s). + session.swap_inv_item(wire_a, wire_b)?; + let mut settled = false; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && !settled { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == world.self_guid { + sf.merge(fields); + } + } + } + settled = sf.player_pack_slot(a0).unwrap_or(0) == guid_b + && sf.player_pack_slot(b0).unwrap_or(0) == guid_a; + } + if !settled { + bail!( + "swap didn't land: slot {a} = {:#x}, slot {b} = {:#x} (wanted {guid_b:#x} / {guid_a:#x})", + sf.player_pack_slot(a0).unwrap_or(0), + sf.player_pack_slot(b0).unwrap_or(0) + ); + } + println!("✅ swap: slots {a}↔{b} exchanged in the descriptor (guids confirmed)."); + + // Swap back and require the original layout to return. + session.swap_inv_item(wire_a, wire_b)?; + let mut restored = false; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && !restored { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::ObjectValues { guid: g, fields } = ev { + if g == world.self_guid { + sf.merge(fields); + } + } + } + restored = sf.player_pack_slot(a0).unwrap_or(0) == guid_a + && sf.player_pack_slot(b0).unwrap_or(0) == guid_b; + } + if !restored { + bail!( + "swap-back didn't restore: slot {a} = {:#x}, slot {b} = {:#x} (wanted {guid_a:#x} / {guid_b:#x})", + sf.player_pack_slot(a0).unwrap_or(0), + sf.player_pack_slot(b0).unwrap_or(0) + ); + } + println!("✅ swap-back: the original layout is restored (character left as found)."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs new file mode 100644 index 00000000..deccaa37 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/use_pack_slot.rs @@ -0,0 +1,72 @@ +//! `--use-pack-slot`: `CMSG_USE_ITEM` on a 1-based backpack slot; require a server reaction — a +//! stack-count delta, a destroy on the item's guid, or an explicit cast-result refusal. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use benilla_protocol::{decode, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +pub(crate) struct UsePackSlot { + pub(crate) n: u8, +} + +impl Probe for UsePackSlot { + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let n = self.n; + + // --use-pack-slot: poke a real item and require a server reaction on its guid. + let sf = world + .self_fields + .as_ref() + .context("no self descriptor — can't find the pack slot")?; + let n0 = n.checked_sub(1).context("pack slots are 1-based")?; + let guid = sf + .player_pack_slot(n0) + .filter(|g| *g != 0) + .with_context(|| format!("pack slot {n} is empty/unsent"))?; + let entry = world.item_entries.get(&guid).copied().unwrap_or(0); + let before = world.item_stacks.get(&guid).copied().unwrap_or(1); + println!( + "\nusing pack slot {n} (guid {guid:#x}, entry {entry}, stack {before}) → CMSG_USE_ITEM bag 255 slot {}", + 23 + n0 + ); + session.use_item(255, 23 + n0, 0)?; + let drain_until = Instant::now() + Duration::from_secs(5); + let mut verdict: Option = None; + while Instant::now() < drain_until && verdict.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::ObjectValues { guid: g, fields } if g == guid => { + if let Some(after) = fields.item_stack_count() { + verdict = Some(format!( + "stack {before} → {after} (values delta on the guid)" + )); + } + } + SessionEvent::ObjectDestroyed(g) if g == guid => { + verdict = Some("item destroyed (last charge consumed)".into()); + } + SessionEvent::CastResult { + success: false, + reason, + .. + } => { + verdict = Some(format!( + "server refused (cast result reason {:#04x})", + reason.unwrap_or(0) + )); + } + _ => {} + } + } + } + let v = verdict.context("no reaction to CMSG_USE_ITEM within 5s")?; + println!("✅ use item: {v}."); + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs new file mode 100644 index 00000000..81837b23 --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/vendor.rs @@ -0,0 +1,157 @@ +//! `--vendor`: the vendor wire (decision 0081 phase 4). Auto-find the nearest streamed vendor, +//! `CMSG_LIST_INVENTORY` it, buy the cheapest row, and require a reaction — plus confirm the money +//! accessor (`PLAYER_FIELD_COINAGE`) reads and that a coinage delta lands on that field. + +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use benilla_protocol::{decode, SessionEvent}; + +use crate::probes::{Ctx, Probe}; + +pub(crate) struct Vendor; + +impl Probe for Vendor { + fn verify(&mut self, cx: &mut Ctx) -> Result<()> { + let world = &mut *cx.world; + let session = &mut *cx.session; + let self_guid = world.self_guid; + + // --vendor: list the nearest streamed vendor and buy its cheapest row; also confirm the money + // accessor (`PLAYER_FIELD_COINAGE`) reads and that any coinage delta lands on that field. + let sf = world + .self_fields + .as_mut() + .context("no self descriptor — can't read coinage / find a vendor")?; + let coinage_before = sf.player_money(); + println!( + "\nPLAYER_FIELD_COINAGE at login = {} copper (money accessor)", + coinage_before + .map(|c| c.to_string()) + .unwrap_or_else(|| "unsent".into()) + ); + + let self_pos = world.tracked.get(&self_guid).map(|t| t.position); + // The nearest advertised vendor (2D distance from us; z ignored — Northshire is ~flat here). + let vendor = self_pos.and_then(|p| { + world + .vendors + .iter() + .map(|(&g, vp)| (g, (vp[0] - p[0]).hypot(vp[1] - p[1]))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + }); + let (vendor_guid, dist) = vendor.context( + "no UNIT_NPC_FLAG_VENDOR creature streamed in range (fresh Northshire spawn has none — \ + re-run with a longer --seconds after moving toward a vendor, or grant GM to teleport)", + )?; + println!( + "nearest vendor: guid {vendor_guid:#x} ({dist:.1} yd) — sending CMSG_LIST_INVENTORY" + ); + session.list_inventory(vendor_guid)?; + + // Drain for the vendor's stock list. + let mut items: Option> = None; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && items.is_none() { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + if let SessionEvent::VendorInventory { + vendor, + items: rows, + } = ev + { + if vendor == vendor_guid { + items = Some(rows); + } + } + } + } + let rows = items.context("no SMSG_LIST_INVENTORY arrived within 5s")?; + println!("✅ list: SMSG_LIST_INVENTORY parsed {} row(s):", rows.len()); + for r in rows.iter().take(8) { + let stock = if r.current_count == 0xFFFF_FFFF { + "∞".to_string() + } else { + r.current_count.to_string() + }; + println!( + " slot {:>2} entry {:>6} price {:>7}c stock {stock} buy_count {}", + r.slot, r.entry, r.price, r.buy_count + ); + } + if rows.is_empty() { + bail!("the vendor listed 0 rows — can't exercise a buy"); + } + + // Buy the cheapest priced, in-stock row (price > 0; current_count 0 = sold out — a limited + // row we already drained; 0xFFFF_FFFF = unlimited, always buyable), one stack. + let cheapest = rows + .iter() + .filter(|r| r.price > 0 && r.current_count != 0) + .min_by_key(|r| r.price) + .context("no priced, in-stock row to buy")?; + println!( + "buying cheapest: entry {} @ {}c → CMSG_BUY_ITEM", + cheapest.entry, cheapest.price + ); + session.buy_item(vendor_guid, cheapest.entry, 1)?; + + // Drain for a reaction: the item arriving, the stock updating, a coinage delta on exactly the + // COINAGE field, or a decoded refusal — any one proves the round trip. Keep draining the full + // window (not stopping at the first) so a coinage delta — the strongest evidence, it confirms + // the accessor *index* — is captured even when the item-create lands first in the same batch. + let mut round_trip: Option = None; + let mut coinage_delta: Option = None; + let drain_until = Instant::now() + Duration::from_secs(5); + while Instant::now() < drain_until && (round_trip.is_none() || coinage_delta.is_none()) { + let Ok(msg) = session.recv() else { continue }; + for ev in decode(msg) { + match ev { + SessionEvent::ObjectValues { guid: g, fields } if g == self_guid => { + sf.merge(fields); + if let (Some(before), Some(after)) = (coinage_before, sf.player_money()) { + if after != before && coinage_delta.is_none() { + coinage_delta = Some(format!( + "coinage {before} → {after} copper (paid {}) — the delta landed on \ + PLAYER_FIELD_COINAGE (index-confirmed accessor)", + before as i64 - after as i64 + )); + } + } + } + SessionEvent::VendorBuyResult { + slot, new_count, .. + } => { + round_trip.get_or_insert(format!( + "SMSG_BUY_ITEM: vendor slot {slot} stock now {new_count} (purchase ok)" + )); + } + SessionEvent::ItemCreate { + guid: g, fields, .. + } => { + let entry = fields.object_entry().unwrap_or(0); + if entry == cheapest.entry { + round_trip.get_or_insert(format!( + "the bought item arrived (ItemCreate guid {g:#x}, entry {entry})" + )); + } + } + SessionEvent::VendorBuyFailed { + item_entry, reason, .. + } => { + round_trip.get_or_insert(format!( + "SMSG_BUY_FAILED for entry {item_entry} (reason {reason}) — round trip proven" + )); + } + _ => {} + } + } + } + let v = round_trip.context("no reaction to CMSG_BUY_ITEM within 5s")?; + println!("✅ vendor buy: {v}."); + if let Some(d) = coinage_delta { + println!("✅ money accessor: {d}."); + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/bin/benilla-world/world.rs b/crates/benilla-protocol/src/bin/benilla-world/world.rs new file mode 100644 index 00000000..50c9b12f --- /dev/null +++ b/crates/benilla-protocol/src/bin/benilla-world/world.rs @@ -0,0 +1,531 @@ +//! The shared world state every probe reads: the entity tracker, the merged self descriptor, the +//! item/vendor stores, the opcode tally — plus the session-keeping acks (teleport, force-speed, +//! root, water-walk) that keep *any* session alive regardless of probe mix, and the shared +//! [`DeathArc`] scenario machinery (`--death`/`--spirit` both read it, so neither owns it). + +use std::collections::{BTreeMap, HashMap}; +use std::time::Instant; + +use anyhow::Result; +use benilla_protocol::{ + Character, EntityKind, ObjectFields, ServerPacket, SessionEvent, SpeedKind, WorldSession, +}; + +/// A Kobold Vermin spawn in Northshire (mangos `creature` guid 79992) — the `--attack` teleport +/// target: hostile, level 1, melee-reach the moment we land on it. Lives here (not in a probe) +/// because attack staging, loot staging, and the [`DeathArc`] staging all teleport to it. +pub(crate) const ATTACK_TP: &str = ".go xyz -8780.71 -164.568 81.94"; + +/// One entity decoded from the stream, as `benilla-world` reports it (raw WoW coords). +pub(crate) struct Tracked { + pub(crate) kind: EntityKind, + pub(crate) position: [f32; 3], + pub(crate) orientation: f32, +} + +/// The shared die→release→ghost→corpse scenario machinery (decision 0308 slice 1). Created when +/// `--death` or `--spirit` is set; both probes read it, so neither owns it. The staging is in +/// [`World::stage`]; the die/release pumps in [`World::poll`]; the ghost/graveyard/reclaim evidence +/// in [`World::on_event`]. +#[derive(Default)] +pub(crate) struct DeathArc { + pub(crate) die_sent: bool, + pub(crate) died_at: Option, + pub(crate) death_pos: Option<[f32; 3]>, + pub(crate) rooted_seen: bool, + pub(crate) repop_sent: bool, + pub(crate) unroot_seen: bool, + pub(crate) water_walk_seen: bool, + pub(crate) ghost_seen: bool, + pub(crate) graveyard_pos: Option<[f32; 3]>, + pub(crate) reclaim_delay_ms: Option, + /// Set when the death probe sends `.revive` OR the spirit probe sends the healer activate — the + /// ghost-clear check keys off this instead of either probe's own flag, so the two are decoupled. + pub(crate) revive_initiated: bool, + pub(crate) revived_seen: bool, +} + +/// Everything more than one probe reads, plus the session-keeping acks and the [`DeathArc`]. +pub(crate) struct World { + pub(crate) self_guid: u64, + pub(crate) self_name: String, + pub(crate) self_level: u8, + pub(crate) self_map: u32, + /// The `SMSG_CHAR_ENUM` spawn pose, the [`Self::self_pose`] fallback before our own create streams. + pub(crate) spawn_pos: [f32; 3], + pub(crate) tracked: HashMap, + /// Our own descriptor fields (equipment/pack slots are PRIVATE-flagged — sent only for our own + /// player) merged across the create + every values delta. + pub(crate) self_fields: Option, + pub(crate) item_entries: HashMap, + pub(crate) item_stacks: HashMap, + pub(crate) item_names: HashMap, + /// Streamed NPCs that advertise UNIT_NPC_FLAG_VENDOR, by guid → position. + pub(crate) vendors: HashMap, + /// The last same-map teleport landing point (the `--attack`/`--charge`/`--loot`/`--death` spot). + pub(crate) attack_pos: Option<[f32; 3]>, + /// Every self force-speed change seen (kind, counter, flat speed). + pub(crate) speed_changes_seen: Vec<(SpeedKind, u32, f32)>, + pub(crate) player_name_answer: Option, + pub(crate) creature_name_answer: Option<(u32, Option)>, + pub(crate) spell_book: Option>, + pub(crate) bar_spells: Option>, + pub(crate) item_asked: Option, + pub(crate) item_answer: Option<(u32, Option)>, + pub(crate) cast_verdict: Option<(u32, bool, Option)>, + pub(crate) targeted_verdict: Option<(u32, bool, Option)>, + pub(crate) swings_seen: u32, + pub(crate) self_moves: u32, + pub(crate) tally: BTreeMap, + pub(crate) total: u32, + pub(crate) death_arc: Option, + /// Shared staging flags: the ATTACK_TP (attack/loot/DeathArc all want it) and the McBride + /// teleport (questlog/giverstatus both want it) each go out once, set by whichever stages first. + pub(crate) attack_tp_staged: bool, + pub(crate) mcbride_staged: bool, +} + +impl World { + pub(crate) fn new(character: &Character) -> Self { + World { + self_guid: character.guid, + self_name: character.name.clone(), + self_level: character.level, + self_map: character.map, + spawn_pos: [ + character.position.x, + character.position.y, + character.position.z, + ], + tracked: HashMap::new(), + self_fields: None, + item_entries: HashMap::new(), + item_stacks: HashMap::new(), + item_names: HashMap::new(), + vendors: HashMap::new(), + attack_pos: None, + speed_changes_seen: Vec::new(), + player_name_answer: None, + creature_name_answer: None, + spell_book: None, + bar_spells: None, + item_asked: None, + item_answer: None, + cast_verdict: None, + targeted_verdict: None, + swings_seen: 0, + self_moves: 0, + tally: BTreeMap::new(), + total: 0, + death_arc: None, + attack_tp_staged: false, + mcbride_staged: false, + } + } + + /// Our tracked pose, for the movement acks the server expects while dead/ghost (root, + /// water-walk): the probe holds still throughout the arc, so any recent `tracked` position is + /// truthful — fall back to the SMSG_CHAR_ENUM spawn pose for the sliver of time before our own + /// ObjectCreate has streamed. + pub(crate) fn self_pose(&self) -> ([f32; 3], f32) { + match self.tracked.get(&self.self_guid) { + Some(t) => (t.position, t.orientation), + None => (self.spawn_pos, 0.0), + } + } + + /// Count a received packet: one per `recv`, before decode (the opcode tally + total). + pub(crate) fn tally_packet(&mut self, msg: &ServerPacket) { + self.total += 1; + *self.tally.entry(msg.name()).or_default() += 1; + } + + /// One-time pre-stream staging for the [`DeathArc`]. Called BEFORE the probes' stage loop, so + /// for `--spirit` the order is preserved: the arc's `.revive` + teleport, then spirit's + /// `.repairitems`. + pub(crate) fn stage(&mut self, session: &mut WorldSession) -> Result<()> { + if self.death_arc.is_some() { + // Stage AWAY from any graveyard first (the kobold spot the other probes use): dying AT a + // graveyard makes the release teleport a 0-yd no-op and the >20-yd graveyard assertion + // reads it as a missing port (live-observed: a cleanup `.revive` leaves the character at + // the graveyard, and the next `.die` there re-pops in place). The `.die` itself goes out + // in-loop, once this staging teleport has landed. The `.revive` first is defensive: a + // prior aborted run can leave the character a ghost (live-observed), and the arc under + // test must start from alive (it's a no-op on a living character). + session.send_chat(".revive")?; + // The ATTACK_TP is the shared teleport — mark it staged so attack/loot don't re-send it. + session.send_chat(ATTACK_TP)?; + self.attack_tp_staged = true; + println!("sent GM: .revive (defensive); teleport (death staging): {ATTACK_TP}"); + } + Ok(()) + } + + /// Every pump iteration, before recv: the [`DeathArc`] die/release steps. + pub(crate) fn poll(&mut self, session: &mut WorldSession) -> Result<()> { + if let Some(arc) = &mut self.death_arc { + // --death/--spirit: kill ourselves once the staging teleport has landed (`.die` with + // nothing selected targets the caster). + if !arc.die_sent && self.attack_pos.is_some() { + session.send_chat(".die")?; + println!("sent GM: .die (self-kill)"); + arc.die_sent = true; + } + // --death: once we've hit 0 health AND the server has force-rooted us (the wire doesn't + // guarantee which arrives first — see the MoveRoot/ObjectValues arms below), release the + // spirit: the RELEASE SPIRIT button's `CMSG_REPOP_REQUEST` (decision 0308 §1). + if !arc.repop_sent && arc.died_at.is_some() && arc.rooted_seen { + session.repop_request()?; + println!("sent CMSG_REPOP_REQUEST (release spirit)"); + arc.repop_sent = true; + } + } + Ok(()) + } + + /// Every decoded event, before the probes' `on_event`: the ungated narration/evidence + the + /// unconditional session-keeping acks + the [`DeathArc`] evidence. + pub(crate) fn on_event(&mut self, ev: &SessionEvent, session: &mut WorldSession) -> Result<()> { + match ev { + SessionEvent::ObjectCreate { + guid, + kind, + position, + orientation, + fields, + .. + } => { + if *guid == self.self_guid { + self.self_fields = Some(fields.clone()); + } + // A vendor advertises UNIT_NPC_FLAG_VENDOR (0x4 — NOT 0x80, which is + // INNKEEPER in 1.12; VERIFIED vmangos `UnitDefines.h:659`) in NPC_FLAGS. + if *kind == EntityKind::Unit && fields.unit_npc_flags() & 0x4 != 0 { + self.vendors.insert(*guid, *position); + } + self.tracked.insert( + *guid, + Tracked { + kind: *kind, + position: *position, + orientation: *orientation, + }, + ); + } + // Our inventory: position-less item/container creates. + SessionEvent::ItemCreate { guid, fields, .. } => { + if let Some(entry) = fields.object_entry() { + self.item_entries.insert(*guid, entry); + } + self.item_stacks + .insert(*guid, fields.item_stack_count().unwrap_or(1)); + } + SessionEvent::ObjectValues { guid, fields } if *guid == self.self_guid => { + // --death: the server force-flushes UNIT_FIELD_HEALTH the instant we + // die — a values delta carrying an EXPLICIT 0 (not merely absent) is + // the death instant itself. Must be read off `fields` (this delta) + // before the merge below moves it. + let pose = self.self_pose(); + if let Some(arc) = &mut self.death_arc { + if arc.died_at.is_none() && fields.unit_health() == Some(0) { + arc.died_at = Some(Instant::now()); + let pos = pose.0; + arc.death_pos = Some(pos); + println!( + "UNIT_FIELD_HEALTH → 0: died at ({:.1}, {:.1}, {:.1})", + pos[0], pos[1], pos[2] + ); + } + } + if let Some(sf) = &mut self.self_fields { + sf.merge(fields.clone()); + // PLAYER_FLAGS_GHOST (bit 0x10, field 190) sets at release and + // clears at revive — read off the MERGED store, not this delta + // alone: a delta that doesn't touch PLAYER_FLAGS reads as an + // absent field on `fields`, not "still whatever it last was". + if let Some(arc) = &mut self.death_arc { + if !arc.ghost_seen && sf.player_is_ghost() { + arc.ghost_seen = true; + println!("PLAYER_FLAGS_GHOST set — released as a ghost"); + } else if arc.ghost_seen + && arc.revive_initiated + && !arc.revived_seen + && !sf.player_is_ghost() + { + arc.revived_seen = true; + println!("PLAYER_FLAGS_GHOST cleared — revived"); + } + } + } + } + SessionEvent::ObjectMove { + guid, + position, + orientation, + } => { + if let Some(t) = self.tracked.get_mut(guid) { + t.position = *position; + t.orientation = *orientation; + } + } + SessionEvent::ObjectsRemoved(guids) => { + for g in guids { + self.tracked.remove(g); + } + } + SessionEvent::ObjectDestroyed(guid) => { + self.tracked.remove(guid); + } + SessionEvent::TimeSpeed { + hours, + minutes, + timescale, + .. + } => { + println!( + "server game-time {hours:02}:{minutes:02} (timescale {timescale} game-min/sec)" + ); + } + SessionEvent::PlayerName { guid, name, .. } => { + println!("SMSG_NAME_QUERY_RESPONSE: guid {guid} → '{name}'"); + self.player_name_answer = Some(name.clone()); + } + SessionEvent::CreatureName { entry, name, .. } => { + println!("SMSG_CREATURE_QUERY_RESPONSE: entry {entry} → {name:?}"); + self.creature_name_answer = Some((*entry, name.clone())); + } + SessionEvent::SpellBook { spell_ids, .. } => { + println!( + "SMSG_INITIAL_SPELLS: {} spells (first: {:?})", + spell_ids.len(), + &spell_ids[..spell_ids.len().min(8)] + ); + self.spell_book = Some(spell_ids.clone()); + } + SessionEvent::ActionButtons { buttons } => { + println!("SMSG_ACTION_BUTTONS: {} occupied slot(s)", buttons.len()); + for b in buttons.iter().take(12) { + println!( + " slot {:>3} action {:>5} kind {:#04x}", + b.slot, b.action, b.kind + ); + } + // Item-query the first item-kind button (T2 wire groundwork), once. + if self.item_asked.is_none() { + if let Some(b) = buttons.iter().find(|b| b.kind == 0x80) { + session.item_query(b.action, 0)?; + println!("sent CMSG_ITEM_QUERY_SINGLE for entry {}", b.action); + self.item_asked = Some(b.action); + } + } + self.bar_spells = Some( + buttons + .iter() + .filter(|b| b.kind == 0) + .map(|b| b.action) + .collect(), + ); + } + SessionEvent::Chat(m) => { + println!( + "chat [{:#04x}] {}{}", + m.chat_type, + m.sender_name + .as_deref() + .map(|n| format!("{n}: ")) + .unwrap_or_default(), + m.text + ); + } + SessionEvent::ItemTemplate { entry, info } => { + println!( + "SMSG_ITEM_QUERY_SINGLE_RESPONSE: entry {entry} → {:?}", + info.as_ref().map(|i| (&i.name, i.class, i.subclass)) + ); + if let Some(i) = info { + self.item_names.insert( + *entry, + format!("{} [class {} subclass {}]", i.name, i.class, i.subclass), + ); + } + self.item_answer = Some((*entry, info.as_ref().map(|i| i.name.clone()))); + } + SessionEvent::ForceSpeedChange { + guid, + kind, + counter, + speed, + } if *guid == self.self_guid => { + // Ack with our live (streamed) pose — a zeroed fallback would ask the + // server to relocate us to (0,0,0), so no known pose means no ack (the + // post-stream ensure then fails loudly instead). + let Some(t) = self.tracked.get(guid) else { + println!( + "force {kind:?} speed change before our own create streamed — cannot ack" + ); + return Ok(()); + }; + session.force_speed_ack( + *kind, + *guid, + *counter, + *speed, + t.position, + t.orientation, + )?; + println!( + "SMSG_FORCE_{kind:?}_SPEED_CHANGE: counter {counter}, {speed} yd/s — acked" + ); + self.speed_changes_seen.push((*kind, *counter, *speed)); + } + SessionEvent::Teleport { + guid, + counter, + position, + .. + } if *guid == self.self_guid => { + // Ack the same-map port (the server freezes our movement otherwise). + session.teleport_ack(*guid, *counter)?; + self.attack_pos = Some(*position); + // Move our tracked self to the landing point so range-based picks (the + // --vendor nearest-vendor search) measure from where we actually are. + if let Some(t) = self.tracked.get_mut(guid) { + t.position = *position; + } + println!( + "teleported to ({:.1}, {:.1}, {:.1}) — ack sent", + position[0], position[1], position[2] + ); + // --death: the graveyard port after release rides this same event — + // record it without duplicating the ack above. The spirit probe's + // healer_tp_landed capture (the old else-if branch) now lives in its own + // on_event: `healer_tp_sent` can only become true after `graveyard_pos` is + // Some (the healer-TP poll requires it), so the two are temporally disjoint. + if let Some(arc) = &mut self.death_arc { + if arc.repop_sent && arc.graveyard_pos.is_none() { + arc.graveyard_pos = Some(*position); + println!( + "graveyard teleport: ({:.1}, {:.1}, {:.1})", + position[0], position[1], position[2] + ); + } + } + } + SessionEvent::AttackStart { attacker, victim } => { + println!("SMSG_ATTACKSTART: {attacker:#x} → {victim:#x}"); + } + SessionEvent::AttackStop { attacker, victim } => { + println!("SMSG_ATTACKSTOP: {attacker:#x} → {victim:#x}"); + } + SessionEvent::AttackerState(s) => { + self.swings_seen += 1; + println!( + "SMSG_ATTACKERSTATEUPDATE: {:#x} → {:#x} hitInfo {:#x} damage {} victimState {}", + s.attacker, s.victim, s.hit_info, s.damage, s.victim_state + ); + } + SessionEvent::CastResult { + spell_id, + success, + reason, + } => { + println!("SMSG_CAST_RESULT: spell {spell_id} success={success} reason={reason:?}"); + if self.cast_verdict.is_none() { + self.cast_verdict = Some((*spell_id, *success, *reason)); + } else { + self.targeted_verdict = Some((*spell_id, *success, *reason)); + } + } + SessionEvent::MonsterMove { + guid, + start, + spline_id, + path, + facing, + stop, + duration_ms, + flying, + } => { + let mine = *guid == self.self_guid; + if mine { + self.self_moves += 1; + } + let speed = { + let len: f32 = path + .windows(2) + .map(|w| (w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1])) + .sum(); + len / ((*duration_ms).max(1) as f32 / 1000.0) + }; + println!( + "SMSG_MONSTER_MOVE {}{guid:#x}: splineId {spline_id}, {} pts, facing {facing:?}, {duration_ms}ms, flying={flying}, stop={stop}, ~{speed:.1} yd/s", + if mine { "[SELF] " } else { "" }, + path.len(), + ); + if mine { + println!( + " start ({:.2}, {:.2}, {:.2})", + start[0], start[1], start[2] + ); + for (i, p) in path.iter().enumerate() { + println!(" pt[{i}] ({:.2}, {:.2}, {:.2})", p[0], p[1], p[2]); + } + } + } + // Root/unroot must be acked (the echoed counter + our current pose) or the change never + // reaches observers, and the app stays gated on its own input while rooted (decision 0308 + // §1/§4). Unconditional session-keeping (like teleport/force-speed): a force-root arriving + // outside the death arc would otherwise go un-acked, and the real client always acks. + SessionEvent::MoveRoot { + guid, + counter, + rooted, + } if *guid == self.self_guid => { + let (pos, orientation) = self.self_pose(); + // A root-APPLY ack must carry MOVEFLAG_ROOT (0x1000) in its + // MovementInfo — the server KICKS one without it (vmangos + // `HandleMoveRootAck:715-723`; live-verified: the flags-0 ack drew + // "movement info does not have rooted movement flag" in Movement.log + // and the root never confirmed, so release sent no unroot). + let flags = if *rooted { 0x1000 } else { 0 }; + session.move_root_ack(*guid, *counter, *rooted, flags, pos, orientation)?; + if *rooted { + if let Some(arc) = &mut self.death_arc { + arc.rooted_seen = true; + } + println!("SMSG_FORCE_MOVE_ROOT — acked (rooted at death)"); + } else { + if let Some(arc) = &mut self.death_arc { + arc.unroot_seen = true; + } + println!("SMSG_FORCE_MOVE_UNROOT — acked (released)"); + } + } + // The ghost form's walk-on-water grant/removal — same ack shape as + // MoveRoot, plus the trailing apply flag; both directions ack here. + SessionEvent::WaterWalk { guid, counter, on } if *guid == self.self_guid => { + let (pos, orientation) = self.self_pose(); + // The faithful echo carries MOVEFLAG_WATERWALKING (0x10000000) while + // applied (the toggle-ack path doesn't hard-require it like root). + let flags = if *on { 0x1000_0000 } else { 0 }; + session.water_walk_ack(*guid, *counter, *on, flags, pos, orientation)?; + if *on { + if let Some(arc) = &mut self.death_arc { + arc.water_walk_seen = true; + } + println!("SMSG_MOVE_WATER_WALK — acked (ghost walk-on-water granted)"); + } else { + println!("SMSG_MOVE_LAND_WALK — acked (water-walk removed)"); + } + } + SessionEvent::CorpseReclaimDelay { delay_ms } => { + if let Some(arc) = &mut self.death_arc { + arc.reclaim_delay_ms = Some(*delay_ms); + println!("SMSG_CORPSE_RECLAIM_DELAY: {delay_ms} ms"); + } + } + _ => {} + } + Ok(()) + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs new file mode 100644 index 00000000..ee8a1897 --- /dev/null +++ b/crates/benilla-protocol/src/events.rs @@ -0,0 +1,903 @@ +//! Decoded session events — the codec's typed output. +//! +//! `benilla-protocol` decodes the world stream into a flat list of [`SessionEvent`]s; the running +//! world model (which entities exist, where they are, the clock) is the **app's** job (the ECS), not +//! the codec's. One `SMSG_(COMPRESSED_)UPDATE_OBJECT` fans out into many create/move/remove events; a +//! `SMSG_MONSTER_MOVE` becomes a path the app turns into a spline; the clock packet becomes a time +//! sample the app advances. Only primitives + the coarse [`EntityKind`] classification cross this +//! boundary. +//! +//! Coordinates stay **raw WoW** (the `benilla` boundary applies `bevy = (-y, z, -x)`). + +use crate::messages::{ + ActionButton, AttackerState, ChannelNoticeTail, Character, DamageShield, + EnvironmentalDamageLog, GossipOption, GroupLootInfo, GroupMemberEntry, ItemInfo, + ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, + LootStartRoll, MailListEntry, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, + PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, + QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, + TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, XpGain, +}; + +/// Coarse entity classification, free of wire types so the app can branch on it without depending on +/// the message layer. Part of the codec's output vocabulary (carried on [`SessionEvent::ObjectCreate`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EntityKind { + Player, + Unit, + GameObject, + Other, +} + +/// Which character-management verb produced a [`SessionEvent::CharActionResult`] — so the screen can +/// phrase the outcome ("created"/"deleted") and map the `WorldResult` code against the right family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CharAction { + Create, + Delete, +} + +/// A unit's 6 movement speeds (yd/s), decoded from its `LIVING` block. The animation selector keys its +/// walk-vs-run boundary on `walk` (2× it → run, RF-0057); the net bridge extrapolates a remote mover +/// between movement packets at `run`/`run_back`/`swim` and turns it in place at `turn_rate` (rad/s). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct MoveSpeeds { + pub walk: f32, + pub run: f32, + pub run_back: f32, + pub swim: f32, + pub swim_back: f32, + pub turn_rate: f32, +} + +impl MoveSpeeds { + /// From the wire order `[walk, run, run_back, swim, swim_back, turn_rate]` (RF-0058). + fn from_wire(s: [f32; 6]) -> Self { + Self { + walk: s[0], + run: s[1], + run_back: s[2], + swim: s[3], + swim_back: s[4], + turn_rate: s[5], + } + } +} + +/// Where a login attempt currently is — the IO thread emits these as it walks the pre-roster +/// sequence (decision 0539), and the login screen's connecting dialog quotes the matching +/// `LOGIN_STATE_*` glue string for each. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginStage { + /// Dialing the auth server (`LOGIN_STATE_CONNECTING`). + Connecting, + /// SRP6 challenge/proof in flight (`LOGIN_STATE_AUTHENTICATING`). + Authenticating, + /// Realm picked; the world-socket handshake + char enum (`LOGIN_STATE_HANDSHAKING`). + Handshaking, +} + +/// One decoded event from the world stream. Carries only primitives + the coarse [`EntityKind`] +/// classification — no wire types leak to the app, and no running state lives here. +#[derive(Debug, Clone)] +pub enum SessionEvent { + /// A login attempt progressed to `stage` (decision 0539) — IO-thread-emitted, like + /// [`Self::CharacterList`], never wire-decoded. + LoginStage { stage: LoginStage }, + /// A login attempt failed before the roster (decision 0539): `code` is the server's auth + /// result byte ([`crate::auth::AuthReject`]) when the server *refused* us, `None` for a + /// transport failure (dial/handshake error). The IO thread is re-parked pre-logon; the app + /// decides whether to resubmit (transport, with intent) or surface the dialog (refusal). + /// `terminal` marks a failure that *retrying cannot fix* even though it carries no auth code — + /// the server is simply unusable by this client (e.g. [`crate::WardenRequired`]). The app must + /// surface `reason` and stop, never fold it into the paced-resubmit path. + LoginFailed { + code: Option, + reason: String, + terminal: bool, + }, + /// The account's character roster (`SMSG_CHAR_ENUM`): the world socket is authenticated and + /// parked at character select. The IO thread emits this each connection cycle, then waits for + /// the app's pick (its pick channel) before `CMSG_PLAYER_LOGIN` moves the session into the world. + /// `realm` is the auth realm-list entry this session connected to (the select screen's realm + /// name + type banner) — `None` only when the packet arrives outside the IO thread's own emit + /// (the raw [`crate::decode`] path has no auth context). + CharacterList { + characters: Vec, + realm: Option, + }, + /// The result of a character create or delete the IO thread serviced in place while parked at + /// select (`SMSG_CHAR_CREATE` / `SMSG_CHAR_DELETE` result byte — decision 0423). On a successful + /// create/delete a fresh [`Self::CharacterList`] is emitted *before* this, so the roster is + /// already up to date when the screen shows the outcome. `code` is the raw `WorldResult` + /// (`CHAR_CREATE_SUCCESS`, a `CHAR_NAME_*` code, …) for the app to map to its status text. + CharActionResult { action: CharAction, code: u8 }, + /// We are in the world: `self_guid` is our player, `name` its character name. The IO thread emits + /// this first, before any object updates. + Connected { self_guid: u64, name: String }, + /// The server confirmed our logout (`SMSG_LOGOUT_COMPLETE`) — we are back at character select. + /// The IO thread cycles the connection immediately; a fresh [`Self::CharacterList`] follows. + LoggedOut, + /// The session ended (socket closed / handshake failure); carries a human-readable reason. + Disconnected { reason: String }, + /// An object entered range / was created: a unit, player, or GameObject at a raw-WoW pose. Carries + /// the interpreted spawn identity (`display_id`/`scale`, decoded per object type here so the app + /// needn't know the wire layout) plus the full descriptor `fields` mask the ECS seeds its per-object + /// store from (decision 0061). + ObjectCreate { + guid: u64, + kind: EntityKind, + /// The model display id (`UNIT_FIELD_DISPLAYID` / `GAMEOBJECT_DISPLAYID`), interpreted per type; + /// `None` when absent/zero. Present raw in `fields` too, but decoded here (unit-vs-GameObject). + display_id: Option, + position: [f32; 3], + orientation: f32, + /// `OBJECT_FIELD_SCALE_X` — the per-object size multiplier (see [`object_scale`]). `1.0` when + /// absent; for a GameObject this *is* its size (no DBC scale column in 5875). + scale: f32, + /// The unit's 6 movement speeds from its `LIVING` block; `None` for a GameObject. Seeds the + /// animation selector's walk-vs-run boundary (RF-0057) and the net bridge's remote-mover + /// extrapolation. (Movement-block data, not a descriptor field — hence not in `fields`.) + speeds: Option, + /// A transport GameObject's (`UPDATE_FLAG_TRANSPORT`) path-progress anchor — `Some` only for a + /// type-15/type-11 transport create (a boat/zeppelin/elevator). Decision 0438's cycle anchor: + /// `anchor = this value`, `t₀ = Instant::now()` on create/re-create; per frame `progress = + /// anchor + elapsed_ms`, position = `timetable(progress % period)`. + transport_progress: Option, + /// This object's own rider pose on a transport (`MOVEFLAG_ON_TRANSPORT` in its `LIVING` block) — + /// `Some` when a freshly-created unit/player is standing on a boat/zeppelin/elevator at create + /// time, local to that transport's frame (decision 0438). + transport: Option, + /// The object's full descriptor field set from the create mask — health/level/appearance/ + /// customization and everything else. The ECS seeds its `ObjectStore` from this, then merges + /// later [`Self::ObjectValues`] deltas into it (decision 0061). + fields: ObjectFields, + }, + /// An item or container entered our view: our own inventory streaming in at login, a looted drop, + /// a traded bag. A pure **descriptor** object — its create block carries no pose (wire-verified: + /// vmangos sends `UPDATEFLAG_ALL` + a constant, nothing spatial), so it never becomes a scene + /// entity. The app seeds its item store from `fields` and merges later [`Self::ObjectValues`] / + /// removes by guid, exactly like the scene store. Which *slot* holds the item is not here — that + /// lives in the owning player/bag descriptor (`PLAYER_FIELD_INV_SLOT`/`PACK_SLOT`, + /// `CONTAINER_FIELD_SLOT`) as this guid. + ItemCreate { + guid: u64, + /// `true` for a container (a bag — its fields carry `CONTAINER_FIELD_SLOT_*` contents), + /// `false` for a plain item. + container: bool, + /// The item's full descriptor set: `OBJECT_FIELD_ENTRY` (→ the template for name/icon via + /// `ITEM_QUERY_SINGLE`), stack count, durability, enchants; container slots when `container`. + fields: ObjectFields, + }, + /// An existing object moved to a new authoritative pose (supersedes any active path). From an + /// `SMSG_UPDATE_OBJECT` movement block — a one-off correction/relocation, not live locomotion. + ObjectMove { + guid: u64, + position: [f32; 3], + orientation: f32, + }, + /// A relayed *player* movement packet (`MSG_MOVE_*`): the mover's authoritative pose plus its live + /// CMovement `moveFlags`. This is how another player's continuous walking/turning/strafing reaches + /// us (creatures move via [`Self::MonsterMove`] splines instead). The app snaps to `position` and + /// extrapolates from `flags` between packets so motion stays smooth at the ~2 Hz relay rate. + /// `fall_time` (ms airborne) + `jump` (the ballistic launch) are present while `JUMPING` is set, so + /// the app replays a jump as one arc instead of stair-stepping its height (decision 0053). + /// `transport` is `Some` while `MOVEFLAG_ON_TRANSPORT` is set: the mover's local pose on the named + /// transport, for an observed rider to re-anchor through that transport's live matrix (decision + /// 0438) rather than snap to `position` as a world coordinate. + UnitMove { + guid: u64, + position: [f32; 3], + orientation: f32, + flags: u32, + /// The swim pitch (radians, +up) — the mover's 3D travel angle while `MOVEFLAG_SWIMMING` + /// is set (`0.0` otherwise): the wire tail exists so observers can integrate a swimmer's + /// vertical between packets (the client's swim velocity basis adds pitch, `0x7c5880`). + pitch: f32, + /// The `MovementInfo` time word — vmangos's own ms clock stamped at receipt (`stime`), one + /// coherent server clock across all movers. The reference schedules a remote's apply from + /// this (fire-time = mapped time + clamped skew, wow-re `remote-apply-timing.md`); the app + /// mirrors that in `RelayClock` (decision 0601). + time: u32, + /// True for `MSG_MOVE_HEARTBEAT` — the periodic mid-move pulse. The reference's reconcile + /// lerp is armed only for NON-heartbeat events (`0x619090` excludes tag `0x26`); a + /// heartbeat applies as an outright snap (decision 0601). + heartbeat: bool, + fall_time: u32, + jump: Option, + transport: Option, + }, + /// A descriptor `Values` update: the fields this packet changed (`UpdateFields`; WoW resends only + /// what changed, so a health-only hit carries just that), for the ECS to merge into the object's + /// store (decision 0061). Emitted from `Object::Values`; a create's *initial* fields ride on + /// [`Self::ObjectCreate`] instead. Never empty (the codec drops an empty delta). + ObjectValues { guid: u64, fields: ObjectFields }, + /// Objects that left our view range (the update-object `OutOfRange` block). They still exist + /// server-side — the real client demotes them to a staging table for cheap re-create. + ObjectsRemoved(Vec), + /// One object destroyed outright (`SMSG_DESTROY_OBJECT` — a corpse decaying ahead of respawn, a + /// despawn): it ceases to exist. The real client frees it on the spot — an **instant pop**, no + /// fade-out; the only lifecycle fade is the appear-fade on a fresh create (wow-re object-layer, + /// selection-death-clear RE). The corpse → destroy → fresh-create-on-respawn sequence is why a + /// respawn never stands the old corpse up. + ObjectDestroyed(u64), + /// The server asked us to play a cinematic (`SMSG_TRIGGER_CINEMATIC`): a character's + /// first-ever login (the race intro) or a GameObject camera. The app must answer + /// `CMSG_COMPLETE_CINEMATIC` when it ends — or immediately, to skip — because vmangos anchors + /// object visibility to the flying cinematic camera while one runs unacked + /// (`Player::UpdateCinematic`): the world around the body despawns until the ack arrives. + CinematicTriggered { cinematic_id: u32 }, + /// A server-dictated movement path: the unit traverses `path` — the full travel-order polyline + /// `[start, …waypoints…, endpoint]` — at constant (arc-length) speed over `duration_ms`. Every + /// waypoint is carried, so a curved patrol reads as its real path, not a straight `start → endpoint` + /// shortcut. A `Stop` / zero-duration / <2-point move clears the path (`path` empty). + MonsterMove { + guid: u64, + start: [f32; 3], + /// The server's per-move spline counter — echoed in `CMSG_MOVE_SPLINE_DONE` when this spline + /// drives our own player (Charge/knockback/taxi); ignored for a creature's walk. + spline_id: u32, + path: Vec<[f32; 3]>, + /// The dictated final facing (`moveType` 2/3/4), applied as a snap. [`MonsterMoveFacing::None`] + /// for a plain move — the unit faces its travel direction along the path. This is how a + /// creature re-faces **without walking** (aggro/scripted/emote), the "won't turn to face me" fix. + facing: MonsterMoveFacing, + stop: bool, + duration_ms: u32, + /// `true` ⇒ a 3-D flight path (keep the spline's Z); `false` ⇒ a ground walk whose Z the app + /// re-derives from the terrain under the unit (see the renderer's creature ground-clamp). + flying: bool, + }, + /// Same-map teleport (`MSG_MOVE_TELEPORT_ACK`): the app snaps our player + echoes the ack. + Teleport { + guid: u64, + counter: u32, + position: [f32; 3], + orientation: f32, + }, + /// Cross-map worldport (`SMSG_NEW_WORLD`, `needs_ack = true`) or the initial-login map + /// announcement (`SMSG_LOGIN_VERIFY_WORLD`, `needs_ack = false`): load the new map's ADTs. + /// While a pending transfer named a transport, `position`/`orientation` are **boat-local** + /// (decision 0455 — vmangos `SendNewWorld` sends `GetTransportPos()` when riding). + Worldport { + map_id: u32, + position: [f32; 3], + orientation: f32, + needs_ack: bool, + }, + /// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): a worldport to `map_id` follows. + /// `transport_entry` is set iff the player rides that transport through the transfer — the + /// signal that the coming [`Self::Worldport`] carries boat-local coordinates (decision 0455). + TransferPending { + map_id: u32, + transport_entry: Option, + }, + /// `SMSG_TRANSFER_ABORTED`: the announced transfer will not happen (map full, no instance); + /// clears the pending-transfer latch. + TransferAborted { reason: u8 }, + /// The server in-game clock (`SMSG_LOGIN_SETTIMESPEED`): drives time-of-day lighting. + TimeSpeed { + hours: u8, + minutes: u8, + /// Monotonic day count (`year·372 + month·31 + day` of the packed server date) — the + /// celestial moon-phase precession input. + day_serial: u32, + timescale: f32, + }, + /// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the + /// `$z` token names ("Returns you to ."). + BindPoint { area: u32 }, + /// The player's equip proficiencies for one item class (`SMSG_SET_PROFICIENCY`, at login + + /// on train): the subclass bitmask the item tooltip's slot-line red compares against + /// (the client's `0xc4d4a0[class]` store). + Proficiency { item_class: u32, subclass_mask: u32 }, + /// The player's reputation store (`SMSG_INITIALIZE_FACTIONS`, once at login): `(flags, standing)` + /// per reputation-list slot, indexed by `Faction.dbc`'s `reputationIndex`. The standing excludes + /// the DBC race/class base — consumers add it before ranking. Drives the reputation branch of + /// unit reaction (a reputation faction's NPCs colour by the player's rank, not faction templates). + Reputations { standings: Vec<(u8, i32)> }, + /// Mid-session reputation deltas (`SMSG_SET_FACTION_STANDING`): `(reputationListId, + /// standing)` per changed slot — same standing convention as [`Self::Reputations`]. + ReputationDelta { standings: Vec<(u32, i32)> }, + /// A player character's identity (`SMSG_NAME_QUERY_RESPONSE`, answering our `CMSG_NAME_QUERY`). + /// Unit names are **not** descriptor fields on the 1.12 wire — they arrive only through this + /// query pair (players) and [`Self::CreatureName`] (creatures); the app caches them by guid/entry + /// (decision 0068 §3's query-cache seam). An unknown guid answers with an empty `name`. + PlayerName { + guid: u64, + name: String, + race: u32, + gender: u32, + class: u32, + }, + /// A creature template's display name (`SMSG_CREATURE_QUERY_RESPONSE`). Keyed by template + /// `entry` (shared by every spawn of that template), not by spawn guid. `name` is `None` when + /// the server flagged the entry unknown; `subname` is the tooltip line ("Stable Master", …). + CreatureName { + entry: u32, + name: Option, + subname: Option, + /// The template's `CreatureType.dbc` id (Beast, Humanoid, Critter, …) — the TAB-target + /// critter/totem filter's input. `None` on a server miss. + creature_type: Option, + /// Elite rank 0..4 (the unit tooltip's rank word, decision 0276). `0` on a miss. + rank: u32, + /// The template type flags — bit `0x10` hides the tooltip's faction-name line. `0` on a miss. + type_flags: u32, + /// The civilian flag (the tooltip's green CIVILIAN line). `false` on a miss. + civilian: bool, + /// The racial-leader flag (the tooltip's white LEADER line). `false` on a miss. + racial_leader: bool, + }, + /// A GameObject template's type/display/name/`data[24]` head (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, + /// answering our `CMSG_GAMEOBJECT_QUERY`) — the ask-once GO template lookup, decision 0236. + /// Keyed by template `entry` (shared by every spawn of that template), not spawn guid. `data[24]` + /// is the type-specific raw tail (e.g. a chest's lockId lives at a type-specific slot); resolving + /// a slot is a later consumer's job. On a server miss (unknown entry) this still fires, with + /// `type_id`/`display_id` zeroed, `name` empty, and `data` all zero — the flattened-field twin of + /// how [`Self::CreatureName`] answers a miss with `None`s. + GameObjectInfo { + entry: u32, + type_id: u32, + display_id: u32, + name: String, + data: [i32; 24], + }, + /// A server-pushed 2D sound kit (`SMSG_PLAY_SOUND`): BG events, quest/zone scripts. + PlaySound { sound_id: u32 }, + /// A server-pushed music kit for the music channel (`SMSG_PLAY_MUSIC`). + PlayMusic { music_id: u32 }, + /// A server-pushed 3D sound kit at a source object (`SMSG_PLAY_OBJECT_SOUND`) — e.g. the + /// fishing-bobber splash. The guid positions the emitter. + PlayObjectSound { sound_id: u32, guid: u64 }, + /// The zone's weather state (`SMSG_WEATHER`): `sound_id` is a SoundEntries loop kit + /// (8533..8558 rain/snow/sandstorm, 0 = clear); `weather_type`/`grade`/`instant` also feed + /// the weather visuals when they exist. + Weather { + weather_type: u32, + grade: f32, + sound_id: u32, + instant: bool, + }, + /// A nearby unit's chat emote (`SMSG_TEXT_EMOTE`): the `EmotesText.dbc` id; the guid names + /// the performer (voice race/sex resolve from its descriptor). + TextEmote { guid: u64, text_emote: u32 }, + /// A unit's anim emote (`SMSG_EMOTE`): the `Emotes.dbc` id (drives the anim + its + /// `EventSoundID`). + Emote { guid: u64, emote_id: u32 }, + /// The player's spell book (`SMSG_INITIAL_SPELLS`, once at login): the known spell ids, + /// widened from the wire's `u16`s, plus the active-cooldown list (the seed of the + /// cooldown store — decision 0137 phase 4). + SpellBook { + spell_ids: Vec, + cooldowns: Vec, + }, + /// The player's saved action bar (`SMSG_ACTION_BUTTONS`, once at login): the occupied slots + /// (0..119; 0–11 = the main bar). Feeds the FrameXML action bar (decision 0068 slice 1). + ActionButtons { buttons: Vec }, + /// A spell added to the book after login (`SMSG_LEARNED_SPELL`): trainer/quest/level-up, widened + /// from the wire `u16`. The first post-login spell-book mutation (decision 0237). + SpellLearned { spell_id: u32 }, + /// A rank-up (`SMSG_SUPERCEDED_SPELL`): the new rank replaces the old in the book and on the + /// action bar. Both ids widened from the wire `u16`. + SpellSuperceded { + old_spell_id: u32, + new_spell_id: u32, + }, + /// The server's verdict on our cast (`SMSG_CAST_RESULT`): `reason` is the failure code when + /// `success` is false (the client's error-text table keys on it; surfaced raw for now). + CastResult { + spell_id: u32, + success: bool, + reason: Option, + }, + /// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our + /// `CMSG_ITEM_QUERY_SINGLE`). Keyed by template entry; `None` = the server doesn't know it + /// (undiscovered) — cached negative, like an unknown creature entry. Boxed for the same reason + /// as [`crate::messages::ServerPacket::ItemQueryResponse`]: the full item template is wide, and + /// this is one variant among many tiny, hot ones. + ItemTemplate { + entry: u32, + info: Option>, + }, + /// One inbound chat line (`SMSG_MESSAGECHAT`) — say/yell/system/NPC/channel. System lines + /// (type `0x0A`) carry GM command feedback. Carries the sender's `chat_tag` (AFK/DND/GM) for + /// the ``/``/`` name-prefix the real chat frame renders. + Chat(crate::messages::ChatMessage), + /// A channel join/leave/error/moderation notice (`SMSG_CHANNEL_NOTIFY`) — `tail` is the payload + /// selected by `notice` (see [`ChannelNoticeTail`]). + ChannelNotify { + notice: u8, + channel: String, + tail: ChannelNoticeTail, + }, + /// A channel's member roster (`SMSG_CHANNEL_LIST`, answering our `CMSG_CHANNEL_LIST`): `(guid, + /// memberFlags)` per row (owner/moderator/voiced/muted/custom/mic-muted — vmangos + /// `Chat/Channel.h:119-130`). + ChannelList { + channel: String, + flags: u8, + members: Vec<(u64, u8)>, + }, + /// A whisper target wasn't found online (`SMSG_CHAT_PLAYER_NOT_FOUND`). + ChatPlayerNotFound { name: String }, + /// A cross-faction whisper was refused (`SMSG_CHAT_WRONG_FACTION`); empty body. + ChatWrongFaction, + /// A server notice (`SMSG_NOTIFICATION`) — pre-formatted text the real client flashes in the + /// red UIErrorsFrame ("You do not know that language", trade refusals, and kin). + Notification { text: String }, + /// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time + /// since the last level-up, both in seconds. + PlayedTime { total: u32, level: u32 }, + /// The server's `/random` broadcast (`MSG_RANDOM_ROLL`): the rolled range, the result, and the + /// roller's guid. + RandomRoll { + min: u32, + max: u32, + roll: u32, + guid: u64, + }, + /// The server refused an inventory operation (`SMSG_INVENTORY_CHANGE_FAILURE` — equip level, + /// proficiency, bag full, …): the UI error line's inventory vocabulary, the equip twin of + /// [`Self::CastResult`]'s failure path. `required_level` rides only on the level refusal. + InventoryFailure { + reason: u8, + required_level: Option, + item_guid: u64, + }, + /// A unit began melee auto-attack (`SMSG_ATTACKSTART` — including the echo of our own + /// `CMSG_ATTACKSWING`). + AttackStart { attacker: u64, victim: u64 }, + /// A unit stopped melee auto-attack (`SMSG_ATTACKSTOP`). + AttackStop { attacker: u64, victim: u64 }, + /// One completed melee swing (`SMSG_ATTACKERSTATEUPDATE`) — the attacker's swing-animation + /// trigger (decision 0073: one packet = one swing, no client timer). + AttackerState(AttackerState), + /// A creature flared at someone (`SMSG_AI_REACTION`): reaction 2 = HOSTILE (sent on every + /// creature melee-attack start), 0 = ALERT (stealth pre-aggro detection); any other value is + /// a no-op. Pure audio in the client — the aggro/alert vocals (decision 0280). + AiReaction { unit: u64, reaction: u32 }, + /// A unit began a non-triggered cast (`SMSG_SPELL_START`), instants included (`cast_time_ms == + /// 0`) — the precast trigger the phase-2 casting animation loop builds on (decision 0099 phase + /// 1). `target` is the explicit unit target, when the spell's target block carries one. + SpellStart { + caster: u64, + spell_id: u32, + cast_flags: u16, + cast_time_ms: u32, + target: Option, + /// The nocked-ammo display id (the `CAST_FLAG_AMMO` tail, ranged spells only) — feeds the + /// caster's worn ammo model (the client's `0x60ba30` @ START `0x6e78b6`, any caster; + /// wow-re `nocked-ammo-cancel.md`). `None` when the flag is clear. + ammo_display_id: Option, + }, + /// The cast launched (`SMSG_SPELL_GO`): hit/miss lists + (for a ranged spell) the ammo display + /// id for the projectile visual. The server schedules impact itself off `Spell.dbc` Speed — + /// nothing about missile travel rides this packet. + SpellGo { + caster: u64, + spell_id: u32, + cast_flags: u16, + hits: Vec, + misses: Vec<(u64, u8)>, + target: Option, + /// The GameObject an open-lock cast launched at (`TARGET_FLAG_GAMEOBJECT`) — opens a chest lid / + /// locked door (decision 0250). `None` for a unit spell. + go_target: Option, + ammo_display_id: Option, + /// The **cast item's** guid when the packet's first guid names one (an item use — potion, + /// scroll: `item_or_caster != caster`); `None` for a plain spell cast. The item-use + /// cooldown keys on it (decision 0137 phase 4 — the client resolves its SPELLCAST item + /// the same way). + item_caster: Option, + }, + /// An observed cast was interrupted/cancelled (`SMSG_SPELL_FAILED_OTHER`) — ends the caster's + /// `Casting` state seam the same as a [`Self::SpellGo`]. + SpellFailedOther { caster: u64, spell_id: u32 }, + /// Our own cast was pushed back by damage (`SMSG_SPELL_DELAYED`) — the cast bar slides its + /// window out by `delay_ms` instead of finishing early (decision 0256). + SpellDelayed { caster: u64, delay_ms: u32 }, + /// Stop our own ranged auto-repeat visual (`SMSG_CANCEL_AUTO_REPEAT`, self-only). No + /// ranged-attack consumer exists yet (decision 0099 phase 5). + CancelAutoRepeat, + /// Server-pushed cooldowns (`SMSG_SPELL_COOLDOWN`) for `caster` (the player or pet): pairs of + /// `(spell_id, cooldown_ms)`, where `0` ms means "use the spell's own Spell.dbc recovery + /// times" (decision 0137 phase 4; the school-lockout / pet path — a normal cast's cooldown is + /// client-tracked). + SpellCooldowns { + caster: u64, + cooldowns: Vec<(u32, u32)>, + }, + /// Put an item instance on the client's fixed 30 s use cooldown (`SMSG_ITEM_COOLDOWN`). + ItemCooldown { item_guid: u64, spell_id: u32 }, + /// Start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's parked timers now + /// (`SMSG_COOLDOWN_EVENT`). + CooldownEvent { spell_id: u32, caster: u64 }, + /// Remove one spell's cooldown record (`SMSG_CLEAR_COOLDOWN`). + ClearCooldown { spell_id: u32, caster: u64 }, + /// Wipe every cooldown for `caster` (`SMSG_COOLDOWN_CHEAT`, the GM reset). + CooldownCheat { caster: u64 }, + /// Our own channeled cast opened (`MSG_CHANNEL_START`, self-only — no guid on the wire). The + /// cast bar's channel-open edge (decision 0137). + ChannelStart { spell_id: u32, duration_ms: u32 }, + /// Our own channel's remaining time (`MSG_CHANNEL_UPDATE`, self-only); `0` = the channel is + /// over — natural end and interrupt alike (decision 0137). + ChannelUpdate { remaining_ms: u32 }, + /// How long one of **our own** auras has left (`SMSG_UPDATE_AURA_DURATION`), keyed by its + /// `UNIT_FIELD_AURA` slot. Self-only, never sent for a permanent aura, and it arrives *before* + /// the descriptor delta that names the slot's spell — so the aura feed buffers it by slot and + /// joins on the spell present when it lands (decisions 0255/0257). + AuraDuration { slot: u8, remaining_ms: u32 }, + /// Play a spell-visual kit on a unit outside the normal cast sequence (`SMSG_PLAY_SPELL_VISUAL` + /// — eat/drink kits, a channel-kit refresh workaround). No VFX consumer yet (decision 0099 + /// phase 3). + PlaySpellVisual { unit: u64, kit_id: u32 }, + /// Non-melee (spell) damage dealt (`SMSG_SPELLNONMELEEDAMAGELOG`) — decision 0137 phase 2's + /// floating-combat-text data feed. + SpellDamageLog(SpellDamageLog), + /// Periodic (DoT/HoT/regen) aura ticks (`SMSG_PERIODICAURALOG`) — decision 0137 phase 2. + PeriodicAuraLog(PeriodicAuraLog), + /// A direct heal landing (`SMSG_SPELLHEALLOG`) — decision 0578's center-combat-text feed. + SpellHealLog(SpellHealLog), + /// An instant power gain (`SMSG_SPELLENERGIZELOG`) — decision 0578. + SpellEnergizeLog(SpellEnergizeLog), + /// A damage-shield (Thorns-style) return hit (`SMSG_SPELLDAMAGESHIELD`) — decision 0137 phase 2. + DamageShield(DamageShield), + /// Environmental damage taken — fall/drowning/fatigue/lava/slime/fire + /// (`SMSG_ENVIRONMENTALDAMAGELOG`): the client-side feedback trigger (the fall-landing dust + /// kit via `EnvironmentalDamage.dbc`). + EnvironmentalDamageLog(EnvironmentalDamageLog), + /// A spell cast's per-target miss list (`SMSG_SPELLLOGMISS`) — decision 0137 phase 2. + SpellLogMiss(SpellLogMiss), + /// An XP award, kill or non-kill (`SMSG_LOG_XPGAIN`) — decision 0137 phase 2. + XpGain(XpGain), + /// Our own ding (`SMSG_LEVELUP_INFO`, self-addressed only) — decision 0304. + LevelUp(LevelUpInfo), + /// A gossip menu opened on an NPC (`SMSG_GOSSIP_MESSAGE`, answering our `CMSG_GOSSIP_HELLO`): + /// `text_id` drives a follow-up [`crate::messages::npc_text_query`] for the greeting body; + /// `options` are the selectable lines (icon + coded flag + label). `quests` are the quest-option + /// rows riding the same packet — `(quest_id, dialog-status icon, title)`; the gossip window lists + /// them and a click sends `CMSG_QUESTGIVER_QUERY_QUEST` (decision 0088). + GossipMenu { + npc: u64, + text_id: u32, + options: Vec, + quests: Vec<(u32, u32, String)>, + }, + /// A questgiver dialog status for one NPC (`SMSG_QUESTGIVER_STATUS`) — the `!`/`?` marker's + /// [`crate::messages::dialog_status`] value. Stored per guid now; the world marker is a later + /// slice (decision 0088). + QuestGiverStatus { npc: u64, status: u32 }, + /// The greeting panel: an NPC's offered/active quest rows (`SMSG_QUESTGIVER_QUEST_LIST`). + QuestGreeting(QuestGiverList), + /// The accept panel: full quest text + rewards on offer (`SMSG_QUESTGIVER_QUEST_DETAILS`). + QuestDetail(QuestDetails), + /// The progress panel: "bring me these" text + required items/money + completability + /// (`SMSG_QUESTGIVER_REQUEST_ITEMS`). + QuestProgress(QuestRequestItems), + /// The reward panel: turn-in text + rewards to grant (`SMSG_QUESTGIVER_OFFER_REWARD`). + QuestOffer(QuestOfferReward), + /// The turn-in result: XP/money granted + fixed items (`SMSG_QUESTGIVER_QUEST_COMPLETE`). + QuestComplete(QuestComplete), + /// The full quest template (`SMSG_QUEST_QUERY_RESPONSE`, answering our `CMSG_QUEST_QUERY`) — + /// the quest log's ask-once detail source, cached by `quest_id`. + QuestTemplate(Box), + /// A kill/use objective ticked (`SMSG_QUESTUPDATE_ADD_KILL`) — the "Kobold Vermin slain: 3/10" + /// toast. `entry` carries the raw creature/GO encoding (GO = `(−id)|0x80000000`); the durable + /// count also lands in the `PLAYER_QUEST_LOG` slot's counter field, this is the announce line. + QuestObjectiveKill { + quest_id: u32, + entry: u32, + count: u32, + required: u32, + }, + /// An item-collection objective toast (`SMSG_QUESTUPDATE_ADD_ITEM`). + QuestObjectiveItem { item_id: u32, count: u32 }, + /// Every objective on the quest is complete (`SMSG_QUESTUPDATE_COMPLETE`) — the "Quest + /// completed" line; the slot's state byte carries the durable fact. + QuestObjectivesComplete { quest_id: u32 }, + /// The quest failed (`SMSG_QUESTUPDATE_FAILED` / `_FAILEDTIMER` — `timed` picks which). + QuestFailed { quest_id: u32, timed: bool }, + /// The log refused a new quest — no free slot (`SMSG_QUESTLOG_FULL`). + QuestLogFull, + /// A questgiver interaction was rejected (`SMSG_QUESTGIVER_QUEST_INVALID` msg code / + /// `SMSG_QUESTGIVER_QUEST_FAILED` reason code — `quest_id` is `0` for INVALID, which + /// doesn't name the quest). + QuestGiverRefused { quest_id: u32, reason: u32 }, + /// The gossip window closes (`SMSG_GOSSIP_COMPLETE`) — no menu is open server-side any more. + GossipComplete, + /// The greeting text for a gossip menu (`SMSG_NPC_TEXT_UPDATE`, answering our + /// `CMSG_NPC_TEXT_QUERY`): the highest-probability text block, already resolved male-with- + /// female-fallback. `$N` (the player name) is a client-substituted token still in the string. + NpcGreeting { text_id: u32, greeting: String }, + /// A vendor's stock (`SMSG_LIST_INVENTORY`, answering our `CMSG_LIST_INVENTORY`): entry, icon, + /// price, and remaining stock per row (`current_count == 0xFFFF_FFFF` = unlimited). + VendorInventory { vendor: u64, items: Vec }, + /// A trainer's service list (`SMSG_TRAINER_LIST`, reached via the gossip trainer option): the + /// wire services (each a spell id + cost + green/red/gray state + level/skill/ability gates), the + /// window-framing `trainer_type` (0 class · 1 mount · 2 tradeskill · 3 pet), and the greeting + /// title. The app resolves each service to name/icon through its spell catalog (decision 0237). + TrainerList { + trainer: u64, + trainer_type: u32, + services: Vec, + greeting: String, + }, + /// A trainer taught a service (`SMSG_TRAINER_BUY_SUCCEEDED`, answering `CMSG_TRAINER_BUY_SPELL`): + /// confirmation only — the spell itself arrives via `SMSG_LEARNED_SPELL` (already in the book). + /// The app re-requests `CMSG_TRAINER_LIST` on this to repaint the bought row green→gray (decision + /// 0237: the server never auto-resends the list on a buy). + TrainerBuySucceeded { trainer: u64, spell_id: u32 }, + /// A trainer refused a purchase (`SMSG_TRAINER_BUY_FAILED`): `error` is a + /// [`crate::messages::train_fail`] code (0 unavailable · 1 not-enough-money · 2 not-enough-skill). + /// Surfaces on the trainer window's error line. + TrainerBuyFailed { + trainer: u64, + spell_id: u32, + error: u32, + }, + /// A vendor's stock updated after a purchase (`SMSG_BUY_ITEM`). The purchased item itself + /// arrives through the normal item-create + inventory-slot update path, already handled. + VendorBuyResult { + vendor: u64, + slot: u32, + new_count: u32, + purchase_count: u32, + }, + /// A sell was refused (`SMSG_SELL_ITEM`'s error path — a success sends no packet at all). + /// `reason` is a `SellResult` code ([`crate::messages::sell_result`]). + VendorSellFailed { + vendor: u64, + item_guid: u64, + reason: u8, + }, + /// A purchase was refused (`SMSG_BUY_FAILED`). `reason` is a `BuyResult` code + /// ([`crate::messages::buy_result`]). + VendorBuyFailed { + vendor: u64, + item_entry: u32, + reason: u8, + }, + /// The bank window opens (`SMSG_SHOW_BANK`), answering our `CMSG_BANKER_ACTIVATE` — or + /// arriving unprompted for the `GOSSIP_OPTION_BANKER` gossip option, so a handler must not + /// assume it always follows our own activate. The vault contents are already in the player + /// descriptor (decision 0604); this only opens the window. + ShowBank { banker: u64 }, + /// A bank-slot purchase was refused (`SMSG_BUY_BANK_SLOT_RESULT`). `result` is a `BankSlotResult` + /// code ([`crate::messages::bank_slot_result`]); a successful buy sends no packet, visible only + /// as the `PLAYER_BYTES_2` bank-bag-count byte advancing + the coinage drop. + BuyBankSlotResult { result: u32 }, + /// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape), answering our `CMSG_LOOT`: + /// `loot_type` is a `loot::loot_type` code, `items` the row list (quest rows ride the same + /// list, `slot = items.len() + i`). A row still under a group roll arrives with + /// `slot_type == ROLL_ONGOING` (decision 0591); master loot stays out of scope. + LootResponse { + guid: u64, + loot_type: u8, + gold: u32, + items: Vec, + }, + /// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't + /// kill it, too far, not standing, …). `error` is a `loot::loot_error` code. + LootError { guid: u64, error: u8 }, + /// One loot-window row was taken, by anyone (`SMSG_LOOT_REMOVED`) — the UI clears that row. + LootRemoved { slot: u8 }, + /// Our share of the loot's coin pile (`SMSG_LOOT_MONEY_NOTIFY`), answering our + /// `CMSG_LOOT_MONEY`. + LootMoneyNotify { amount: u32 }, + /// The coin line disappears for every current looter (`SMSG_LOOT_CLEAR_MONEY`). + LootClearMoney, + /// The loot window closes (`SMSG_LOOT_RELEASE_RESPONSE`), answering our `CMSG_LOOT_RELEASE`. + LootReleaseResponse { guid: u64 }, + /// A group roll opened on one drop (`SMSG_LOOT_START_ROLL`) — the app allocates a client-side + /// `rollID` for it and raises a `GroupLootFrame` (decision 0591). + LootStartRoll(LootStartRoll), + /// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — the chat announcement line. The + /// `(roll_number, roll_type)` pair is overloaded; `LootRoll::is_dice`/`vote` disentangle it. + LootRoll(LootRoll), + /// A group roll resolved (`SMSG_LOOT_ROLL_WON`) — closes that roll's frame. + LootRollWon(LootRollWon), + /// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — closes that roll's frame; the item returns to + /// the corpse for ordinary looting. + LootAllPassed(LootAllPassed), + /// An item landed in our bags — looted or received from an NPC (`SMSG_ITEM_PUSH_RESULT`); + /// drives the "You receive loot: …" chat line. + ItemPushResult(ItemPushResult), + /// The keepalive echo (`SMSG_PONG`): the sequence number of the `CMSG_PING` it answers. The app + /// matches it against the ping clock (shared with the write thread's 30 s sender) to measure + /// the round-trip time shown in the debug panel. + Pong { sequence: u32 }, + /// The server changed one of our mover's speeds (`SMSG_FORCE_*_SPEED_CHANGE` — an aura + /// slow/haste, a mount, GM `.modify speed`) and awaits the matching ack. The app must (1) apply + /// `speed` to the mover's [`MoveSpeeds`] entry for `kind`, and (2) answer + /// `CMSG_FORCE_*_SPEED_CHANGE_ACK` echoing `counter` + the exact `speed` with a live + /// `MovementInfo` — unacked, the server force-resolves after ~4 s and flags its anticheat + /// (`Unit::CheckPendingMovementChanges`), so every speed change desyncs without this. + ForceSpeedChange { + guid: u64, + kind: crate::messages::SpeedKind, + counter: u32, + speed: f32, + }, + /// A speed change on a unit we do NOT control (`SMSG_SPLINE_SET_*_SPEED` / + /// `MSG_MOVE_SET_*_SPEED` — an observed player mounting up, a hastened creature). Apply to the + /// unit's [`MoveSpeeds`] entry for `kind`; **no ack** (decision 0441). The MOVE_SET flavour's + /// accompanying pose arrives as its own [`Self::UnitMove`]. + SpeedChanged { + guid: u64, + kind: crate::messages::SpeedKind, + speed: f32, + }, + /// The server's answer to our (dis)mount attempt (`SMSG_MOUNTRESULT` / + /// `SMSG_DISMOUNTRESULT`, split by `mount`): a raw result code (OK = 10 mounting / 3 + /// dismounting; the failure codes are red-error-line material — a P2 trimming, decision 0441). + MountResult { mount: bool, code: u32 }, + /// A nearby rider hit the mounted flourish (`SMSG_MOUNTSPECIAL_ANIM`): play MountSpecial(94) + /// on that unit's mount. Our own guid may arrive too — whether the sender gets the echo + /// is a server-config detail (vmangos's non-broadcaster delivery echoes; the optional + /// per-player broadcaster does not) — so the app self-suppresses it on receive and plays + /// its own flourish locally at send time. + MountSpecial { guid: u64 }, + /// A packet the codec dropped on the floor: `unparseable = false` means **no parse arm exists** + /// for the opcode at all (it fell through to `ServerPacket::Other` — a wire-coverage gap); + /// `true` means a parser exists but errored on this body (the reader skipped it to keep the + /// stream aligned). The app tallies these per opcode (the debug panel's dropped-opcode + /// instrument): a silently-ignored opcode is how a whole wire family — a speed change, a + /// compressed-moves batch — goes unnoticed for months. + PacketDropped { opcode: u16, unparseable: bool }, + /// Where our corpse is (`MSG_CORPSE_QUERY`'s answer, after our empty-body request): feeds the + /// map corpse markers and the corpse-run range gate (decision 0308 §5). `found == false` also + /// arrives UNPROMPTED when the corpse converts to bones — it means "drop the marker". + /// `display_map`/`position` are where to walk toward (dungeon-entrance-adjusted); + /// `corpse_map` is the corpse's real map. + CorpseQuery { + found: bool, + display_map: i32, + position: [f32; 3], + corpse_map: u32, + }, + /// Milliseconds until our corpse can be reclaimed (`SMSG_CORPSE_RECLAIM_DELAY` — at release + /// and at login-while-dead; 30 s base, 60/120 s on repeated deaths). Feeds + /// `GetCorpseRecoveryDelay` and the RECOVER_CORPSE popup's StartDelay gate. + CorpseReclaimDelay { delay_ms: u32 }, + /// The 10% natural-death durability loss landed (`SMSG_DURABILITY_DAMAGE_DEATH`, empty + /// body — sent beside the item-field deltas): the red + /// "Your equipped items suffer a 10% durability loss." error line's cue. + DurabilityDamageDeath, + /// Someone offered to resurrect us (`SMSG_RESURRECT_REQUEST`). `name` is EMPTY for a player + /// caster (resolve via the guid name cache); `sickness`/`has_timer` pick the reference popup + /// variant. Answered by `CMSG_RESURRECT_RESPONSE` (accept/decline). + ResurrectRequest { + caster: u64, + name: String, + sickness: bool, + has_timer: bool, + }, + /// The spirit healer asks for the confirm (`SMSG_SPIRIT_HEALER_CONFIRM`, from the server's + /// gossip spirit-healer option): fires the CONFIRM_XP_LOSS popup; its Accept sends + /// `CMSG_SPIRIT_HEALER_ACTIVATE` back with this guid. + SpiritHealerConfirm { npc: u64 }, + /// The server (un)rooted a mover (`SMSG_FORCE_MOVE_ROOT`/`UNROOT` — ours at death/release). + /// Must be acked with the echoed `counter` (+ our current `MovementInfo`) or observers never + /// see the change; the app also gates its own input while rooted. + MoveRoot { + guid: u64, + counter: u32, + rooted: bool, + }, + /// Water-walking granted/removed on a mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` — the ghost + /// form's walk-on-water). Ack like [`Self::MoveRoot`], with the trailing apply flag. + WaterWalk { guid: u64, counter: u32, on: bool }, + /// Someone invited us to their group (`SMSG_GROUP_INVITE`) — the invite popup. + GroupInvite { inviter: String }, + /// An invite we sent was declined (`SMSG_GROUP_DECLINE`). + GroupDecline { name: String }, + /// We were removed from our group (`SMSG_GROUP_UNINVITE`, empty body) — kicked or left. + GroupUninvited, + /// The group's leader changed (`SMSG_GROUP_SET_LEADER`). + GroupLeaderChanged { name: String }, + /// The group disbanded outright (`SMSG_GROUP_DESTROYED`, empty body). + GroupDestroyed, + /// The full roster (`SMSG_GROUP_LIST`, sent on every membership change): `members` excludes + /// the recipient's own row (`own_flags` carries theirs); `loot` is `None` for the empty "you + /// left" shape and whenever the fetched list has no other members. + GroupList { + group_type: u8, + own_flags: u8, + members: Vec, + leader: u64, + loot: Option, + }, + /// The server's verdict on a group command we issued — invite/leave + /// (`SMSG_PARTY_COMMAND_RESULT`); `operation` is a [`crate::messages::party_operation`] code, + /// `result` a [`crate::messages::party_result`] code, `member` the named player (may be empty). + PartyCommandResult { + operation: u32, + member: String, + result: u32, + }, + /// A party/raid member's live stats for the frame (`SMSG_PARTY_MEMBER_STATS`/`_FULL` — `full` + /// distinguishes the delta form from the ask-once full form our own + /// [`crate::messages::request_party_member_stats`] pulls, and the offline-miss reply). + PartyMemberStats { + guid: u64, + full: bool, + info: Box, + }, + /// Someone pinged the minimap (`MSG_MINIMAP_PING`) — the group ping marker. + MinimapPing { guid: u64, x: f32, y: f32 }, + /// One raid-target icon changed (`MSG_RAID_TARGET_UPDATE`, delta shape) — `guid == 0` clears it. + RaidTargetSet { icon: u8, guid: u64 }, + /// The full current raid-target icon set (`MSG_RAID_TARGET_UPDATE`, full-list shape) — only + /// currently-set icons are present. + RaidTargetList { entries: Vec<(u8, u64)> }, + /// The raid leader started a ready check (`MSG_RAID_READY_CHECK`, empty body). + ReadyCheckRequest, + /// One member's ready-check answer, forwarded to the leader only (`MSG_RAID_READY_CHECK`, + /// non-empty body). + ReadyCheckAnswer { guid: u64, ready: u8 }, + /// The taxi map (`SMSG_SHOWTAXINODES`, decision 0484): `flightmaster` is the NPC the menu + /// opened on, `nearest_node` the node it sits at, `known_mask` the full known-node bitmask + /// ([`TaxiMask::is_known`]). The wire's window-framing constant carries no state and is + /// dropped here. + TaxiNodesShown { + flightmaster: u64, + nearest_node: u32, + known_mask: TaxiMask, + }, + /// A taxi node's known status (`SMSG_TAXINODE_STATUS`) — answers + /// `CMSG_TAXINODE_STATUS_QUERY`, and also rides a first-visit "learn" alongside + /// [`Self::NewTaxiPath`] (vmangos `SendLearnNewTaxiNode`). `guid` names the flight master + /// asked about; `known` is whether the nearest node to it is now in our taxi mask. + TaxiNodeStatus { guid: u64, known: bool }, + /// The server's verdict on `CMSG_ACTIVATETAXI`/`CMSG_ACTIVATETAXIEXPRESS` + /// (`SMSG_ACTIVATETAXIREPLY`): `code` is a [`crate::messages::taxi_reply`] value — `0` OK, + /// everything else a refusal (no flight starts, and no mount/spline packets follow). + ActivateTaxiReply { code: u32 }, + /// `SMSG_NEW_TAXI_PATH` — empty body, rides a first-visit "learn" alongside + /// [`Self::TaxiNodeStatus`]. No payload to carry; modelled so the wire isn't silently dropped. + NewTaxiPath, + /// `SMSG_MAIL_LIST_RESULT` — the inbox page, answering `CMSG_GET_MAIL_LIST` (decision 0544 + /// P0's wire layer; the inbox/open-mail window is P1). + MailList { mails: Vec }, + /// `SMSG_SEND_MAIL_RESULT` — the verdict on a send/take-money/take-item/return/delete action; + /// `equip_error`/`item` are the two mutually-exclusive conditional tails (decision 0544). + SendMailResult { + mail_id: u32, + action: u32, + error: u32, + equip_error: Option, + item: Option<(u32, u32)>, + }, + /// `SMSG_ITEM_TEXT_QUERY_RESPONSE` — a letter's body text, answering `CMSG_ITEM_TEXT_QUERY` + /// (a mail's nonzero `item_text_id` triggers the ask-once fetch; decision 0544). + MailItemText { text_id: u32, text: String }, + /// `SMSG_RECEIVED_MAIL` — a mail arrived (instant for text-only, on the delivery timer's + /// expiry otherwise); the wire's always-0 `u32` carries nothing, so the event does either. + ReceivedMail, + /// `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode as our empty-body request): `0.0` = unread + /// mail waiting, `-86400.0` = none. Drives `HasNewMail()`/`UPDATE_PENDING_MAIL` (decision 0544 + /// P3). + NextMailTime { seconds: f32 }, + /// `SMSG_TRADE_STATUS` — one pulse of the player-trade state machine (open/accept/cancel/ + /// complete + the refusal reasons). The app drives the trade window and the auto-`BEGIN_TRADE` + /// reply off this (decision 0592; the window itself is P1). + TradeStatus { status: TradeStatus }, + /// `SMSG_TRADE_STATUS_EXTENDED` — the item/gold snapshot for one window side (our own or the + /// partner's, per [`TradeStatusExtended::their_window`]); pushed whenever that side changes. + /// Boxed (~460 bytes) so it doesn't bloat every `SessionEvent`. + TradeStatusExtended { state: Box }, +} + +/// The result of polling the reader for the next packet's events. +pub enum Poll { + /// A packet decoded into these events (possibly empty for an unmodelled opcode). + Events(Vec), + /// An unparseable packet was skipped — kept the stream aligned, not an error. Carries the + /// opcode (for the app's dropped-packet tally) and a short description (opcode + error + a hex + /// preview) so the net thread can log *which* packet was dropped. + Skipped { opcode: u16, reason: String }, +} + +mod decode; +pub use decode::decode; diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs new file mode 100644 index 00000000..f5d7386b --- /dev/null +++ b/crates/benilla-protocol/src/events/decode.rs @@ -0,0 +1,833 @@ +//! The [`ServerPacket`]→[`SessionEvent`] mapping: [`decode`]'s per-packet fan-out plus the +//! object-list interpretation ([`decode_objects`] and the spawn-identity readers). Pure functions +//! of the packet — no I/O, no state; the running world model stays the app's. + +use crate::messages::{CastOutcome, MovementBlock, Object, ObjectFields, ObjectType, ServerPacket}; +use crate::wire::Vector3d; + +use super::{EntityKind, MoveSpeeds, SessionEvent}; + +fn v3(v: Vector3d) -> [f32; 3] { + [v.x, v.y, v.z] +} + +/// Decode one server packet into zero or more [`SessionEvent`]s. Pure: no I/O, no state. Packets the +/// client doesn't model yield an empty list. +pub fn decode(packet: ServerPacket) -> Vec { + match packet { + ServerPacket::UpdateObject { objects } => decode_objects(objects), + ServerPacket::CharEnum { characters } => vec![SessionEvent::CharacterList { + characters, + realm: None, + }], + ServerPacket::LogoutComplete => vec![SessionEvent::LoggedOut], + ServerPacket::PlaySound { sound_id } => vec![SessionEvent::PlaySound { sound_id }], + ServerPacket::PlayMusic { music_id } => vec![SessionEvent::PlayMusic { music_id }], + ServerPacket::PlayObjectSound { sound_id, guid } => { + vec![SessionEvent::PlayObjectSound { sound_id, guid }] + } + ServerPacket::Weather { + weather_type, + grade, + sound_id, + instant, + } => vec![SessionEvent::Weather { + weather_type, + grade, + sound_id, + instant, + }], + ServerPacket::TextEmote { guid, text_emote } => { + vec![SessionEvent::TextEmote { guid, text_emote }] + } + ServerPacket::Emote { guid, emote_id } => vec![SessionEvent::Emote { guid, emote_id }], + ServerPacket::InitialSpells { + spell_ids, + cooldowns, + } => vec![SessionEvent::SpellBook { + spell_ids: spell_ids.into_iter().map(u32::from).collect(), + cooldowns, + }], + ServerPacket::ActionButtons { buttons } => vec![SessionEvent::ActionButtons { buttons }], + ServerPacket::LearnedSpell { spell_id } => vec![SessionEvent::SpellLearned { + spell_id: u32::from(spell_id), + }], + ServerPacket::SupercededSpell { + old_spell_id, + new_spell_id, + } => vec![SessionEvent::SpellSuperceded { + old_spell_id: u32::from(old_spell_id), + new_spell_id: u32::from(new_spell_id), + }], + ServerPacket::CastResult { spell_id, outcome } => vec![SessionEvent::CastResult { + spell_id, + success: outcome == CastOutcome::Ok, + reason: match outcome { + CastOutcome::Ok => None, + CastOutcome::Failed { reason } => Some(reason), + }, + }], + ServerPacket::ItemQueryResponse { entry, info } => { + vec![SessionEvent::ItemTemplate { entry, info }] + } + ServerPacket::MessageChat(m) => vec![SessionEvent::Chat(m)], + ServerPacket::ChannelNotify(n) => vec![SessionEvent::ChannelNotify { + notice: n.notice, + channel: n.channel, + tail: n.tail, + }], + ServerPacket::ChannelList { + channel, + flags, + members, + } => vec![SessionEvent::ChannelList { + channel, + flags, + members, + }], + ServerPacket::ChatPlayerNotFound { name } => { + vec![SessionEvent::ChatPlayerNotFound { name }] + } + ServerPacket::ChatWrongFaction => vec![SessionEvent::ChatWrongFaction], + ServerPacket::Notification { text } => vec![SessionEvent::Notification { text }], + ServerPacket::PlayedTime { total, level } => { + vec![SessionEvent::PlayedTime { total, level }] + } + ServerPacket::RandomRoll { + min, + max, + roll, + guid, + } => vec![SessionEvent::RandomRoll { + min, + max, + roll, + guid, + }], + ServerPacket::InventoryChangeFailure { + reason, + required_level, + item_guid, + } if reason != 0 => { + vec![SessionEvent::InventoryFailure { + reason, + required_level, + item_guid, + }] + } + ServerPacket::AttackStart { attacker, victim } => { + vec![SessionEvent::AttackStart { attacker, victim }] + } + ServerPacket::AttackStop { attacker, victim } => { + vec![SessionEvent::AttackStop { attacker, victim }] + } + ServerPacket::AttackerState(s) => vec![SessionEvent::AttackerState(s)], + ServerPacket::AiReaction { unit, reaction } => { + vec![SessionEvent::AiReaction { unit, reaction }] + } + ServerPacket::SpellStart(s) => vec![SessionEvent::SpellStart { + caster: s.caster, + spell_id: s.spell_id, + cast_flags: s.cast_flags, + cast_time_ms: s.cast_time_ms, + target: s.targets.unit_target, + ammo_display_id: s.ammo_display_id, + }], + ServerPacket::SpellGo(s) => vec![SessionEvent::SpellGo { + caster: s.caster, + spell_id: s.spell_id, + cast_flags: s.cast_flags, + hits: s.hits, + misses: s.misses, + target: s.targets.unit_target, + go_target: s.targets.go_target, + ammo_display_id: s.ammo_display_id, + item_caster: (s.item_or_caster != s.caster).then_some(s.item_or_caster), + }], + ServerPacket::SpellDelayed { caster, delay_ms } => { + vec![SessionEvent::SpellDelayed { caster, delay_ms }] + } + ServerPacket::SpellFailedOther { caster, spell_id } => { + vec![SessionEvent::SpellFailedOther { caster, spell_id }] + } + ServerPacket::CancelAutoRepeat => vec![SessionEvent::CancelAutoRepeat], + ServerPacket::SpellCooldownList { caster, cooldowns } => { + vec![SessionEvent::SpellCooldowns { caster, cooldowns }] + } + ServerPacket::ItemCooldown { + item_guid, + spell_id, + } => vec![SessionEvent::ItemCooldown { + item_guid, + spell_id, + }], + ServerPacket::CooldownEvent { spell_id, caster } => { + vec![SessionEvent::CooldownEvent { spell_id, caster }] + } + ServerPacket::ClearCooldown { spell_id, caster } => { + vec![SessionEvent::ClearCooldown { spell_id, caster }] + } + ServerPacket::CooldownCheat { caster } => vec![SessionEvent::CooldownCheat { caster }], + ServerPacket::ChannelStart { + spell_id, + duration_ms, + } => vec![SessionEvent::ChannelStart { + spell_id, + duration_ms, + }], + ServerPacket::ChannelUpdate { remaining_ms } => { + vec![SessionEvent::ChannelUpdate { remaining_ms }] + } + ServerPacket::UpdateAuraDuration { slot, remaining_ms } => { + vec![SessionEvent::AuraDuration { slot, remaining_ms }] + } + ServerPacket::PlaySpellVisual { unit, kit_id } => { + vec![SessionEvent::PlaySpellVisual { unit, kit_id }] + } + ServerPacket::SpellDamageLog(s) => vec![SessionEvent::SpellDamageLog(s)], + ServerPacket::PeriodicAuraLog(s) => vec![SessionEvent::PeriodicAuraLog(s)], + ServerPacket::SpellHealLog(s) => vec![SessionEvent::SpellHealLog(s)], + ServerPacket::SpellEnergizeLog(s) => vec![SessionEvent::SpellEnergizeLog(s)], + ServerPacket::DamageShield(s) => vec![SessionEvent::DamageShield(s)], + ServerPacket::EnvironmentalDamageLog(s) => vec![SessionEvent::EnvironmentalDamageLog(s)], + ServerPacket::SpellLogMiss(s) => vec![SessionEvent::SpellLogMiss(s)], + ServerPacket::XpGain(s) => vec![SessionEvent::XpGain(s)], + ServerPacket::LevelUp(l) => vec![SessionEvent::LevelUp(l)], + ServerPacket::GossipMessage { + npc, + text_id, + options, + quests, + } => vec![SessionEvent::GossipMenu { + npc, + text_id, + options, + quests: quests + .into_iter() + .map(|q| (q.quest_id, q.icon, q.title)) + .collect(), + }], + ServerPacket::QuestGiverStatus { npc, status } => { + vec![SessionEvent::QuestGiverStatus { npc, status }] + } + ServerPacket::QuestGiverQuestList(l) => vec![SessionEvent::QuestGreeting(l)], + ServerPacket::QuestGiverDetails(d) => vec![SessionEvent::QuestDetail(d)], + ServerPacket::QuestGiverRequestItems(p) => vec![SessionEvent::QuestProgress(p)], + ServerPacket::QuestGiverOfferReward(o) => vec![SessionEvent::QuestOffer(o)], + ServerPacket::QuestGiverComplete(c) => vec![SessionEvent::QuestComplete(c)], + ServerPacket::QuestQueryResponse(t) => vec![SessionEvent::QuestTemplate(t)], + ServerPacket::QuestUpdateAddKill { + quest_id, + entry, + count, + required, + guid: _, // the killed unit's guid — no consumer (the toast keys on the entry) + } => vec![SessionEvent::QuestObjectiveKill { + quest_id, + entry, + count, + required, + }], + ServerPacket::QuestUpdateAddItem { item_id, count } => { + vec![SessionEvent::QuestObjectiveItem { item_id, count }] + } + ServerPacket::QuestUpdateComplete { quest_id } => { + vec![SessionEvent::QuestObjectivesComplete { quest_id }] + } + ServerPacket::QuestUpdateFailed { quest_id } => vec![SessionEvent::QuestFailed { + quest_id, + timed: false, + }], + ServerPacket::QuestUpdateFailedTimer { quest_id } => vec![SessionEvent::QuestFailed { + quest_id, + timed: true, + }], + ServerPacket::QuestLogFull => vec![SessionEvent::QuestLogFull], + ServerPacket::QuestGiverInvalid { msg } => vec![SessionEvent::QuestGiverRefused { + quest_id: 0, + reason: msg, + }], + ServerPacket::QuestGiverFailed { quest_id, reason } => { + vec![SessionEvent::QuestGiverRefused { quest_id, reason }] + } + ServerPacket::GossipComplete => vec![SessionEvent::GossipComplete], + ServerPacket::NpcText { text_id, greeting } => { + vec![SessionEvent::NpcGreeting { text_id, greeting }] + } + ServerPacket::VendorList { vendor, items } => { + vec![SessionEvent::VendorInventory { vendor, items }] + } + ServerPacket::TrainerList { + trainer, + trainer_type, + services, + title, + } => vec![SessionEvent::TrainerList { + trainer, + trainer_type, + services, + greeting: title, + }], + ServerPacket::TrainerBuySucceeded { trainer, spell_id } => { + vec![SessionEvent::TrainerBuySucceeded { trainer, spell_id }] + } + ServerPacket::TrainerBuyFailed { + trainer, + spell_id, + error, + } => vec![SessionEvent::TrainerBuyFailed { + trainer, + spell_id, + error, + }], + ServerPacket::BuyItem { + vendor, + slot, + new_count, + purchase_count, + } => vec![SessionEvent::VendorBuyResult { + vendor, + slot, + new_count, + purchase_count, + }], + ServerPacket::SellItemResult { + vendor, + item_guid, + reason, + } => vec![SessionEvent::VendorSellFailed { + vendor, + item_guid, + reason, + }], + ServerPacket::BuyFailed { + vendor, + item_entry, + reason, + } => vec![SessionEvent::VendorBuyFailed { + vendor, + item_entry, + reason, + }], + ServerPacket::ShowBank { banker } => vec![SessionEvent::ShowBank { banker }], + ServerPacket::BuyBankSlotResult { result } => { + vec![SessionEvent::BuyBankSlotResult { result }] + } + ServerPacket::LootResponse { + guid, + loot_type, + gold, + items, + } => vec![SessionEvent::LootResponse { + guid, + loot_type, + gold, + items, + }], + ServerPacket::LootError { guid, error } => vec![SessionEvent::LootError { guid, error }], + ServerPacket::LootReleaseResponse { guid, .. } => { + vec![SessionEvent::LootReleaseResponse { guid }] + } + ServerPacket::LootRemoved { slot } => vec![SessionEvent::LootRemoved { slot }], + ServerPacket::LootMoneyNotify { amount } => { + vec![SessionEvent::LootMoneyNotify { amount }] + } + ServerPacket::LootClearMoney => vec![SessionEvent::LootClearMoney], + ServerPacket::LootStartRoll(p) => vec![SessionEvent::LootStartRoll(p)], + ServerPacket::LootRoll(p) => vec![SessionEvent::LootRoll(p)], + ServerPacket::LootRollWon(p) => vec![SessionEvent::LootRollWon(p)], + ServerPacket::LootAllPassed(p) => vec![SessionEvent::LootAllPassed(p)], + ServerPacket::ItemPushResult(p) => vec![SessionEvent::ItemPushResult(p)], + ServerPacket::CorpseQuery(loc) => vec![SessionEvent::CorpseQuery { + found: loc.found, + display_map: loc.display_map, + position: loc.position, + corpse_map: loc.corpse_map, + }], + ServerPacket::CorpseReclaimDelay { delay_ms } => { + vec![SessionEvent::CorpseReclaimDelay { delay_ms }] + } + ServerPacket::DurabilityDamageDeath => vec![SessionEvent::DurabilityDamageDeath], + ServerPacket::ResurrectRequest(r) => vec![SessionEvent::ResurrectRequest { + caster: r.caster, + name: r.name, + sickness: r.sickness, + has_timer: r.has_timer, + }], + ServerPacket::SpiritHealerConfirm { npc } => { + vec![SessionEvent::SpiritHealerConfirm { npc }] + } + ServerPacket::MoveRoot { + guid, + counter, + rooted, + } => vec![SessionEvent::MoveRoot { + guid, + counter, + rooted, + }], + ServerPacket::WaterWalk { guid, counter, on } => { + vec![SessionEvent::WaterWalk { guid, counter, on }] + } + ServerPacket::GroupInvite { inviter } => vec![SessionEvent::GroupInvite { inviter }], + ServerPacket::GroupDecline { name } => vec![SessionEvent::GroupDecline { name }], + ServerPacket::GroupUninvited => vec![SessionEvent::GroupUninvited], + ServerPacket::GroupLeaderChanged { name } => { + vec![SessionEvent::GroupLeaderChanged { name }] + } + ServerPacket::GroupDestroyed => vec![SessionEvent::GroupDestroyed], + ServerPacket::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } => vec![SessionEvent::GroupList { + group_type, + own_flags, + members, + leader, + loot, + }], + ServerPacket::PartyCommandResult { + operation, + member, + result, + } => vec![SessionEvent::PartyCommandResult { + operation, + member, + result, + }], + ServerPacket::PartyMemberStats { guid, full, info } => { + vec![SessionEvent::PartyMemberStats { guid, full, info }] + } + ServerPacket::MinimapPing { guid, x, y } => { + vec![SessionEvent::MinimapPing { guid, x, y }] + } + ServerPacket::RaidTargetSet { icon, guid } => { + vec![SessionEvent::RaidTargetSet { icon, guid }] + } + ServerPacket::RaidTargetList { entries } => { + vec![SessionEvent::RaidTargetList { entries }] + } + ServerPacket::ReadyCheckRequest => vec![SessionEvent::ReadyCheckRequest], + ServerPacket::ReadyCheckAnswer { guid, ready } => { + vec![SessionEvent::ReadyCheckAnswer { guid, ready }] + } + ServerPacket::DestroyObject { guid } => vec![SessionEvent::ObjectDestroyed(guid)], + ServerPacket::TriggerCinematic { cinematic_id } => { + vec![SessionEvent::CinematicTriggered { cinematic_id }] + } + ServerPacket::MonsterMove { + guid, + start, + spline_id, + path, + facing, + stop, + duration_ms, + flying, + } => vec![SessionEvent::MonsterMove { + guid, + start: v3(start), + spline_id, + path: path.into_iter().map(v3).collect(), + facing, + stop, + duration_ms, + flying, + }], + ServerPacket::PlayerMove { + guid, + opcode, + flags, + position, + orientation, + pitch, + time, + fall_time, + jump, + transport, + } => vec![SessionEvent::UnitMove { + guid, + position: v3(position), + orientation, + flags, + pitch, + time, + heartbeat: opcode == crate::messages::opcode::MSG_MOVE_HEARTBEAT, + fall_time, + jump, + transport, + }], + ServerPacket::Teleport { + guid, + counter, + position, + orientation, + } => vec![SessionEvent::Teleport { + guid, + counter, + position: v3(position), + orientation, + }], + ServerPacket::NewWorld { + map, + position, + orientation, + } => vec![SessionEvent::Worldport { + map_id: map, + position: v3(position), + orientation, + needs_ack: true, + }], + ServerPacket::LoginVerifyWorld { + map, + position, + orientation, + } => vec![SessionEvent::Worldport { + map_id: map, + position: v3(position), + orientation, + needs_ack: false, + }], + ServerPacket::TransferPending { map, transport } => vec![SessionEvent::TransferPending { + map_id: map, + transport_entry: transport.map(|(entry, _old_map)| entry), + }], + ServerPacket::TransferAborted { reason } => { + vec![SessionEvent::TransferAborted { reason }] + } + ServerPacket::TimeSpeed { + hours, + minutes, + day_serial, + timescale, + } => vec![SessionEvent::TimeSpeed { + hours, + minutes, + day_serial, + timescale, + }], + ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }], + ServerPacket::SetProficiency { + item_class, + subclass_mask, + } => vec![SessionEvent::Proficiency { + item_class: u32::from(item_class), + subclass_mask, + }], + ServerPacket::InitializeFactions { standings } => { + vec![SessionEvent::Reputations { standings }] + } + ServerPacket::SetFactionStanding { standings } => { + vec![SessionEvent::ReputationDelta { standings }] + } + ServerPacket::NameQueryResponse { + guid, + name, + race, + gender, + class, + } => vec![SessionEvent::PlayerName { + guid, + name, + race, + gender, + class, + }], + ServerPacket::CreatureQueryResponse { entry, info } => { + let (name, subname, creature_type, rank, type_flags, civilian, racial_leader) = + match info { + Some(i) => ( + Some(i.name), + // An EMPTY wire subname is NO subname (vmangos sends "" for creatures + // without one; the client renders no line for it — the nameplate + // builder's verified shape, 0x608f50). Mapped here, once, so no consumer + // ever sees a present-but-empty tooltip/nameplate line. + Some(i.subname).filter(|s| !s.is_empty()), + Some(i.creature_type), + i.rank, + i.type_flags, + i.civilian, + i.racial_leader, + ), + None => (None, None, None, 0, 0, false, false), + }; + vec![SessionEvent::CreatureName { + entry, + name, + subname, + creature_type, + rank, + type_flags, + civilian, + racial_leader, + }] + } + ServerPacket::GameObjectQueryResponse { entry, info } => { + let event = match info { + Some(i) => SessionEvent::GameObjectInfo { + entry, + type_id: i.type_id, + display_id: i.display_id, + name: i.name, + data: i.data, + }, + None => SessionEvent::GameObjectInfo { + entry, + type_id: 0, + display_id: 0, + name: String::new(), + data: [0; 24], + }, + }; + vec![event] + } + // The keepalive echo: the io layer matches the sequence against its ping clock to compute + // the round-trip time (the codec is stateless, so the timing lives with the sender). + ServerPacket::Pong { sequence } => vec![SessionEvent::Pong { sequence }], + ServerPacket::ForceSpeedChange { + guid, + kind, + counter, + speed, + } => vec![SessionEvent::ForceSpeedChange { + guid, + kind, + counter, + speed, + }], + ServerPacket::SplineSpeedChange { guid, kind, speed } => { + vec![SessionEvent::SpeedChanged { guid, kind, speed }] + } + // The MOVE_SET flavour is a speed change AND a fresh authoritative pose in one packet + // (decision 0441) — surface both; they land in the same drain, so extrapolation resumes + // from the new pose at the new speed either way. + ServerPacket::MoveSetSpeed { + guid, + kind, + flags, + position, + orientation, + pitch, + time, + fall_time, + jump, + transport, + speed, + } => vec![ + SessionEvent::UnitMove { + guid, + position: v3(position), + orientation, + flags, + pitch, + time, + heartbeat: false, + fall_time, + jump, + transport, + }, + SessionEvent::SpeedChanged { guid, kind, speed }, + ], + ServerPacket::MountResult { mount, code } => { + vec![SessionEvent::MountResult { mount, code }] + } + ServerPacket::MountSpecialAnim { guid } => { + vec![SessionEvent::MountSpecial { guid }] + } + ServerPacket::ShowTaxiNodes { + window, + flightmaster, + nearest_node, + known, + } => { + // The leading word is the client parser's own conditional gate (0496 §claim-1): a + // zero-gated body carries no menu — no event. vmangos always sends 1. + if window == 0 { + vec![] + } else { + vec![SessionEvent::TaxiNodesShown { + flightmaster, + nearest_node, + known_mask: known, + }] + } + } + ServerPacket::TaxiNodeStatus { guid, known } => { + vec![SessionEvent::TaxiNodeStatus { guid, known }] + } + ServerPacket::ActivateTaxiReply { code } => { + vec![SessionEvent::ActivateTaxiReply { code }] + } + ServerPacket::NewTaxiPath => vec![SessionEvent::NewTaxiPath], + ServerPacket::MailList { mails } => vec![SessionEvent::MailList { mails }], + ServerPacket::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } => vec![SessionEvent::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + }], + ServerPacket::ItemTextQueryResponse { text_id, text } => { + vec![SessionEvent::MailItemText { text_id, text }] + } + ServerPacket::ReceivedMail => vec![SessionEvent::ReceivedMail], + ServerPacket::NextMailTime { seconds } => { + vec![SessionEvent::NextMailTime { seconds }] + } + ServerPacket::TradeStatus { status } => vec![SessionEvent::TradeStatus { status }], + ServerPacket::TradeStatusExtended { state } => { + vec![SessionEvent::TradeStatusExtended { state }] + } + // An opcode with NO parse arm at all — surface it so the app can tally the coverage gap + // (the debug panel's dropped-opcode instrument); the silent fall-through hid whole wire + // families. Parsed-but-unmodelled packets (the `_` below) stay deliberate no-ops. + ServerPacket::Other { opcode } => vec![SessionEvent::PacketDropped { + opcode, + unparseable: false, + }], + _ => Vec::new(), + } +} + +/// Fan one object-update packet's object list out into create/move/remove/values events. Consumes the +/// list so each object's descriptor `mask` moves into its event as the ECS store seed/delta (decision +/// 0061) — no clone. +fn decode_objects(objects: Vec) -> Vec { + let mut out = Vec::new(); + for object in objects { + match object { + Object::Create { + guid, + object_type, + movement, + mask, + } => { + // Items/containers are descriptor-only creates — no pose to interpret, no scene + // presence. Route them whole into the item event before the placement gate below + // (which would otherwise silently drop a position-less create, fields and all). + if matches!(object_type, ObjectType::Item | ObjectType::Container) { + out.push(SessionEvent::ItemCreate { + guid, + container: object_type == ObjectType::Container, + fields: mask, + }); + continue; + } + // Interpret the spawn identity + placement from the mask (borrow), then move the whole + // mask into the create event as the object's initial descriptor store. + if let Some((position, orientation)) = + create_placement(object_type, &mask, &movement) + { + let display = display_id(object_type, &mask); + let scale = object_scale(object_type, &mask); + let speeds = movement.speeds.map(MoveSpeeds::from_wire); + out.push(SessionEvent::ObjectCreate { + guid, + kind: entity_kind(object_type), + display_id: display, + position: v3(position), + orientation, + scale, + speeds, + transport_progress: movement.transport_progress, + transport: movement.transport, + fields: mask, + }); + } + } + Object::Movement { guid, movement } => { + if let Some((position, orientation)) = movement.position { + out.push(SessionEvent::ObjectMove { + guid, + position: v3(position), + orientation, + }); + } + } + Object::OutOfRange { guids } => { + out.push(SessionEvent::ObjectsRemoved(guids)); + } + // `Values` carries UpdateFields-only changes (no position) — descriptor deltas (health on a + // hit, an appearance byte, a door's state). Emit the whole non-empty delta for the ECS to + // merge into the object's store; interpretation is the consumer's, per named accessor. + Object::Values { guid, mask } => { + if !mask.is_empty() { + out.push(SessionEvent::ObjectValues { guid, fields: mask }); + } + } + // `Near` is a guid pre-announce; no state. + Object::Near { .. } => {} + } + } + out +} + +/// Coarse classification of a decoded object from its `TypeId`. +fn entity_kind(t: ObjectType) -> EntityKind { + match t { + ObjectType::Player => EntityKind::Player, + ObjectType::Unit => EntityKind::Unit, + ObjectType::GameObject => EntityKind::GameObject, + _ => EntityKind::Other, + } +} + +/// The display id from a create packet — units, **players**, and GameObjects all carry one. A player's +/// body model resolves through the same CreatureDisplayInfo→CreatureModelData chain as a creature's: +/// the 1.12 client has no race/sex→path resolver, CGPlayer inherits CGUnit's displayId model-build +/// (decision 0041), so we route `Player` alongside `Unit`. Cast from the wire `i32`; `0`/absent → +/// `None`. +fn display_id(t: ObjectType, mask: &ObjectFields) -> Option { + let raw = match t { + ObjectType::Unit | ObjectType::Player => mask.unit_displayid(), + ObjectType::GameObject => mask.gameobject_displayid(), + _ => None, + }?; + (raw > 0).then_some(raw as u32) +} + +/// The per-object scale (`OBJECT_FIELD_SCALE_X`) units/players/GameObjects carry — the *complete* +/// render scale that multiplies the raw model's native size. The real client sizes any object by this +/// field alone (verified `world_model_scale` `0x613ef0`); for a unit the server has already folded the +/// CreatureDisplayInfo/CreatureModelData scale into it (vmangos `Unit::GetScaleForDisplayId`), so it is +/// *not* multiplied again client-side. Defaults to `1.0` when absent or non-positive (the server always +/// sends a positive value on create). +fn object_scale(t: ObjectType, mask: &ObjectFields) -> f32 { + let raw = match t { + ObjectType::Unit | ObjectType::Player | ObjectType::GameObject => mask.object_scale_x(), + _ => None, + }; + raw.filter(|s| *s > 0.0).unwrap_or(1.0) +} + +/// Where a freshly-created object sits: GameObjects from UpdateFields (`GAMEOBJECT_POS_*`) — falling +/// back to the movement block's `HAS_POSITION` pose when those fields were never sent at all — everything +/// else straight from the movement block. +/// +/// The fallback exists for transports: vmangos never sets `GAMEOBJECT_POS_*` on a boat/zeppelin/elevator +/// (it sends the stationary spawn point in the movement block's `HAS_POSITION` slot instead — decision +/// 0438 "the wire"). Gated on [`ObjectFields::gameobject_pos_sent`] rather than +/// `gameobject_position().or(..)`: a create block's absent-is-zero fold +/// (`ObjectFields`'s "created" semantics) means `gameobject_position()` reads `Some((0,0,0), 0)` even +/// when nothing was sent, so a plain `.or` never falls through. An ordinary GameObject sends at least +/// one non-zero `GAMEOBJECT_POS_*` axis virtually always, so this never changes its placement. +fn create_placement( + object_type: ObjectType, + mask: &ObjectFields, + movement: &MovementBlock, +) -> Option<(Vector3d, f32)> { + if object_type == ObjectType::GameObject && mask.gameobject_pos_sent() { + mask.gameobject_position() + } else { + movement.position + } +} diff --git a/crates/benilla-protocol/src/guid.rs b/crates/benilla-protocol/src/guid.rs new file mode 100644 index 00000000..a4ee94b3 --- /dev/null +++ b/crates/benilla-protocol/src/guid.rs @@ -0,0 +1,185 @@ +//! GUID bit-layout decode for 1.12.1 — the wire's object identity, `[high:16][entry:24][counter:24]` +//! for entry-carrying types and `[high:16][counter:48-usable-as-32]` for the rest (VERIFIED vmangos +//! `ObjectGuid.h`: `GetHigh` = bits 48–63, `GetEntry` = bits 24–47 masked to 24 bits, gated on the +//! high value carrying an entry at all). +//! +//! The one load-bearing consumer today: `CMSG_CREATURE_QUERY` wants the creature's **template entry**, +//! which the client recovers from the guid exactly this way — the u64→entry extraction is the same +//! stable bijection decision 0068's mapping layer relies on for modern string GUIDs later. + +/// `HIGHGUID_PLAYER` — a player character's guid high part. +pub const HIGH_PLAYER: u16 = 0x0000; +/// `HIGHGUID_ITEM` == `HIGHGUID_CONTAINER` — items and bags share the marker (a bag *is* an item). +pub const HIGH_ITEM: u16 = 0x4000; +/// `HIGHGUID_GAMEOBJECT`. +pub const HIGH_GAMEOBJECT: u16 = 0xF110; +/// `HIGHGUID_UNIT` — a spawned creature. +pub const HIGH_UNIT: u16 = 0xF130; +/// `HIGHGUID_PET`. +pub const HIGH_PET: u16 = 0xF140; +/// `HIGHGUID_MO_TRANSPORT` — a boat/zeppelin (`GAMEOBJECT_TYPE_MO_TRANSPORT`, template type 15): the +/// WMO-modeled transports that sail scripted taxi-path routes. VERIFIED vmangos `ObjectGuid.h:77`. +pub const HIGH_MO_TRANSPORT: u16 = 0x1FC0; +/// `HIGHGUID_TRANSPORT` — an elevator (`GAMEOBJECT_TYPE_TRANSPORT`, template type 11): the M2-modeled, +/// `TransportAnimation.dbc`-driven lifts. VERIFIED vmangos `ObjectGuid.h:72`. +pub const HIGH_TRANSPORT: u16 = 0xF120; + +/// The guid's high 16 bits (`GetHigh`, vmangos `ObjectGuid.h`) — the object-family tag. +pub fn high(guid: u64) -> u16 { + ((guid >> 48) & 0xFFFF) as u16 +} + +/// A (non-zero) player-character guid. +pub fn is_player(guid: u64) -> bool { + guid != 0 && high(guid) == HIGH_PLAYER +} + +/// A creature or pet guid (`IsCreatureOrPet`) — the family whose template entry rides in the guid. +pub fn is_creature_or_pet(guid: u64) -> bool { + matches!(high(guid), HIGH_UNIT | HIGH_PET) +} + +/// A (non-zero) item or container guid (`IsItem` — containers included). Items carry **no** entry +/// in the guid; the entry lives in the descriptor (`OBJECT_FIELD_ENTRY`). +pub fn is_item(guid: u64) -> bool { + guid != 0 && high(guid) == HIGH_ITEM +} + +/// Either transport guid family — a boat/zeppelin (`HIGH_MO_TRANSPORT`) or an elevator +/// (`HIGH_TRANSPORT`). The two are composed differently (see [`entry`]) but both name "a transport" to +/// every other consumer (the create-routing gate, the rider wire tail). +pub fn is_transport(guid: u64) -> bool { + matches!(high(guid), HIGH_MO_TRANSPORT | HIGH_TRANSPORT) +} + +/// The embedded template entry, for the guid families that carry one. Two different layouts, both +/// VERIFIED against vmangos: +/// +/// - Creatures/pets/GameObjects/**elevators** (`HIGH_UNIT`/`HIGH_PET`/`HIGH_GAMEOBJECT`/ +/// `HIGH_TRANSPORT`) use the standard `[high:16][entry:24][counter:24]` layout — vmangos's +/// class-internal `HasEntry` switch returns `true` for all four (`ObjectGuid.h:223-240`), and an +/// elevator composes exactly like an ordinary GameObject: `GameObject::Create` calls +/// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` for a `GAMEOBJECT_TYPE_TRANSPORT` +/// template (`Objects/GameObject.cpp:207`, reached via `ElevatorTransport::Create` → +/// `GenericTransport::Create` → the same `GameObject::Create`) — `goinfo->id` (the template entry) +/// rides bits 24–47, the spawn's own DB-assigned low guid rides bits 0–23. +/// - **Boats/zeppelins** (`HIGH_MO_TRANSPORT`) carry **no** entry part at all: vmangos's +/// `DoesGuidHaveEntryPart`/`HasEntry` both return `false` for `HIGHGUID_MO_TRANSPORT` +/// (`ObjectGuid.h:87-104`, `223-240`). `ShipTransport::Create` calls `Object::_Create(guidlow, 0, +/// HIGHGUID_MO_TRANSPORT)` (`Transports/Transport.cpp:65`) where `guidlow` is the +/// `gameobject_template` entry itself, threaded in from `TransportMgr::CreateTransport(uint32 +/// entry, …)` (`TransportMgr.cpp:364,400`: `trans->Create(entry, frameItr)`). The `ObjectGuid` +/// 3-arg ctor folds `entry`(=0 here) and `counter`(=guidlow) into ONE 32-bit low field when the +/// family has no entry part (`ObjectGuid.h:123`: `counter | (entry << 24) | (hi << 48)`) — so the +/// template entry rides the **full low 32 bits**, recovered the same way `GetCounter`'s +/// `hasEntry=false` branch does (`ObjectGuid.h:157-165`: mask `0xFFFF_FFFF`, not the 24-bit entry +/// slot the other families use). Masking to 24 bits here would silently truncate a >16.7M template +/// id — none exist today, but the 32-bit mask is what vmangos actually does, so that's what we do. +/// +/// `None` for players/items/corpses/dynamic objects (no entry part, and not composed like a transport +/// either). +pub fn entry(guid: u64) -> Option { + match high(guid) { + HIGH_MO_TRANSPORT => Some((guid & 0xFFFF_FFFF) as u32), + HIGH_UNIT | HIGH_PET | HIGH_GAMEOBJECT | HIGH_TRANSPORT => { + Some(((guid >> 24) & 0xFF_FFFF) as u32) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Compose a guid the way vmangos does: `counter | (entry << 24) | (high << 48)`. + fn compose(high: u16, entry: u32, counter: u32) -> u64 { + u64::from(counter) | (u64::from(entry) << 24) | (u64::from(high) << 48) + } + + #[test] + fn creature_guid_decodes_high_and_entry() { + // A Northshire creature: entry 69 (Diseased Young Wolf), counter 12345. + let g = compose(HIGH_UNIT, 69, 12345); + assert_eq!(high(g), HIGH_UNIT); + assert!(is_creature_or_pet(g)); + assert!(!is_player(g)); + assert_eq!(entry(g), Some(69)); + } + + #[test] + fn player_guid_has_no_entry() { + let g = compose(HIGH_PLAYER, 0, 7); + assert!(is_player(g)); + assert!(!is_creature_or_pet(g)); + assert_eq!(entry(g), None); + } + + #[test] + fn zero_guid_is_nothing() { + assert!(!is_player(0)); + assert!(!is_creature_or_pet(0)); + assert!(!is_item(0)); + assert_eq!(entry(0), None); + } + + #[test] + fn item_guid_is_item_and_has_no_entry() { + let g = compose(HIGH_ITEM, 0, 42); + assert!(is_item(g)); + assert!(!is_player(g)); + assert_eq!( + entry(g), + None, + "an item's entry is a descriptor field, not guid bits" + ); + } + + #[test] + fn entry_masks_to_24_bits() { + let g = compose(HIGH_PET, 0xFF_FFFF, 0xFF_FFFF); + assert_eq!(entry(g), Some(0xFF_FFFF)); + } + + /// An elevator (`HIGHGUID_TRANSPORT`, 0xF120) composes exactly like an ordinary GameObject — + /// `Object::_Create(guidlow, goinfo->id, HIGHGUID_TRANSPORT)` (`GameObject.cpp:207`): the DB spawn's + /// own low guid in `counter`, the `gameobject_template` entry in the standard entry slot. + #[test] + fn elevator_guid_decodes_high_and_entry() { + // The Undercity/Ironforge-style elevator template entry 900 (a real GAMEOBJECT_TYPE_TRANSPORT + // id range), DB spawn low guid 4242. + let g = compose(HIGH_TRANSPORT, 900, 4242); + assert_eq!(high(g), HIGH_TRANSPORT); + assert!(is_transport(g)); + assert_eq!(entry(g), Some(900)); + } + + /// A boat/zeppelin (`HIGHGUID_MO_TRANSPORT`, 0x1FC0) composes the OTHER way — + /// `Object::_Create(guidlow, 0, HIGHGUID_MO_TRANSPORT)` (`Transport.cpp:65`) where `guidlow` is + /// itself the `gameobject_template` entry (`TransportMgr::CreateTransport`'s `entry` param threaded + /// straight through, `TransportMgr.cpp:364,400`): the entry rides the FULL low 32 bits, not the + /// 24-bit entry slot the standard families use — composed here with `entry=0` per the ctor + /// (`ObjectGuid.h:123`), matching `entry()`'s `HIGH_MO_TRANSPORT` mask. + #[test] + fn mo_transport_guid_decodes_high_and_entry() { + // The Menethil-Theramore ferry's real gameobject_template entry, 176495. + let g = compose(HIGH_MO_TRANSPORT, 0, 176_495); + assert_eq!(high(g), HIGH_MO_TRANSPORT); + assert!(is_transport(g)); + assert_eq!( + entry(g), + Some(176_495), + "the template entry rides the full low 32 bits, not bits 24-47" + ); + // The naive `(guid >> 24) & 0xFFFFFF` extraction the other families use would find nothing + // useful here — pin that the two layouts genuinely differ, not just that our helper works. + assert_ne!(((g >> 24) & 0xFF_FFFF) as u32, 176_495); + } + + #[test] + fn is_transport_excludes_other_families() { + assert!(!is_transport(compose(HIGH_UNIT, 69, 1))); + assert!(!is_transport(compose(HIGH_GAMEOBJECT, 1, 1))); + assert!(!is_transport(0)); + } +} diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs new file mode 100644 index 00000000..3121a802 --- /dev/null +++ b/crates/benilla-protocol/src/lib.rs @@ -0,0 +1,156 @@ +//! `benilla-protocol` — WoW 1.12.1 (build 5875) auth + world wire protocol. +//! +//! Auth/SRP6 and the world header crypto come from `benilla-srp`; message (de)serialization is +//! in-repo ([`auth`] for the realmd login protocol, [`world`]/[`events`] for the world protocol) — +//! decision 0021. 1.12.1 uses **login protocol version 3**. This crate owns the session logic; the +//! async↔ECS bridge lives in `benilla`. Phase 3: SRP6 logon + realm +//! list. + +pub mod auth; +pub mod events; +pub mod guid; +pub mod messages; +pub mod wire; +pub mod world; +pub use auth::AuthReject; +pub use events::{decode, CharAction, EntityKind, LoginStage, MoveSpeeds, Poll, SessionEvent}; +pub use messages::{ + CharCreateReq, CharEnumItem, Character, ItemInfo, JumpInfo, MonsterMoveFacing, ObjectFields, + ServerPacket, SpeedKind, TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, + CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, +}; +pub use world::{WardenRequired, WorldReader, WorldSession, WorldWriter, WORLD_PORT}; + +use std::net::TcpStream; + +use anyhow::{anyhow, Context, Result}; +use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_LENGTH}; + +/// The realmd (auth/login) server port. Our vmangos deploy maps realmd to host port 13724 — +/// moved off the classic 3724, which the WOA dev server owns on this machine (the compose file at +/// `vmangos-deploy` carries the matching mapping). +pub const AUTH_PORT: u16 = 13724; +/// The 1.12.1 client build we present to the server. +pub const CLIENT_BUILD: u16 = 5875; + +/// Split an optional `:port` off a host string (`play.example.com:3724`). A bare host — or one +/// whose suffix isn't a port, e.g. a raw IPv6 address like `::1` — comes back intact with +/// `default`. +pub fn host_port(host: &str, default: u16) -> (&str, u16) { + match host.rsplit_once(':') { + Some((h, p)) if !h.contains(':') => match p.parse::() { + Ok(p) => (h, p), + Err(_) => (host, default), + }, + _ => (host, default), + } +} + +/// A realm as advertised by the auth server's realm list. +#[derive(Debug, Clone)] +pub struct RealmInfo { + pub name: String, + /// `host:port` of the world server, as the client would connect to it. + pub address: String, + pub population: String, + pub characters: u8, + /// The wire's `realm_type` (the list "icon"): 0 Normal, 1 PvP, 6 RP, 8 RPPvP — the select + /// screen's `(PVP)`/`(RP)` realm-name suffix keys on it. + pub realm_type: u32, +} + +/// Result of a successful logon: the SRP6 session key (carried into the world server) + realms. +pub struct Logon { + pub session_key: [u8; SESSION_KEY_LENGTH], + pub realms: Vec, +} + +/// Perform the full SRP6 logon against a vanilla `realmd` and fetch the realm list. +/// +/// Flow: logon challenge → server challenge (B, g, N, salt) → SRP6 → logon proof → verify the +/// server's proof (M2) → request + read the realm list. +pub fn logon(host: &str, username: &str, password: &str) -> Result { + // `host` may carry an explicit `:port` (`WOW_HOST=play.example.com:3724`); bare hosts get + // [`AUTH_PORT`]. + let (host, port) = host_port(host, AUTH_PORT); + let mut stream = + TcpStream::connect((host, port)).with_context(|| format!("connecting to {host}:{port}"))?; + + // 1. Logon challenge — the account name is sent uppercased (matching the SRP6 calculation). + auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD) + .context("sending logon challenge")?; + + // 2. Server challenge → SRP6 inputs. + let reply = auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?; + + // 3. SRP6 (WoW flavor) — compute A, M1, and the session key. + let username_n = + NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?; + let password_n = + NormalizedString::new(password).map_err(|e| anyhow!("invalid password: {e}"))?; + let server_public_key = PublicKey::from_le_bytes(reply.server_public_key) + .map_err(|e| anyhow!("invalid server public key: {e}"))?; + let challenge = SrpClientChallenge::new( + username_n, + password_n, + reply.generator, + reply.large_safe_prime, + server_public_key, + reply.salt, + ); + + // 4. Logon proof. + auth::write_logon_proof( + &mut stream, + challenge.client_public_key(), + challenge.client_proof(), + ) + .context("sending logon proof")?; + + // 5. Verify the server's proof (M2); on success we hold the session key. + let server_proof = auth::read_proof_reply(&mut stream).context("reading logon proof reply")?; + let client = challenge + .verify_server_proof(server_proof) + .map_err(|e| anyhow!("server proof mismatch (wrong password?): {e}"))?; + let session_key = *client.session_key(); + + // 6. Realm list. + auth::write_realm_list_request(&mut stream).context("requesting realm list")?; + let realms = auth::read_realm_list(&mut stream).context("reading realm list")?; + + Ok(Logon { + session_key, + realms, + }) +} + +#[cfg(test)] +mod host_port_tests { + use super::host_port; + + #[test] + fn explicit_port_splits() { + assert_eq!( + host_port("play.example.com:3724", 13724), + ("play.example.com", 3724) + ); + } + + #[test] + fn bare_host_gets_default() { + assert_eq!(host_port("localhost", 13724), ("localhost", 13724)); + } + + #[test] + fn raw_ipv6_is_not_split() { + assert_eq!(host_port("::1", 3724), ("::1", 3724)); + } + + #[test] + fn non_numeric_suffix_stays_host() { + assert_eq!( + host_port("play.example.com:realmd", 3724), + ("play.example.com:realmd", 3724) + ); + } +} diff --git a/crates/benilla-protocol/src/messages/bank.rs b/crates/benilla-protocol/src/messages/bank.rs new file mode 100644 index 00000000..df38f476 --- /dev/null +++ b/crates/benilla-protocol/src/messages/bank.rs @@ -0,0 +1,69 @@ +//! Bank messages — the six-opcode wire (`CMSG_BANKER_ACTIVATE`/`SMSG_SHOW_BANK`/ +//! `CMSG_BUY_BANK_SLOT`/`SMSG_BUY_BANK_SLOT_RESULT`/`CMSG_AUTOSTORE_BANK_ITEM`/ +//! `CMSG_AUTOBANK_ITEM`, opcodes 439-442,642-643, vmangos `Opcodes_1_12_1.h`, VERIFIED). Bodies +//! from vmangos `Server/Packets/Npc.{h,cpp}` + `Server/Packets/Item.{h,cpp}`, all read this +//! session. Decision 0604 — the vault itself is already streamed through the ordinary player +//! descriptor (bank slots 39–62, bank bags 63–68); these opcodes only open the window, buy a bag +//! slot, and move items in/out. + +use std::io; + +use crate::wire::{read_u32_le, read_u64_le}; + +/// `SMSG_BUY_BANK_SLOT_RESULT`'s `u32` reason (vmangos `Player.h:91-94`, +/// `BANK_SLOT_*` enum). Sent only on failure — success is signaled purely by the +/// `PLAYER_BYTES_2` bank-bag-count byte advancing + the coinage delta, both via `UPDATE_OBJECT` +/// (decision 0604). +pub mod bank_slot_result { + pub const FAILED_TOO_MANY: u32 = 0; + pub const INSUFFICIENT_FUNDS: u32 = 1; + pub const NOTBANKER: u32 = 2; + pub const OK: u32 = 3; +} + +/// Body of `CMSG_BANKER_ACTIVATE` (vmangos `Npc.h:58-66`): one full 8-byte banker guid. Sent on a +/// right-click of a *pure* banker (bits 0-7 of `UNIT_NPC_FLAGS` clear, bit 8 `0x100` set) — a +/// gossip-flagged banker instead opens the gossip menu and the bank arrives through the +/// `GOSSIP_OPTION_BANKER` option's own `SMSG_SHOW_BANK` (decision 0604). +pub fn banker_activate(banker_guid: u64) -> Vec { + banker_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_BUY_BANK_SLOT` (vmangos `Item.h:157-163`): one full 8-byte banker guid. The +/// server buys slot `purchased_count + 1` itself (no slot index on the wire) — the client's job +/// is only knowing the next price, read from `BankBagSlotPrices.dbc` by `benilla-formats` +/// (decision 0604). +pub fn buy_bank_slot(banker_guid: u64) -> Vec { + banker_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_AUTOBANK_ITEM` (vmangos `Item.h:108-116`): `u8 srcbag, u8 srcslot`. Deposit +/// only — moves a non-bank item into the first free bank slot. Handler +/// `HandleAutoBankItemOpcode` (`ItemHandler.cpp:874`). +pub fn autobank_item(bag: u8, slot: u8) -> Vec { + vec![bag, slot] +} + +/// Body of `CMSG_AUTOSTORE_BANK_ITEM` (vmangos `Item.h:118-126`): `u8 srcbag, u8 srcslot`. +/// Direction-tolerant: vmangos's `HandleAutoStoreBankItemOpcode` (`ItemHandler.cpp:971`) +/// withdraws to inventory when `srcbag`/`srcslot` name a bank position, else deposits — the same +/// opcode drives both halves of the reference client's right-click auto-move while the bank +/// window is open. +pub fn autostore_bank_item(bag: u8, slot: u8) -> Vec { + vec![bag, slot] +} + +/// Read `SMSG_SHOW_BANK` (vmangos `Npc.cpp:94`, `buffer << bankerGuid`): one full 8-byte banker +/// guid. Opens the bank window; the server also sends this **unprompted** for the gossip bank +/// option (`GOSSIP_OPTION_BANKER` → `SendShowBank`, vmangos `Player.cpp:12426`) — a handler must +/// not assume it only ever follows our own [`banker_activate`]. +pub(super) fn read_show_bank(r: &mut &[u8]) -> io::Result { + read_u64_le(r) +} + +/// Read `SMSG_BUY_BANK_SLOT_RESULT` (vmangos `Item.cpp:137-140`): one `u32` [`bank_slot_result`] +/// code. vmangos sends this only on failure; a successful buy is silent, visible only as the +/// `PLAYER_BYTES_2` bank-bag-count byte advancing plus the coinage drop, both via `UPDATE_OBJECT`. +pub(super) fn read_buy_bank_slot_result(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} diff --git a/crates/benilla-protocol/src/messages/channel.rs b/crates/benilla-protocol/src/messages/channel.rs new file mode 100644 index 00000000..c7b36596 --- /dev/null +++ b/crates/benilla-protocol/src/messages/channel.rs @@ -0,0 +1,183 @@ +//! The channel subsystem — join/leave/list/moderation (opcodes 151-168, VERIFIED vmangos +//! `Server/Protocol/Opcodes_1_12_1.h:154-171`) plus the two server notification packets: +//! `SMSG_CHANNEL_NOTIFY` (a notice byte + channel name + a per-notice tail — [`read_channel_notify`], +//! VERIFIED vmangos `Chat/Channel.cpp:804-1008` against the enum in `Chat/Channel.h:35-71`) and +//! `SMSG_CHANNEL_LIST` (the member roster — [`read_channel_list`], VERIFIED +//! `Chat/Channel.cpp:497-557`). CMSG bodies are VERIFIED against `Server/Packets/Channel.h`/`.cpp` +//! (every `ReadFromWorldPacket` there): every moderation command reads `cstring channelName [+ +//! cstring playerName]` — the client body builders in [`super::client`] all funnel through +//! [`super::client::cstrings_body`] for that shared shape. + +use std::io; + +use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; + +/// `SMSG_CHANNEL_NOTIFY`'s notice byte (`ChatNotify`, VERIFIED vmangos `Chat/Channel.h:35-71`) — +/// selects which tail follows the channel name (see [`ChannelNoticeTail`]/[`read_channel_notify`]). +pub mod channel_notice { + pub const JOINED: u8 = 0x00; + pub const LEFT: u8 = 0x01; + pub const YOU_JOINED: u8 = 0x02; + pub const YOU_LEFT: u8 = 0x03; + pub const WRONG_PASSWORD: u8 = 0x04; + pub const NOT_MEMBER: u8 = 0x05; + pub const NOT_MODERATOR: u8 = 0x06; + pub const PASSWORD_CHANGED: u8 = 0x07; + pub const OWNER_CHANGED: u8 = 0x08; + pub const PLAYER_NOT_FOUND: u8 = 0x09; + pub const NOT_OWNER: u8 = 0x0A; + pub const CHANNEL_OWNER: u8 = 0x0B; + pub const MODE_CHANGE: u8 = 0x0C; + pub const ANNOUNCEMENTS_ON: u8 = 0x0D; + pub const ANNOUNCEMENTS_OFF: u8 = 0x0E; + pub const MODERATION_ON: u8 = 0x0F; + pub const MODERATION_OFF: u8 = 0x10; + pub const MUTED: u8 = 0x11; + pub const PLAYER_KICKED: u8 = 0x12; + pub const BANNED: u8 = 0x13; + pub const PLAYER_BANNED: u8 = 0x14; + pub const PLAYER_UNBANNED: u8 = 0x15; + pub const PLAYER_NOT_BANNED: u8 = 0x16; + pub const PLAYER_ALREADY_MEMBER: u8 = 0x17; + pub const INVITE: u8 = 0x18; + pub const INVITE_WRONG_FACTION: u8 = 0x19; + pub const WRONG_FACTION: u8 = 0x1A; + pub const INVALID_NAME: u8 = 0x1B; + pub const NOT_MODERATED: u8 = 0x1C; + pub const PLAYER_INVITED: u8 = 0x1D; + pub const PLAYER_INVITE_BANNED: u8 = 0x1E; + pub const THROTTLED: u8 = 0x1F; +} + +/// The per-notice tail `SMSG_CHANNEL_NOTIFY` carries after `u8 notice + cstring channel` — which +/// shape follows depends on `notice` ([`channel_notice`]); VERIFIED against vmangos's one +/// `Channel::Make*` producer per notice (`Chat/Channel.cpp:804-1008`). `NOT_MEMBER` (0x05) has a +/// second, **static** producer (`Channel::MakeNotOnPacket`, `Channel.cpp:847-851`, used when there's +/// no live `Channel` object to query — e.g. `/list` on a channel name that doesn't exist) that writes +/// the byte-**identical** shape (`u8 notice, cstring channel`, nothing more): both call sites decode +/// the same way, an empty tail — there is no ambiguity to special-case here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChannelNoticeTail { + /// 0x00 JOINED / 0x01 LEFT: the guid that joined/left. + Guid(u64), + /// 0x02 YOU_JOINED: our own new channel flags. The trailing `u32` vmangos writes after it is a + /// hardcoded `0` ("the non-zero number will be appended to the channel name" — never actually + /// sent nonzero) and carries no information, so it isn't surfaced. + YouJoined { flags: u32 }, + /// 0x03 YOU_LEFT / 0x04 WRONG_PASSWORD / 0x05 NOT_MEMBER / 0x06 NOT_MODERATOR / 0x0A NOT_OWNER / + /// 0x11 MUTED / 0x13 BANNED / 0x19 INVITE_WRONG_FACTION / 0x1A WRONG_FACTION / 0x1B INVALID_NAME / + /// 0x1C NOT_MODERATED / 0x1F THROTTLED: no tail at all — the notice + channel name is the whole + /// packet. + Empty, + /// 0x07 PASSWORD_CHANGED / 0x08 OWNER_CHANGED / 0x0D ANNOUNCEMENTS_ON / 0x0E ANNOUNCEMENTS_OFF / + /// 0x0F MODERATION_ON / 0x10 MODERATION_OFF / 0x17 PLAYER_ALREADY_MEMBER / 0x18 INVITE: the guid + /// that made the change / is doing the inviting. + Actor(u64), + /// 0x09 PLAYER_NOT_FOUND / 0x0B CHANNEL_OWNER / 0x16 PLAYER_NOT_BANNED / 0x1D PLAYER_INVITED / + /// 0x1E PLAYER_INVITE_BANNED: a bare player name (CHANNEL_OWNER sends the literal string + /// `"Nobody"` for a non-constant channel with no owner, or `"PLAYER_NOT_FOUND"` if the owner's + /// name can't be resolved — both ride this same string field). + Name(String), + /// 0x0C MODE_CHANGE: the affected guid + its `ChannelMemberFlags` byte before/after the change. + ModeChange { + guid: u64, + old_flags: u8, + new_flags: u8, + }, + /// 0x12 PLAYER_KICKED / 0x14 PLAYER_BANNED / 0x15 PLAYER_UNBANNED: the affected player, then who + /// did it. + Actors { target: u64, source: u64 }, +} + +/// One decoded `SMSG_CHANNEL_NOTIFY`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelNotify { + pub notice: u8, + pub channel: String, + pub tail: ChannelNoticeTail, +} + +/// Read `SMSG_CHANNEL_NOTIFY` (see [`ChannelNoticeTail`] for the per-notice VERIFIED citations). +/// An unrecognized notice byte (outside vmangos's defined `0x00..=0x1F`) errors rather than guessing +/// a shape — there is no way to skip an unknown tail without risking misreading the rest of this +/// packet's bytes as something else. +pub(super) fn read_channel_notify(r: &mut &[u8]) -> io::Result { + use channel_notice as n; + + let notice = read_u8(r)?; + let channel = read_cstring(r)?; + let tail = match notice { + n::JOINED | n::LEFT => ChannelNoticeTail::Guid(read_u64_le(r)?), + n::YOU_JOINED => { + let flags = read_u32_le(r)?; + let _reserved = read_u32_le(r)?; // always 0 on the wire (see the variant doc) + ChannelNoticeTail::YouJoined { flags } + } + n::YOU_LEFT + | n::WRONG_PASSWORD + | n::NOT_MEMBER + | n::NOT_MODERATOR + | n::NOT_OWNER + | n::MUTED + | n::BANNED + | n::INVITE_WRONG_FACTION + | n::WRONG_FACTION + | n::INVALID_NAME + | n::NOT_MODERATED + | n::THROTTLED => ChannelNoticeTail::Empty, + n::PASSWORD_CHANGED + | n::OWNER_CHANGED + | n::ANNOUNCEMENTS_ON + | n::ANNOUNCEMENTS_OFF + | n::MODERATION_ON + | n::MODERATION_OFF + | n::PLAYER_ALREADY_MEMBER + | n::INVITE => ChannelNoticeTail::Actor(read_u64_le(r)?), + n::PLAYER_NOT_FOUND + | n::CHANNEL_OWNER + | n::PLAYER_NOT_BANNED + | n::PLAYER_INVITED + | n::PLAYER_INVITE_BANNED => ChannelNoticeTail::Name(read_cstring(r)?), + n::MODE_CHANGE => ChannelNoticeTail::ModeChange { + guid: read_u64_le(r)?, + old_flags: read_u8(r)?, + new_flags: read_u8(r)?, + }, + n::PLAYER_KICKED | n::PLAYER_BANNED | n::PLAYER_UNBANNED => ChannelNoticeTail::Actors { + target: read_u64_le(r)?, + source: read_u64_le(r)?, + }, + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("SMSG_CHANNEL_NOTIFY: unknown notice type {other:#04x}"), + )) + } + }; + Ok(ChannelNotify { + notice, + channel, + tail, + }) +} + +/// Read `SMSG_CHANNEL_LIST` (`Channel::List`, VERIFIED vmangos `Chat/Channel.cpp:513-556`): cstring +/// channel name + `u8` channel flags + `u32` member count + that many `(u64 guid, u8 memberFlags)` +/// rows (`ChannelMemberFlags` — owner/moderator/voiced/muted/custom/mic-muted, `Chat/Channel.h: +/// 119-130`). Returns `(channel, flags, members)`. +#[allow(clippy::type_complexity)] +pub(super) fn read_channel_list(r: &mut &[u8]) -> io::Result<(String, u8, Vec<(u64, u8)>)> { + let channel = read_cstring(r)?; + let flags = read_u8(r)?; + let count = read_u32_le(r)?; + // The count is wire-controlled — never let it drive allocation (a corrupt 0xFFFFFFFF must fail + // at the bounds-checked reads below, not abort in the allocator). Legit rosters exceed 256 + // rarely; push simply grows past the hint. + let mut members = Vec::with_capacity((count as usize).min(256)); + for _ in 0..count { + let guid = read_u64_le(r)?; + let member_flags = read_u8(r)?; + members.push((guid, member_flags)); + } + Ok((channel, flags, members)) +} diff --git a/crates/benilla-protocol/src/messages/chat.rs b/crates/benilla-protocol/src/messages/chat.rs new file mode 100644 index 00000000..5917188f --- /dev/null +++ b/crates/benilla-protocol/src/messages/chat.rs @@ -0,0 +1,193 @@ +//! Inbound chat — `SMSG_MESSAGECHAT` (VERIFIED vmangos `ChatHandler::BuildChatPacket`, +//! `Chat/Chat.cpp:2542-2599`, all `SUPPORTED_CLIENT_BUILD` bands included for 5875): `u8 type`, `u32 +//! language`, a **per-type sender prefix**, then `u32 len` (includes the NUL) + the text + `u8 +//! chatTag`. This is how everything textual reaches the client — player say/yell/whisper, NPC +//! lines, channels, and the **system messages** GM dot-commands answer with (type `0x0A`), which +//! is what makes command feedback visible to the probe and the chat UI. +//! +//! Also owns the handful of small chat-adjacent SMSG bodies that aren't their own subsystem: +//! `SMSG_CHAT_PLAYER_NOT_FOUND`, `SMSG_CHAT_WRONG_FACTION`, `SMSG_PLAYED_TIME`, and `MSG_RANDOM_ROLL`'s +//! server→client broadcast shape. The channel subsystem (join/leave/list/moderation + +//! `SMSG_CHANNEL_NOTIFY`/`_LIST`) is big enough to own its own module: [`super::channel`]. + +use std::io; + +use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; + +// ChatMsg values (VERIFIED vmangos `SharedDefines.h:1191-1301`, the 5875 band — every +// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`-or-earlier guard is compile-time true for build +// 5875). vmangos's enum runs the full `0x00..=0x5D`; the combat-log/spell/skill/loot/money block +// (0x09, 0x0F-0x13, 0x17-0x50, 0x52-0x56, 0x5B) is **never** `SMSG_MESSAGECHAT` wire in 1.12 — canned +// emotes ride `SMSG_TEXT_EMOTE`, channel feedback rides `SMSG_CHANNEL_NOTIFY`/`_LIST`, and +// skill/loot/money/xp lines are client-composed from their own opcodes (decision 0288) — so those +// values have no named constant here. What's below is every type benilla either receives (as a +// distinct wire shape or a renderable line) or sends. +pub const CHAT_MSG_SAY: u8 = 0x00; +pub const CHAT_MSG_PARTY: u8 = 0x01; +pub const CHAT_MSG_RAID: u8 = 0x02; +pub const CHAT_MSG_GUILD: u8 = 0x03; +pub const CHAT_MSG_OFFICER: u8 = 0x04; +pub const CHAT_MSG_YELL: u8 = 0x05; +pub const CHAT_MSG_WHISPER: u8 = 0x06; +/// Server→client only ("You whisper to Name: …" self-echo, vmangos `MasterPlayerChat.cpp:54`) — +/// never a `CMSG_MESSAGECHAT` type a client sends. +pub const CHAT_MSG_WHISPER_INFORM: u8 = 0x07; +pub const CHAT_MSG_EMOTE: u8 = 0x08; +/// GM dot-command feedback and other server-authored lines; always the single-sender-guid shape +/// (the guid is usually 0). +pub const CHAT_MSG_SYSTEM: u8 = 0x0A; +pub const CHAT_MSG_MONSTER_SAY: u8 = 0x0B; +pub const CHAT_MSG_MONSTER_YELL: u8 = 0x0C; +pub const CHAT_MSG_MONSTER_EMOTE: u8 = 0x0D; +pub const CHAT_MSG_CHANNEL: u8 = 0x0E; +pub const CHAT_MSG_AFK: u8 = 0x14; +pub const CHAT_MSG_DND: u8 = 0x15; +/// The "so-and-so is ignoring you" self-notice (`WorldSession::HandleChatIgnoredOpcode`, +/// `Handlers/ChatHandler.cpp:755-763`) — single-sender-guid shape, sender is the ignoring player. +pub const CHAT_MSG_IGNORED: u8 = 0x16; +pub const CHAT_MSG_MONSTER_WHISPER: u8 = 0x1A; +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` — active for 5875. +pub const CHAT_MSG_RAID_LEADER: u8 = 0x57; +pub const CHAT_MSG_RAID_WARNING: u8 = 0x58; +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` — active for 5875 (pre-1.11.3 this value +/// aliased `CHAT_MSG_MONSTER_WHISPER` instead; irrelevant here since 5875 always takes this branch). +pub const CHAT_MSG_RAID_BOSS_WHISPER: u8 = 0x59; +pub const CHAT_MSG_RAID_BOSS_EMOTE: u8 = 0x5A; +pub const CHAT_MSG_BATTLEGROUND: u8 = 0x5C; +pub const CHAT_MSG_BATTLEGROUND_LEADER: u8 = 0x5D; + +/// `PlayerChatTag` (VERIFIED vmangos `Chat/Chat.h:86-92`) — [`ChatMessage::chat_tag`]'s values. +/// `Player::GetChatTag` (`Objects/Player.cpp:1757-1767`) picks GM over DND over AFK over none. +pub mod chat_tag { + pub const NONE: u8 = 0; + pub const AFK: u8 = 1; + pub const DND: u8 = 2; + pub const GM: u8 = 3; +} + +/// One decoded chat line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatMessage { + /// The `ChatMsg` type byte (0x00 say, 0x0A system, 0x0B monster say, …). + pub chat_type: u8, + /// The `Language` id (0 universal, 7 common, …). + pub language: u32, + /// The speaker, when the type carries a guid (0 for system messages / name-only shapes). + pub sender_guid: u64, + /// The speaker's name, for the monster shapes that carry it inline (`None` elsewhere — + /// player names resolve via the name query, exactly like the real client). + pub sender_name: Option, + /// The channel name, on `CHAT_MSG_CHANNEL` only. + pub channel: Option, + pub text: String, + /// The trailing tag byte ([`chat_tag`]) — 0 none, 1 AFK, 2 DND, 3 GM. Drives the + /// ``/``/`` prefix the real chat frame prepends to the sender's name. + pub chat_tag: u8, +} + +/// Read the length-prefixed string shape (`u32 len` including NUL + bytes). +fn read_len_string(r: &mut &[u8]) -> io::Result { + let len = read_u32_le(r)? as usize; + if len == 0 { + return Ok(String::new()); + } + // The NUL is inside `len`; read through it via the cstring reader and sanity-cap first. + if len > r.len() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + format!("chat text length {len} exceeds remaining {}", r.len()), + )); + } + read_cstring(r) +} + +/// Read an `SMSG_MESSAGECHAT` body. +pub(super) fn read_message_chat(r: &mut &[u8]) -> io::Result { + let chat_type = read_u8(r)?; + let language = read_u32_le(r)?; + let mut sender_guid = 0u64; + let mut sender_name = None; + let mut channel = None; + match chat_type { + CHAT_MSG_MONSTER_WHISPER + | CHAT_MSG_RAID_BOSS_WHISPER + | CHAT_MSG_RAID_BOSS_EMOTE + | CHAT_MSG_MONSTER_EMOTE => { + sender_name = Some(read_len_string(r)?); + let _target = read_u64_le(r)?; + } + CHAT_MSG_SAY | CHAT_MSG_PARTY | CHAT_MSG_YELL => { + sender_guid = read_u64_le(r)?; + let _sender_again = read_u64_le(r)?; + } + CHAT_MSG_MONSTER_SAY | CHAT_MSG_MONSTER_YELL => { + sender_guid = read_u64_le(r)?; + sender_name = Some(read_len_string(r)?); + let _target = read_u64_le(r)?; + } + CHAT_MSG_CHANNEL => { + channel = Some(read_cstring(r)?); + let _player_rank = read_u32_le(r)?; + sender_guid = read_u64_le(r)?; + } + // Everything else (RAID/GUILD/OFFICER/WHISPER/WHISPER_INFORM/EMOTE/SYSTEM/AFK/DND/IGNORED/ + // RAID_LEADER/RAID_WARNING/BATTLEGROUND(+LEADER)/BG_SYSTEM_*/…): one sender guid + // (vmangos `BuildChatPacket`'s own `default:` branch). + _ => { + sender_guid = read_u64_le(r)?; + } + } + let text = read_len_string(r)?; + let chat_tag = read_u8(r)?; + Ok(ChatMessage { + chat_type, + language, + sender_guid, + sender_name, + channel, + text, + chat_tag, + }) +} + +/// Read `SMSG_CHAT_PLAYER_NOT_FOUND` (VERIFIED vmangos `WorldPackets::Chat::ChatPlayerNotFound:: +/// AppendBodyTo`, `Server/Packets/Chat.cpp:26-29`): one cstring, the (un-normalized) name the +/// player tried to whisper. Sent by `WorldSession::SendPlayerNotFoundNotice` +/// (`Handlers/ChatHandler.cpp:766-771`) — a bad whisper target, offline or misspelled. +pub(super) fn read_chat_player_not_found(r: &mut &[u8]) -> io::Result { + read_cstring(r) +} + +/// Read `SMSG_NOTIFICATION` (VERIFIED vmangos `WorldSession::SendNotification`, +/// `Server/WorldSession.cpp:900-915`: `data << szStr`): one cstring — the notice text, already +/// formatted server-side ("You do not know that language", trade refusals, and kin). The real +/// client flashes it in the red UIErrorsFrame. +pub(super) fn read_notification(r: &mut &[u8]) -> io::Result { + read_cstring(r) +} + +/// Read `SMSG_PLAYED_TIME` (VERIFIED vmangos `WorldPackets::Misc::PlayedTime::AppendBodyTo`, +/// `Server/Packets/Misc.cpp:278-282`; handler `WorldSession::HandlePlayedTime`, +/// `Handlers/MiscHandler.cpp:935-941`): `u32 totalPlayedTime` (seconds since character creation) + +/// `u32 levelPlayedTime` (seconds since the last level-up) — answers `CMSG_PLAYED_TIME` (empty +/// body, the `/played` command). +pub(super) fn read_played_time(r: &mut &[u8]) -> io::Result<(u32, u32)> { + let total = read_u32_le(r)?; + let level = read_u32_le(r)?; + Ok((total, level)) +} + +/// Read `MSG_RANDOM_ROLL`'s server→client **broadcast** shape (VERIFIED vmangos +/// `WorldSession::HandleRandomRollOpcode`, `Handlers/GroupHandler.cpp:394-422`): `u32 minimum, u32 +/// maximum, u32 roll` (the server's own `urand(minimum, maximum)`), then a **raw** `u64` guid — the +/// roller (`data << GetPlayer()->GetObjectGuid()`, an `ObjectGuid` stream write, so unpacked). +/// Same opcode as the client→server request shape (`u32 minimum, u32 maximum`, +/// [`super::client::random_roll`]) — a client only ever *receives* this broadcast shape, never its +/// own request echoed back. +pub(super) fn read_random_roll(r: &mut &[u8]) -> io::Result<(u32, u32, u32, u64)> { + let min = read_u32_le(r)?; + let max = read_u32_le(r)?; + let roll = read_u32_le(r)?; + let guid = read_u64_le(r)?; + Ok((min, max, roll, guid)) +} diff --git a/crates/benilla-protocol/src/messages/client.rs b/crates/benilla-protocol/src/messages/client.rs new file mode 100644 index 00000000..ad05ab71 --- /dev/null +++ b/crates/benilla-protocol/src/messages/client.rs @@ -0,0 +1,399 @@ +//! Client packet **body builders** — the outbound half of the message layer (the inbound parse + +//! the `ServerPacket` vocabulary stay in [`super`]; spell/attack builders live with their family +//! in [`super::spells`]). Framing/encryption is `world.rs`'s job; these produce plain body bytes. + +use super::{CharCreateReq, MovementInfo}; + +/// `CMSG_AUTH_SESSION` body: build, server id, account name, client seed, proof, and an empty +/// zlib-wrapped addon block (`decompressed_size = 0` + zlib of no bytes — what the client sends and +/// vmangos accepts). +pub fn auth_session( + build: u32, + username: &str, + client_seed: u32, + client_proof: &[u8; 20], +) -> Vec { + let mut body = Vec::with_capacity(40 + username.len()); + body.extend_from_slice(&build.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // server_id + body.extend_from_slice(username.as_bytes()); + body.push(0); + body.extend_from_slice(&client_seed.to_le_bytes()); + body.extend_from_slice(client_proof); + body.extend_from_slice(&0u32.to_le_bytes()); // addon decompressed_size + let encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + let empty_addon_zlib = encoder.finish().expect("zlib finish on empty input"); + body.extend_from_slice(&empty_addon_zlib); + body +} + +/// `CMSG_CHAR_CREATE` body: name (CString), then race/class/gender + the 5 appearance bytes +/// (skin/face/hair_style/hair_color/facial_hair) + `outfit_id`. Matches vmangos's read exactly +/// (`Packets/Character.cpp:4-19`): name + 9 bytes. `outfit_id` is always 0 — the server reads and +/// ignores it (`CharacterHandler.cpp:310`) and recomputes start gear (decision 0423). +pub fn char_create(req: &CharCreateReq) -> Vec { + let mut body = Vec::with_capacity(req.name.len() + 10); + body.extend_from_slice(req.name.as_bytes()); + body.push(0); + // race, class, gender, skin, face, hair_style, hair_color, facial_hair, outfit_id(0). + body.extend_from_slice(&[ + req.race, + req.class, + req.gender, + req.skin, + req.face, + req.hair_style, + req.hair_color, + req.facial_hair, + 0, + ]); + body +} + +/// `CMSG_MESSAGECHAT` body for every sendable `ChatMsg` type (VERIFIED vmangos +/// `WorldPackets::Chat::ChatMessage::ReadFromWorldPacket`, `Server/Packets/Chat.cpp:3-12`): +/// `recv_data >> type >> lang`, then `>> whisperTargetOrChannel` **only** for +/// `CHAT_MSG_WHISPER`/`CHAT_MSG_CHANNEL` (the target name or channel name rides *before* the +/// message, not after — vmangos's own union of the two), then `>> message`. `target` is `None` for +/// every other sendable type. The general form [`messagechat`]/[`messagechat_whisper`]/ +/// [`messagechat_channel`] build on. +pub fn messagechat_kind( + chat_type: u32, + language: u32, + target: Option<&str>, + message: &str, +) -> Vec { + let mut body = Vec::with_capacity(message.len() + target.map_or(0, str::len) + 10); + body.extend_from_slice(&chat_type.to_le_bytes()); + body.extend_from_slice(&language.to_le_bytes()); + if let Some(target) = target { + body.extend_from_slice(target.as_bytes()); + body.push(0); + } + body.extend_from_slice(message.as_bytes()); + body.push(0); + body +} + +/// `CMSG_MESSAGECHAT` body (chat type + language as `u32`, then the message) — the shape shared by +/// every type that carries no target/channel name (SAY, YELL, EMOTE, PARTY, RAID, GUILD, OFFICER, +/// RAID_LEADER, RAID_WARNING, BATTLEGROUND(+LEADER), AFK, DND). Thin wrapper over +/// [`messagechat_kind`] with `target: None`. +pub fn messagechat(chat_type: u32, language: u32, message: &str) -> Vec { + messagechat_kind(chat_type, language, None, message) +} + +/// `CMSG_MESSAGECHAT` body for `CHAT_MSG_WHISPER` (`0x6`): chat type + language, then the whisper +/// **target's player name**, then the message. Thin wrapper over [`messagechat_kind`]. +pub fn messagechat_whisper(language: u32, target: &str, message: &str) -> Vec { + messagechat_kind(super::CHAT_TYPE_WHISPER, language, Some(target), message) +} + +/// `CMSG_MESSAGECHAT` body for `CHAT_MSG_CHANNEL` (`0xE`): chat type + language, then the +/// **channel name**, then the message — the same wire shape as [`messagechat_whisper`], vmangos's +/// `whisperTargetOrChannel` field doing double duty. Thin wrapper over [`messagechat_kind`]. +pub fn messagechat_channel(language: u32, channel: &str, message: &str) -> Vec { + messagechat_kind(super::CHAT_TYPE_CHANNEL, language, Some(channel), message) +} + +/// Concatenate `strings` as back-to-back NUL-terminated C-strings — the shape shared by the whole +/// `CMSG_CHANNEL_*` family (VERIFIED vmangos `Server/Packets/Channel.cpp`, every `ReadFromWorldPacket` +/// there: a channel name alone, or a channel name + a second field — a password or a target player +/// name). +fn cstrings_body(strings: &[&str]) -> Vec { + let mut body = Vec::with_capacity(strings.iter().map(|s| s.len() + 1).sum()); + for s in strings { + body.extend_from_slice(s.as_bytes()); + body.push(0); + } + body +} + +/// `CMSG_JOIN_CHANNEL` body (VERIFIED vmangos `WorldPackets::Channel::JoinChannel:: +/// ReadFromWorldPacket`, `Server/Packets/Channel.cpp:3-7`): cstring channel name + cstring password +/// (empty string for none) — **no channel id** on the 1.12 wire (a TBC+ addition). +pub fn join_channel(name: &str, password: &str) -> Vec { + cstrings_body(&[name, password]) +} + +/// `CMSG_LEAVE_CHANNEL` body: cstring channel name (VERIFIED vmangos +/// `WorldPackets::Channel::LeaveChannel::ReadFromWorldPacket`, `Channel.cpp:9-12`). +pub fn leave_channel(name: &str) -> Vec { + cstrings_body(&[name]) +} + +/// `CMSG_CHANNEL_LIST` body — asks for the member roster (VERIFIED vmangos +/// `WorldPackets::Channel::ChannelList::ReadFromWorldPacket`, `Channel.cpp:14-17`): cstring channel +/// name. Answered by `SMSG_CHANNEL_LIST` ([`super::channel::read_channel_list`]). +pub fn channel_list(name: &str) -> Vec { + cstrings_body(&[name]) +} + +/// `CMSG_CHANNEL_PASSWORD` body (VERIFIED vmangos `ChannelPassword::ReadFromWorldPacket`, +/// `Channel.cpp:19-23`): cstring channel name + cstring new password. +pub fn channel_password(name: &str, password: &str) -> Vec { + cstrings_body(&[name, password]) +} + +/// `CMSG_CHANNEL_SET_OWNER` body (VERIFIED vmangos `ChannelSetOwner::ReadFromWorldPacket`, +/// `Channel.cpp:25-29`): cstring channel name + cstring new owner's player name. +pub fn channel_set_owner(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_OWNER` body — asks who owns the channel (VERIFIED vmangos +/// `ChannelOwner::ReadFromWorldPacket`, `Channel.cpp:31-34`): cstring channel name only (the answer +/// rides `SMSG_CHANNEL_NOTIFY`'s [`super::channel::channel_notice::CHANNEL_OWNER`] notice). +pub fn channel_owner(name: &str) -> Vec { + cstrings_body(&[name]) +} + +/// `CMSG_CHANNEL_MODERATOR` body (VERIFIED vmangos `ChannelModerator::ReadFromWorldPacket`, +/// `Channel.cpp:36-40`): cstring channel name + cstring target player name. +pub fn channel_moderator(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_UNMODERATOR` body (VERIFIED vmangos `ChannelUnmoderator::ReadFromWorldPacket`, +/// `Channel.cpp:42-46`): cstring channel name + cstring target player name. +pub fn channel_unmoderator(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_MUTE` body (VERIFIED vmangos `ChannelMute::ReadFromWorldPacket`, +/// `Channel.cpp:48-52`): cstring channel name + cstring target player name. +pub fn channel_mute(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_UNMUTE` body (VERIFIED vmangos `ChannelUnmute::ReadFromWorldPacket`, +/// `Channel.cpp:54-58`): cstring channel name + cstring target player name. +pub fn channel_unmute(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_INVITE` body (VERIFIED vmangos `ChannelInvite::ReadFromWorldPacket`, +/// `Channel.cpp:60-64`): cstring channel name + cstring invitee player name. +pub fn channel_invite(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_KICK` body (VERIFIED vmangos `ChannelKick::ReadFromWorldPacket`, +/// `Channel.cpp:66-70`): cstring channel name + cstring target player name. +pub fn channel_kick(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_BAN` body (VERIFIED vmangos `ChannelBan::ReadFromWorldPacket`, +/// `Channel.cpp:72-76`): cstring channel name + cstring target player name. +pub fn channel_ban(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_UNBAN` body (VERIFIED vmangos `ChannelUnban::ReadFromWorldPacket`, +/// `Channel.cpp:78-82`): cstring channel name + cstring target player name. +pub fn channel_unban(name: &str, player: &str) -> Vec { + cstrings_body(&[name, player]) +} + +/// `CMSG_CHANNEL_ANNOUNCEMENTS` body — toggles the channel's join/leave announcements (VERIFIED +/// vmangos `ChannelAnnouncements::ReadFromWorldPacket`, `Channel.cpp:84-87`): cstring channel name +/// only. +pub fn channel_announcements(name: &str) -> Vec { + cstrings_body(&[name]) +} + +/// `CMSG_CHANNEL_MODERATE` body — toggles channel moderation (VERIFIED vmangos +/// `ChannelModerate::ReadFromWorldPacket`, `Channel.cpp:89-92`): cstring channel name only. +pub fn channel_moderate(name: &str) -> Vec { + cstrings_body(&[name]) +} + +/// `CMSG_PLAYED_TIME` body — empty (VERIFIED vmangos: the handler takes a `NullClientPacket`, +/// `Handlers/MiscHandler.cpp:935`). The `/played` request. +pub fn played_time() -> Vec { + Vec::new() +} + +/// `MSG_RANDOM_ROLL` client→server request body (VERIFIED vmangos +/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`): `u32 +/// minimum + u32 maximum`. The server validates `minimum <= maximum <= 10000` and broadcasts the +/// roll (layout in [`super::chat::read_random_roll`]); an out-of-range request is silently dropped. +pub fn random_roll(min: u32, max: u32) -> Vec { + let mut body = Vec::with_capacity(8); + body.extend_from_slice(&min.to_le_bytes()); + body.extend_from_slice(&max.to_le_bytes()); + body +} + +/// `CMSG_TEXT_EMOTE` body: `u32 textEmote (EmotesText.dbc id), u32 emoteNum, u64 target guid` +/// (0 = no target) — VERIFIED vmangos `TextEmote::ReadFromWorldPacket` (`Misc.cpp:60-65`), three +/// fields. `emoteNum` is only relayed to observers inside `SMSG_TEXT_EMOTE` (the emote-text +/// variation index; we send 0 = the first variation). The 12-byte two-field body this originally +/// sent made the server's guid read run off the packet's end — the emote was silently discarded, +/// on every send, which read as "/wave does nothing" (director-caught against the ref client). +/// The server echoes `SMSG_TEXT_EMOTE` to everyone in range **including us**, so our own emote +/// sound/anim plays through the same receive path as everyone else's. +pub fn text_emote(text_id: u32, target: u64) -> Vec { + let mut b = Vec::with_capacity(16); + b.extend_from_slice(&text_id.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); // emoteNum: text-variation 0 + b.extend_from_slice(&target.to_le_bytes()); + b +} + +pub fn full_guid(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_STANDSTATECHANGE` (vmangos `StandStateChange::ReadFromWorldPacket`: +/// `recv_data >> animState`): one `u32` stand state. The server accepts only +/// {0 STAND, 1 SIT, 3 SLEEP, 8 KNEEL} (`HandleStandStateChangeOpcode`, +/// `MiscHandler.cpp:437`) and applies it via `Unit::SetStandState` → `UNIT_FIELD_BYTES_1` +/// byte 0, which relays to every observer — the same client-volunteers → server-echoes → +/// fields-drive-everyone pattern as sheath (decision 0080c). +pub fn stand_state_change(state: u32) -> Vec { + state.to_le_bytes().to_vec() +} + +/// Body of a `CMSG_CREATURE_QUERY`: the template `entry` + the asking guid (VERIFIED vmangos +/// `QueryCreature::ReadFromWorldPacket` — `entry` then a full 8-byte guid). +pub fn creature_query(entry: u32, guid: u64) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&entry.to_le_bytes()); + body.extend_from_slice(&guid.to_le_bytes()); + body +} + +/// Body of a movement message (`MSG_MOVE_*`) — a `MovementInfo`. +pub fn movement(info: &MovementInfo) -> Vec { + let mut body = Vec::with_capacity(28); + info.write(&mut body); + body +} + +/// Body of `CMSG_MOVE_SPLINE_DONE` (client): the `MovementInfo` at the ride's endpoint, the +/// `spline_id` being acknowledged (matched against the server's newest spline for our mover), and a +/// trailing float the server reads-and-discards (`MoveSplineDone::ReadFromWorldPacket` does an +/// unconditional `read_skip()`, so the four bytes MUST be present or the server's parse +/// under-runs). The real client writes its **completion fraction** `clamp(elapsed/duration, 0, 1)` +/// there (byte-verified — `0x600b10`, decision 0496 §claim-6); we only ever send at ride +/// completion, so the faithful value is `1.0`. Emitted once when a server spline that drove our own +/// player (Charge/knockback/taxi) finishes; the server then relocates us and re-broadcasts a +/// stop/heartbeat to observers. +pub fn move_spline_done(info: &MovementInfo, spline_id: u32) -> Vec { + let mut body = Vec::with_capacity(36); + info.write(&mut body); + body.extend_from_slice(&spline_id.to_le_bytes()); + body.extend_from_slice(&1.0f32.to_le_bytes()); // completion fraction (server-skipped) + body +} + +/// Body of `MSG_MOVE_TELEPORT_ACK` (client): a **full** 8-byte GUID (not packed — vmangos requires +/// it for this opcode), the movement counter, and the client time. +pub fn teleport_ack(guid: u64, counter: u32, time_ms: u32) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&guid.to_le_bytes()); + body.extend_from_slice(&counter.to_le_bytes()); + body.extend_from_slice(&time_ms.to_le_bytes()); + body +} + +/// Body of a `CMSG_FORCE_*_SPEED_CHANGE_ACK` (all six kinds share it): a **full** 8-byte guid (the +/// mover the SMSG named — vmangos `MoveSpeedAck::ReadFromWorldPacket` extracts a plain `ObjectGuid`, +/// which reads a raw `uint64`, `ObjectGuid.cpp:180` — NOT packed, unlike the SMSG's guid), the +/// echoed movement counter, our live `MovementInfo`, and the echoed new speed. The server accepts +/// only an exact counter match and a speed within ±0.01 of what it sent +/// (`Unit::FindPendingMovementSpeedChange`), then relocates us to the carried pose — so the info +/// must be the mover's honest live state, same as any `MSG_MOVE_*`. +pub fn force_speed_ack(guid: u64, counter: u32, info: &MovementInfo, speed: f32) -> Vec { + let mut body = Vec::with_capacity(44); + body.extend_from_slice(&guid.to_le_bytes()); + body.extend_from_slice(&counter.to_le_bytes()); + info.write(&mut body); + body.extend_from_slice(&speed.to_le_bytes()); + body +} + +/// Body of `CMSG_PING` (the ~30 s keepalive): `{u32 sequence, u32 lastRtt}` — VERIFIED twice, wow-re +/// net W1 (`SendPing 0x537e10`: seq = ++counter, then the last measured round-trip ms) and vmangos +/// `WorldSocket::_HandlePing` (`recvPacket >> ping >> latency`). The server echoes `sequence` back +/// as `SMSG_PONG` and stores `lastRtt` as the session's reported latency. +pub fn ping(sequence: u32, last_rtt_ms: u32) -> Vec { + let mut body = Vec::with_capacity(8); + body.extend_from_slice(&sequence.to_le_bytes()); + body.extend_from_slice(&last_rtt_ms.to_le_bytes()); + body +} + +/// Body of a movement-flag-change ack (`CMSG_FORCE_MOVE_[UN]ROOT_ACK` / `CMSG_MOVE_WATER_WALK_ACK` +/// — the death arc's root/water-walk family, decision 0308): a **full** `u64` guid, the echoed +/// `u32` movement counter, our current `MovementInfo`, and — for the water-walk ack ONLY — a +/// trailing `u32 apply` (VERIFIED vmangos `Server/Packets/Movement.cpp:38-59`: +/// `MoveFlagChangeAck` reads the apply dword, `MoveRootAck` does not). The counter MUST be the one +/// the server sent — a zero/greater counter trips its cheat log (`HandleMoveRootAck`). +pub fn move_flag_ack(guid: u64, counter: u32, info: &MovementInfo, apply: Option) -> Vec { + let mut body = Vec::with_capacity(48); + body.extend_from_slice(&guid.to_le_bytes()); + body.extend_from_slice(&counter.to_le_bytes()); + info.write(&mut body); + if let Some(apply) = apply { + body.extend_from_slice(&u32::from(apply).to_le_bytes()); + } + body +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::Vector3d; + + /// `CMSG_MOVE_SPLINE_DONE` body, byte-exact: a `MovementInfo` (here with no swim/jump tail), + /// then the acked `splineId`, then the trailing float the server `read_skip`s. The order and the + /// mandatory-but-ignored trailing four bytes are what vmangos `MoveSplineDone::ReadFromWorldPacket` + /// requires (`>> movementInfo; >> splineId; read_skip()`); omitting the float under-runs + /// its parse. A new wire body never lands without a golden (method). + #[test] + fn move_spline_done_body_golden() { + let info = MovementInfo { + flags: 0, // no SWIMMING / JUMPING ⇒ no conditional MovementInfo tails + timestamp: 0x1122_3344, + position: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + transport: None, + pitch: 0.0, + fall_time: 0x5566_7788, + jump: None, + }; + let body = move_spline_done(&info, 0xAABB_CCDD); + #[rustfmt::skip] + let want: &[u8] = &[ + 0x00, 0x00, 0x00, 0x00, // flags + 0x44, 0x33, 0x22, 0x11, // timestamp + 0x00, 0x00, 0x80, 0x3F, // pos.x = 1.0 + 0x00, 0x00, 0x00, 0x40, // pos.y = 2.0 + 0x00, 0x00, 0x40, 0x40, // pos.z = 3.0 + 0x00, 0x00, 0x00, 0x3F, // orientation = 0.5 + 0x88, 0x77, 0x66, 0x55, // fall_time + 0xDD, 0xCC, 0xBB, 0xAA, // splineId + 0x00, 0x00, 0x80, 0x3F, // completion fraction 1.0 (server-skipped) + ]; + assert_eq!(body, want, "CMSG_MOVE_SPLINE_DONE body"); + // The leading bytes are exactly a MovementInfo (the shape the server reads first). + let mi = movement(&info); + assert_eq!( + &body[..mi.len()], + &mi[..], + "MovementInfo comes first, verbatim" + ); + assert_eq!( + body.len(), + mi.len() + 8, + "…then splineId + the skipped float" + ); + } +} diff --git a/crates/benilla-protocol/src/messages/death.rs b/crates/benilla-protocol/src/messages/death.rs new file mode 100644 index 00000000..9f9e1aa5 --- /dev/null +++ b/crates/benilla-protocol/src/messages/death.rs @@ -0,0 +1,285 @@ +//! The death-arc messages (decision 0308): release/repop, the corpse query, the reclaim delay + +//! reclaim, the spirit healer, and resurrect requests. Every body shape below is VERIFIED against +//! vmangos source (file:line cites per item); the goldens mirror the server's own packet builds. +//! +//! Not modelled here (known siblings): `SMSG_DURABILITY_DAMAGE_DEATH` (0x2BD — an EMPTY body the +//! server sends with the 10% natural-death durability loss; the real client shows a durability +//! warning — a later polish slice), and the BG-only death family (area spirit healers, the forced +//! BG repop aura 2584 — 0308 defers battlegrounds). + +use std::io; + +use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8}; + +/// `MSG_CORPSE_QUERY`'s answer (opcode `0x216`/534 — VERIFIED vmangos `Opcodes_1_12_1.h:535`; our +/// request is the same opcode with an EMPTY body, `NullClientPacket`). VERIFIED +/// `QueryHandler.cpp:258-304` (`HandleCorpseQueryOpcode`): not-found = the lone `u8(0)`; found = +/// `u8(1), i32 mapid, f32 x, f32 y, f32 z, u32 corpsemapid`. +/// +/// The two maps differ deliberately: `display_map`/`position` are **where to walk toward** — for a +/// corpse inside a dungeon they are rewritten to the dungeon's `ghostEntranceMap` + entrance +/// coordinates — while `corpse_map` is always the corpse's real map, untouched. The server also +/// pushes an UNPROMPTED not-found (`u8(0)`) when a lootable corpse converts to bones +/// (`Map.cpp:3624-3629`), so a not-found mid-session means "drop the marker", not "query failed". +#[derive(Debug, Clone, PartialEq)] +pub struct CorpseLocation { + pub found: bool, + /// The map to show/route toward (dungeon-entrance-adjusted). `0` when `!found`. + pub display_map: i32, + /// Raw WoW coordinates of the corpse (or the dungeon entrance). Zeroed when `!found`. + pub position: [f32; 3], + /// The corpse's REAL map id, never adjusted. `0` when `!found`. + pub corpse_map: u32, +} + +/// Read `MSG_CORPSE_QUERY`'s answer — both shapes (see [`CorpseLocation`]). +pub(super) fn read_corpse_query_response(r: &mut &[u8]) -> io::Result { + let found = read_u8(r)? != 0; + if !found { + return Ok(CorpseLocation { + found: false, + display_map: 0, + position: [0.0; 3], + corpse_map: 0, + }); + } + let display_map = read_i32_le(r)?; + let position = [read_f32_le(r)?, read_f32_le(r)?, read_f32_le(r)?]; + let corpse_map = read_u32_le(r)?; + Ok(CorpseLocation { + found: true, + display_map, + position, + corpse_map, + }) +} + +/// Read `SMSG_CORPSE_RECLAIM_DELAY` (opcode `0x269`/617 — VERIFIED `Opcodes_1_12_1.h:618`): one +/// `u32`, the delay in **milliseconds** before the corpse can be reclaimed (VERIFIED +/// `Server/Packets/Misc.cpp:653-656` — `buffer << delayMs`). Sent at release +/// (`Player::BuildPlayerRepop`, `Player.cpp:4677`) and at login while dead +/// (`CharacterHandler.cpp:571-572`) — never at the death itself. The value is 30 s base, +/// 60/120 s for repeated deaths within 5-minute steps (`copseReclaimDelay[]`, `Player.cpp:106`). +pub(super) fn read_corpse_reclaim_delay(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// A resurrection offer (`SMSG_RESURRECT_REQUEST`, opcode `0x15B`/347 — VERIFIED +/// `Opcodes_1_12_1.h:348`). VERIFIED `Spell.cpp:5024-5044` (`Spell::SendResurrectRequest`): +/// `u64 caster, u32 nameLen (strlen+1), cstring name, u8 sickness, u8 hasResTimer`. +/// +/// `name` is **empty when the caster is a player** (the client resolves the offerer's display name +/// from the guid through the ordinary name-query cache); it carries the localized creature name for +/// an NPC caster. `sickness` warns the accept applies resurrection sickness; `has_timer` says the +/// client should still honor the `SMSG_CORPSE_RECLAIM_DELAY` gate (inverted server-side from +/// `SPELL_ATTR_EX3_NO_RES_TIMER`). The reference UI picks its popup off exactly these two bits +/// (RESURRECT / RESURRECT_NO_SICKNESS / RESURRECT_NO_TIMER — UIParent.lua's RESURRECT_REQUEST arm). +#[derive(Debug, Clone, PartialEq)] +pub struct ResurrectRequestBody { + pub caster: u64, + /// Empty for a player caster (resolve by guid); the creature's name for an NPC caster. + pub name: String, + pub sickness: bool, + pub has_timer: bool, +} + +/// Read `SMSG_RESURRECT_REQUEST` (see [`ResurrectRequestBody`]). The `u32` length prefix duplicates +/// the C-string's own length (strlen+1) — read and cross-checked loosely (the cstring terminator is +/// authoritative; vmangos always writes both consistently). +pub(super) fn read_resurrect_request(r: &mut &[u8]) -> io::Result { + let caster = read_u64_le(r)?; + let _name_len = read_u32_le(r)?; + let name = read_cstring(r)?; + let sickness = read_u8(r)? != 0; + let has_timer = read_u8(r)? != 0; + Ok(ResurrectRequestBody { + caster, + name, + sickness, + has_timer, + }) +} + +/// Read `SMSG_SPIRIT_HEALER_CONFIRM` (opcode `0x222`/546 — VERIFIED `Opcodes_1_12_1.h:547`): one +/// full `u64`, the spirit-healer NPC's guid. VERIFIED `SpellEffects.cpp:818-831` — vmangos sends it +/// from the gossip-menu spirit-healer option (the NPC casts 17251 "Spirit Healer Res" whose dummy +/// effect builds this packet), NOT from `CMSG_SPIRIT_HEALER_ACTIVATE`. The client answers the +/// eventual XP_LOSS confirm-accept with [`spirit_healer_activate`] carrying this guid. +pub(super) fn read_spirit_healer_confirm(r: &mut &[u8]) -> io::Result { + read_u64_le(r) +} + +/// Body of `CMSG_RECLAIM_CORPSE` (opcode `0x1D2`/466 — VERIFIED `Opcodes_1_12_1.h:467`): one full +/// `u64` guid (VERIFIED `Server/Packets/Misc.cpp:122-125` — `recv_data >> guid`). The server +/// resolves the corpse through the player itself and never checks this guid's content, but the +/// real client sends its corpse's guid — we do the same. Server gates (`MiscHandler.cpp:573-603`): +/// dead + `PLAYER_FLAGS_GHOST` + corpse exists + the reclaim delay elapsed + within +/// `CORPSE_RECLAIM_RADIUS` (39 yd, `Corpse.h:40`) — success is `ResurrectPlayer(0.5)` + bones. +pub fn reclaim_corpse(corpse_guid: u64) -> Vec { + corpse_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_SPIRIT_HEALER_ACTIVATE` (opcode `0x21C`/540 — VERIFIED `Opcodes_1_12_1.h:541`): +/// one full `u64`, the spirit healer's guid (VERIFIED `Server/Packets/Npc.cpp:40-43`). Server +/// gates (`NPCHandler.cpp:416-428` → `CanInteractWithNPC` with `UNIT_NPC_FLAG_SPIRITHEALER` +/// `0x20`): 5-yd interaction distance, ghost-visible NPC. Effect (`SendSpiritResurrect`, +/// `NPCHandler.cpp:430-477`): res at 50%, 25% durability loss on ALL items, resurrection sickness +/// 15007 at level ≥ 11 (scaling duration to 10 min at 20+), corpse → bones, teleport to the +/// corpse-nearest graveyard when it differs from the current one. +pub fn spirit_healer_activate(npc: u64) -> Vec { + npc.to_le_bytes().to_vec() +} + +/// Body of `CMSG_RESURRECT_RESPONSE` (opcode `0x15C`/348 — VERIFIED `Opcodes_1_12_1.h:349`): the +/// offerer's `u64` guid + `u8 accept` (VERIFIED `Server/Packets/Misc.cpp:132-136`). A decline just +/// clears the server-side offer; an accept teleports us to the caster (cross-instance redirected to +/// the entrance) and resurrects with the offer's stored health/mana (`Player.cpp:20188-20244`). +pub fn resurrect_response(caster: u64, accept: bool) -> Vec { + let mut body = Vec::with_capacity(9); + body.extend_from_slice(&caster.to_le_bytes()); + body.push(u8::from(accept)); + body +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The found shape, byte-exact against `HandleCorpseQueryOpcode`'s build (`QueryHandler.cpp: + /// 296-303`): `u8(1) << i32(mapid) << f32(x) << f32(y) << f32(z) << u32(corpsemapid)` — here a + /// corpse on Eastern Kingdoms (0) at Northshire-ish coords, no entrance adjustment. + #[test] + fn corpse_query_found_golden() { + let mut body = vec![1u8]; + body.extend_from_slice(&0i32.to_le_bytes()); + body.extend_from_slice(&(-8949.95f32).to_le_bytes()); + body.extend_from_slice(&(-132.49f32).to_le_bytes()); + body.extend_from_slice(&83.53f32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + let mut r = body.as_slice(); + let loc = read_corpse_query_response(&mut r).unwrap(); + assert!(r.is_empty(), "the whole body is consumed"); + assert_eq!( + loc, + CorpseLocation { + found: true, + display_map: 0, + position: [-8949.95, -132.49, 83.53], + corpse_map: 0, + } + ); + } + + /// The not-found shape is the lone `u8(0)` (`QueryHandler.cpp:262-267`) — also pushed + /// unprompted at bones-conversion (`Map.cpp:3624-3629`). + #[test] + fn corpse_query_not_found_golden() { + let body = [0u8]; + let mut r = body.as_slice(); + let loc = read_corpse_query_response(&mut r).unwrap(); + assert!(r.is_empty()); + assert!(!loc.found); + assert_eq!(loc.corpse_map, 0); + } + + /// A dungeon corpse: `mapid`/coords rewritten to the ghost entrance, `corpsemapid` kept real + /// (`QueryHandler.cpp:276-292`) — the two-map split the marker consumer relies on. + #[test] + fn corpse_query_dungeon_entrance_split() { + let mut body = vec![1u8]; + body.extend_from_slice(&0i32.to_le_bytes()); // entrance map: EK + body.extend_from_slice(&(-11209.6f32).to_le_bytes()); // Deadmines entrance-ish + body.extend_from_slice(&1666.54f32.to_le_bytes()); + body.extend_from_slice(&25.0f32.to_le_bytes()); + body.extend_from_slice(&36u32.to_le_bytes()); // the corpse's REAL map: Deadmines + let mut r = body.as_slice(); + let loc = read_corpse_query_response(&mut r).unwrap(); + assert_eq!(loc.display_map, 0); + assert_eq!(loc.corpse_map, 36); + } + + /// `SMSG_CORPSE_RECLAIM_DELAY` is one `u32` of milliseconds (`Misc.cpp:653-656`; 30 s base). + #[test] + fn corpse_reclaim_delay_golden() { + let body = 30_000u32.to_le_bytes(); + let mut r = body.as_slice(); + assert_eq!(read_corpse_reclaim_delay(&mut r).unwrap(), 30_000); + assert!(r.is_empty()); + } + + /// A player-caster offer, byte-exact against `Spell::SendResurrectRequest` (`Spell.cpp: + /// 5024-5044`): empty name (len prefix 1 = just the terminator), sickness 0 (a player res + /// spell), hasResTimer 1 (no `SPELL_ATTR_EX3_NO_RES_TIMER`). + #[test] + fn resurrect_request_player_caster_golden() { + let mut body = Vec::new(); + body.extend_from_slice(&0x0000_0001_0000_002Au64.to_le_bytes()); + body.extend_from_slice(&1u32.to_le_bytes()); // strlen("") + 1 + body.push(0); // the empty cstring + body.push(0); // sickness = false + body.push(1); // hasResTimer = true + let mut r = body.as_slice(); + let req = read_resurrect_request(&mut r).unwrap(); + assert!(r.is_empty()); + assert_eq!( + req, + ResurrectRequestBody { + caster: 0x0000_0001_0000_002A, + name: String::new(), + sickness: false, + has_timer: true, + } + ); + } + + /// An NPC-caster offer carries the creature's localized name and (for a spirit-healer-style + /// caster) the sickness warning bit. + #[test] + fn resurrect_request_npc_caster_golden() { + let name = b"Spirit Healer"; + let mut body = Vec::new(); + body.extend_from_slice(&0xF130_0FBE_0000_2AB3u64.to_le_bytes()); + body.extend_from_slice(&((name.len() + 1) as u32).to_le_bytes()); + body.extend_from_slice(name); + body.push(0); + body.push(1); // sickness = true + body.push(1); + let mut r = body.as_slice(); + let req = read_resurrect_request(&mut r).unwrap(); + assert!(r.is_empty()); + assert_eq!(req.name, "Spirit Healer"); + assert!(req.sickness); + } + + /// `SMSG_SPIRIT_HEALER_CONFIRM` is one full guid (`SpellEffects.cpp:825-829`). + #[test] + fn spirit_healer_confirm_golden() { + let body = 0xF130_0FBE_0000_2AB3u64.to_le_bytes(); + let mut r = body.as_slice(); + assert_eq!( + read_spirit_healer_confirm(&mut r).unwrap(), + 0xF130_0FBE_0000_2AB3 + ); + assert!(r.is_empty()); + } + + /// The three client bodies: full little-endian guids (+ the accept byte), matching the server + /// readers quoted on each builder. + #[test] + fn client_bodies_are_full_guids() { + assert_eq!( + reclaim_corpse(0xF500_0000_0000_0001), + 0xF500_0000_0000_0001u64.to_le_bytes().to_vec() + ); + assert_eq!( + spirit_healer_activate(0xF130_0FBE_0000_2AB3), + 0xF130_0FBE_0000_2AB3u64.to_le_bytes().to_vec() + ); + let mut expect = 0x2Au64.to_le_bytes().to_vec(); + expect.push(1); + assert_eq!(resurrect_response(0x2A, true), expect); + let mut expect = 0x2Au64.to_le_bytes().to_vec(); + expect.push(0); + assert_eq!(resurrect_response(0x2A, false), expect); + } +} diff --git a/crates/benilla-protocol/src/messages/gameobject.rs b/crates/benilla-protocol/src/messages/gameobject.rs new file mode 100644 index 00000000..2cc327e6 --- /dev/null +++ b/crates/benilla-protocol/src/messages/gameobject.rs @@ -0,0 +1,161 @@ +//! GameObject-interaction messages (decision 0236). The player-facing verb for every usable world +//! GameObject — a chest, a door, a quest object, a lever — is the single `CMSG_GAMEOBJ_USE`; the +//! server fans it out by GO type and answers on the systems we already have (the loot window for a +//! chest, the gossip/quest windows for a questgiver GO, a `GAMEOBJECT_STATE` flip for a door). This +//! module also carries the ask-once `CMSG_GAMEOBJECT_QUERY` template lookup (`entry + guid` → +//! `SMSG_GAMEOBJECT_QUERY_RESPONSE`'s type/display/name/`data[24]` head) — the GO twin of +//! `CMSG_CREATURE_QUERY`; it grows further as the arc lands its later phases. + +use std::io; + +use crate::wire::{read_cstring, read_i32_le, read_u32_le}; + +/// Body of `CMSG_GAMEOBJ_USE` (opcode `0xB1`/177 — VERIFIED vmangos +/// `Server/Protocol/Opcodes_1_12_1.h`): one full `u64` guid, the GameObject to use. Read server-side +/// by `WorldSession::HandleGameObjectUseOpcode` (`Handlers/SpellHandler.cpp`), which gates on +/// spawned · not GENERIC(5) · not `GO_FLAG_NO_INTERACT` · interact distance · `PlayerCanUse`, then +/// runs `GameObject::Use`. The wire shape is identical to `CMSG_LOOT` — a bare little-endian guid — +/// but the two are **not** interchangeable: `HandleLootOpcode` rejects a GameObject guid outright +/// (anticheat), so a chest opens its loot through *this* opcode (→ server `SendLoot` → +/// `SMSG_LOOT_RESPONSE`), never through `CMSG_LOOT`. +pub fn gameobj_use(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of a `CMSG_GAMEOBJECT_QUERY` (opcode `0x005E`/94 — VERIFIED vmangos +/// `Server/Protocol/Opcodes_1_12_1.h`): the template `entry` + the asking guid (VERIFIED vmangos +/// `QueryGameObject::ReadFromWorldPacket` — `u32 entryID` then a full `u64` guid), identical shape to +/// [`super::creature_query`]. +pub fn gameobject_query(entry: u32, guid: u64) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&entry.to_le_bytes()); + body.extend_from_slice(&guid.to_le_bytes()); + body +} + +/// The head of a GameObject-template answer (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, opcode `0x005F`/95, +/// answering `CMSG_GAMEOBJECT_QUERY`) — decision 0236's ask-once template lookup. `data[24]` is the +/// template's raw `GameObjectData` union tail (VERIFIED vmangos `QueryHandler.cpp` +/// `HandleGameObjectQueryOpcode`: `data << info->raw.data[i]` for `i in 0..24`); its slot layout is +/// **type-specific** (e.g. a chest's lockId lives at a different index than a door's) — this layer +/// parses the array verbatim and leaves resolving a slot to the consumer that knows `type_id`. +#[derive(Debug, Clone, PartialEq)] +pub struct GameObjectQueryInfo { + pub type_id: u32, + pub display_id: u32, + pub name: String, + pub data: [i32; 24], +} + +/// Read `SMSG_GAMEOBJECT_QUERY_RESPONSE` → `(entry, Some(info))`, or `(entry, None)` on a miss. +/// VERIFIED vmangos `QueryHandler.cpp` `HandleGameObjectQueryOpcode`, the +/// `SUPPORTED_CLIENT_BUILD >= CLIENT_BUILD_1_12_1` branch — wire order: `entry, type, displayId, +/// name`, then 3 empty name slots + an `icon` C-string (the 5th name slot), all read past and +/// dropped except `name`, then `data[24]` as 24 little-endian `i32`s. There is **no** trailing float +/// `size` field in 5875 (the `data << float(info->size)` line is commented out server-side). A miss +/// is the lone `u32` of `entry | 0x8000_0000` — the same shape as the creature/item miss. +pub(super) fn read_gameobject_query_response( + r: &mut &[u8], +) -> io::Result<(u32, Option)> { + let entry = read_u32_le(r)?; + if entry & 0x8000_0000 != 0 { + return Ok((entry & 0x7FFF_FFFF, None)); + } + let type_id = read_u32_le(r)?; + let display_id = read_u32_le(r)?; + let name = read_cstring(r)?; + for _ in 0..3 { + let _ = read_cstring(r)?; // name2..name4, always empty in 5875 + } + let _icon = read_cstring(r)?; // name5 — the GO's icon key; unused until a consumer needs it + let mut data = [0i32; 24]; + for slot in &mut data { + *slot = read_i32_le(r)?; + } + Ok(( + entry, + Some(GameObjectQueryInfo { + type_id, + display_id, + name, + data, + }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::{opcode, parse_server, ServerPacket}; + + fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + #[test] + fn cmsg_gameobj_use_body_golden() { + // A full little-endian guid, no more (SpellHandler.cpp reads a single ObjectGuid). + assert_eq!( + gameobj_use(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_GAMEOBJ_USE body" + ); + } + + #[test] + fn cmsg_gameobject_query_body_golden() { + // entry u32 + full 8-byte guid — identical shape to CMSG_CREATURE_QUERY (VERIFIED vmangos + // `QueryGameObject::ReadFromWorldPacket`). + assert_eq!( + gameobject_query(1731, 0x1234_5678_9abc_def0), + hx("c3060000f0debc9a78563412"), + "CMSG_GAMEOBJECT_QUERY body" + ); + } + + #[test] + fn gameobject_query_response_hit_decodes() { + let mut body = 1731u32.to_le_bytes().to_vec(); // entry + body.extend_from_slice(&3u32.to_le_bytes()); // type = GAMEOBJECT_TYPE_CHEST + body.extend_from_slice(&123u32.to_le_bytes()); // displayId + body.extend_from_slice(b"Chest\0"); // name + body.extend_from_slice(&[0, 0, 0]); // name2..name4, empty C-strings + body.extend_from_slice(b"Icon\0"); // icon (name5) — read past and dropped + let mut data = [0i32; 24]; + data[0] = 57; // stand-in lockId — the type-specific slot a later consumer resolves + for v in data { + body.extend_from_slice(&v.to_le_bytes()); + } + + match parse_server(opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::GameObjectQueryResponse { entry, info } => { + assert_eq!(entry, 1731); + let info = info.expect("hit"); + assert_eq!(info.type_id, 3); + assert_eq!(info.display_id, 123); + assert_eq!(info.name, "Chest"); + assert_eq!(info.data[0], 57); + assert_eq!(info.data[1], 0); + } + other => panic!("expected GameObjectQueryResponse, got {}", other.name()), + } + } + + #[test] + fn gameobject_query_response_miss_decodes() { + // A miss is the lone entry echoed with its top bit set (VERIFIED vmangos + // HandleGameObjectQueryOpcode's "GO's info was not found" branch — same shape as the + // creature/item miss). + let body = (1731u32 | 0x8000_0000).to_le_bytes(); + match parse_server(opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::GameObjectQueryResponse { entry, info } => { + assert_eq!(entry, 1731); + assert!(info.is_none()); + } + other => panic!("expected GameObjectQueryResponse, got {}", other.name()), + } + } +} diff --git a/crates/benilla-protocol/src/messages/gossip.rs b/crates/benilla-protocol/src/messages/gossip.rs new file mode 100644 index 00000000..33035e03 --- /dev/null +++ b/crates/benilla-protocol/src/messages/gossip.rs @@ -0,0 +1,130 @@ +//! Gossip + NPC-text messages — the "right-click a friendly NPC → dialog" family (opcodes 379-384, +//! vmangos `Opcodes_1_12_1.h`, VERIFIED). Bodies from vmangos `Npc.{h,cpp}` + the hand-serialized +//! `GossipDef.cpp`. Vendor bodies live beside this in [`super::vendor`] — a different wire family +//! (`CMSG_LIST_INVENTORY`/buy/sell) that a gossip option can lead into but doesn't share a shape with. +//! Quest-giver flows (`SMSG_QUESTGIVER_*`) are a separate, out-of-scope arc; the quest-option block +//! riding inside `SMSG_GOSSIP_MESSAGE` is parsed here only to stay byte-aligned. + +use std::io; + +use crate::wire::{read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8}; + +/// One gossip menu entry (`SMSG_GOSSIP_MESSAGE`'s option list, 1.12 shape — no box-money field, +/// that's TBC+). `index` is the value the client echoes back as `gossipListId` on select; `icon` is +/// a `GOSSIP_ICON_*` (0 chat bubble, 1 vendor, 2 taxi, 3 trainer, …); `coded` marks a password-gated +/// option (petition signing etc. — v1 sends an empty code only for non-coded options, see +/// [`gossip_select_option`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GossipOption { + pub index: u32, + pub icon: u8, + pub coded: bool, + pub message: String, +} + +/// One quest-giver entry riding the same packet (`SMSG_GOSSIP_MESSAGE`'s quest-option list). Parsed +/// for byte alignment only — quest-giver flows are out of scope for this arc (gossip/vendor arc +/// brief §8). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestOption { + pub quest_id: u32, + pub icon: u32, + pub level: u32, + pub title: String, +} + +/// Body of `CMSG_GOSSIP_HELLO` (vmangos `Npc.cpp:3`): one full 8-byte NPC guid. Works on *any* +/// interactable creature, not only gossip-flagged ones — the server's `CanInteractWithNPC` passes +/// `UNIT_NPC_FLAG_NONE` for this opcode (`Player.cpp:347`). +pub fn gossip_hello(npc_guid: u64) -> Vec { + npc_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_GOSSIP_SELECT_OPTION` (vmangos `Npc.cpp:78-86`): guid, the `gossipListId` +/// (= the chosen [`GossipOption::index`]), then an **optional** trailing code cstring — appended +/// only for a `coded` option carrying a real code; the server reads it only when the buffer is +/// non-empty, so a non-coded select must omit it entirely rather than send an empty string. +/// Handler dispatch: `NPCHandler.cpp:370`. +pub fn gossip_select_option(npc_guid: u64, gossip_list_id: u32, code: Option<&str>) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&npc_guid.to_le_bytes()); + body.extend_from_slice(&gossip_list_id.to_le_bytes()); + if let Some(code) = code { + body.extend_from_slice(code.as_bytes()); + body.push(0); + } + body +} + +/// Body of `CMSG_NPC_TEXT_QUERY` (vmangos `Npc.cpp:8-12`): `u32 textID`, `u64 guid`. Sent on +/// receiving a gossip menu to fetch the greeting text for `textId` (ask-once cacheable, like the +/// item template query). +pub fn npc_text_query(text_id: u32, guid: u64) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&text_id.to_le_bytes()); + body.extend_from_slice(&guid.to_le_bytes()); + body +} + +/// Read `SMSG_GOSSIP_MESSAGE` (vmangos `GossipDef.cpp:180-225`, the 1.12 shape — build 5875 predates +/// the TBC box-money field): `u64 objectGuid, u32 textId, u32 optionCount` + options +/// (`u32 index, u8 icon, u8 coded, cstr message`), then `u32 questCount` + quest options +/// (`u32 questId, u32 icon, u32 level, cstr title`). Returns `(npc_guid, text_id, options, quests)`. +pub(super) fn read_gossip_message( + r: &mut &[u8], +) -> io::Result<(u64, u32, Vec, Vec)> { + let npc_guid = read_u64_le(r)?; + let text_id = read_u32_le(r)?; + let option_count = read_u32_le(r)?; + let mut options = Vec::with_capacity(option_count as usize); + for _ in 0..option_count { + options.push(GossipOption { + index: read_u32_le(r)?, + icon: read_u8(r)?, + coded: read_u8(r)? != 0, + message: read_cstring(r)?, + }); + } + let quest_count = read_u32_le(r)?; + let mut quests = Vec::with_capacity(quest_count as usize); + for _ in 0..quest_count { + quests.push(QuestOption { + quest_id: read_u32_le(r)?, + icon: read_u32_le(r)?, + level: read_u32_le(r)?, + title: read_cstring(r)?, + }); + } + Ok((npc_guid, text_id, options, quests)) +} + +/// Read `SMSG_NPC_TEXT_UPDATE` (vmangos `GossipDef.cpp:298-369`): `u32 textID` then always exactly 8 +/// blocks of `{f32 probability, cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32 +/// emoteDelay, u32 emoteId)}`. The client only needs one line for the greeting: the highest- +/// probability block's `text0`, falling back to `text1` when `text0` is empty (vmangos's own default +/// row, when a text is missing entirely, is literally `"Greetings $N"`). Returns +/// `(text_id, greeting)`; the emote/language tails and the losing blocks are parsed for alignment +/// and dropped. +pub(super) fn read_npc_text_update(r: &mut &[u8]) -> io::Result<(u32, String)> { + let text_id = read_u32_le(r)?; + let mut best: Option<(f32, String)> = None; + for _ in 0..8 { + let probability = read_f32_le(r)?; + let text0 = read_cstring(r)?; + let text1 = read_cstring(r)?; + let _language_id = read_u32_le(r)?; + for _ in 0..3 { + let _emote_delay = read_u32_le(r)?; + let _emote_id = read_u32_le(r)?; + } + let text = if text0.is_empty() { text1 } else { text0 }; + let is_new_best = match &best { + Some((best_probability, _)) => probability > *best_probability, + None => true, + }; + if is_new_best { + best = Some((probability, text)); + } + } + Ok((text_id, best.map(|(_, text)| text).unwrap_or_default())) +} diff --git a/crates/benilla-protocol/src/messages/group.rs b/crates/benilla-protocol/src/messages/group.rs new file mode 100644 index 00000000..e86c2452 --- /dev/null +++ b/crates/benilla-protocol/src/messages/group.rs @@ -0,0 +1,512 @@ +//! Group/party messages — invite/accept/decline/kick/leader/disband, the loot method, the roster +//! push, party command feedback, live member stats for the party/raid frame, minimap pings, raid +//! subgroup management, raid-target icons, and ready checks (opcodes 110/111/114-127, 469, +//! 638-640, 654-655, 754, 801-802 — VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h`). CMSG +//! bodies from vmangos `Server/Packets/Group.{h,cpp}` (every `ReadFromWorldPacket`); SMSG layouts +//! from the same file's `AppendBodyTo` writers plus the hand-serialized member-stats builder in +//! `Handlers/GroupHandler.cpp` (line citations inline in each item). `ObjectGuid`'s own +//! `operator<>` (`ObjectGuid.cpp:172-183`) is a plain 8-byte `u64` — every guid below is FULL +//! unless explicitly marked PACKED (only [`PartyMemberStatsInfo`]'s subject guid is). + +use std::io::{self, Read}; + +use crate::wire::{ + read_cstring, read_f32_le, read_packed_guid, read_u16_le, read_u32_le, read_u64_le, read_u8, +}; + +/// The raid-assistant bit in [`GroupMemberEntry::flags`] / `SMSG_GROUP_LIST`'s own-flags byte — +/// bits 0-2 carry the subgroup index (0-7, raid-only; a party has just one), this bit marks a +/// raid assistant (VERIFIED vmangos `Group.cpp:158,166`: `uint8(itr.group | (itr.assistant ? 0x80 +/// : 0))`). +pub const GROUP_MEMBER_ASSISTANT: u8 = 0x80; + +/// `GroupMemberStatus` (VERIFIED vmangos `Group/Group.h:102-111`) — the bits of +/// [`GroupMemberEntry::status`] (bit `0x20` is reserved/unused — `MEMBER_STATUS_UNK3`, never set). +pub mod member_status { + pub const OFFLINE: u8 = 0x00; + pub const ONLINE: u8 = 0x01; + pub const PVP: u8 = 0x02; + pub const DEAD: u8 = 0x04; + pub const GHOST: u8 = 0x08; + pub const PVP_FFA: u8 = 0x10; + pub const AFK: u8 = 0x40; + pub const DND: u8 = 0x80; +} + +/// `PartyOperation` (VERIFIED vmangos `Server/WorldSession.h:94-98`) — the `u32` on +/// `SMSG_PARTY_COMMAND_RESULT` naming which command the result answers. +pub mod party_operation { + pub const INVITE: u32 = 0; + pub const LEAVE: u32 = 2; +} + +/// `PartyResult` (VERIFIED vmangos `Server/WorldSession.h:100-111`) — the `u32` verdict on +/// `SMSG_PARTY_COMMAND_RESULT`. `ERR_INTERNAL_BATTLEGROUND` (10, "does not exist client-side" per +/// vmangos's own comment) never reaches the wire and is omitted. +pub mod party_result { + pub const OK: u32 = 0; + pub const BAD_PLAYER_NAME: u32 = 1; + pub const TARGET_NOT_IN_GROUP: u32 = 2; + pub const GROUP_FULL: u32 = 3; + pub const ALREADY_IN_GROUP: u32 = 4; + pub const NOT_IN_GROUP: u32 = 5; + pub const NOT_LEADER: u32 = 6; + pub const WRONG_FACTION: u32 = 7; + pub const IGNORING_YOU: u32 = 8; +} + +/// One other member row on `SMSG_GROUP_LIST` (VERIFIED vmangos `Server/Packets/Group.h:261-267`, +/// `Group.cpp:161-167`, `Group::SendUpdate`). The recipient's own row never appears in the list — +/// their subgroup/assistant flag rides `SMSG_GROUP_LIST`'s separate `own_flags` byte instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupMemberEntry { + pub name: String, + pub guid: u64, + /// [`member_status`] bits (`GetGroupMemberStatus`, `Group.cpp:45-63`). + pub status: u8, + /// Subgroup index in bits 0-2; [`GROUP_MEMBER_ASSISTANT`] (`0x80`) set = raid assistant. + pub flags: u8, +} + +/// The loot-method tail `SMSG_GROUP_LIST` appends only when the member list is non-empty +/// (VERIFIED `Group.cpp:170-179`). `threshold` is an `ItemQualities` value (2..4 in practice — +/// uncommon and up). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GroupLootInfo { + /// `0` free-for-all, `1` round-robin, `2` master, `3` group, `4` need-before-greed (vmangos + /// `LootMethod`, `Group.h`). + pub method: u8, + /// The master looter's guid — `0` unless `method == 2` (master loot). + pub master: u64, + pub threshold: u8, +} + +/// Read `SMSG_GROUP_LIST` (VERIFIED vmangos `Group.cpp:155-180`, `GroupList::AppendBodyTo`): +/// `u8 groupType` (0 party / 1 raid), `u8 ownGroupAndAssistantFlag`, `u32 memberCount`, +/// `memberCount` × [`GroupMemberEntry`], `u64 leaderGuid`, then — only if `memberCount > 0` — the +/// [`GroupLootInfo`] tail plus a trailing `u8 dungeonDifficulty` (always `0` at 5875, `Group.h:281` +/// — "unused in 1.x"). The "you left the group" shape is the degenerate empty-member-list case: +/// exactly 14 bytes (`0, 0, u32 0, u64 0`), no tail at all. +#[allow(clippy::type_complexity)] +pub(super) fn read_group_list( + r: &mut &[u8], +) -> io::Result<(u8, u8, Vec, u64, Option)> { + let group_type = read_u8(r)?; + let own_flags = read_u8(r)?; + let count = read_u32_le(r)?; + let mut members = Vec::with_capacity(count as usize); + for _ in 0..count { + members.push(GroupMemberEntry { + name: read_cstring(r)?, + guid: read_u64_le(r)?, + status: read_u8(r)?, + flags: read_u8(r)?, + }); + } + let leader = read_u64_le(r)?; + let loot = if count > 0 { + let method = read_u8(r)?; + let master = read_u64_le(r)?; + let threshold = read_u8(r)?; + let _dungeon_difficulty = read_u8(r)?; // always 0 at 5875 (Group.h:281) + Some(GroupLootInfo { + method, + master, + threshold, + }) + } else { + None + }; + Ok((group_type, own_flags, members, leader, loot)) +} + +/// Read `SMSG_GROUP_INVITE` (VERIFIED vmangos `Group.cpp:107-110`, +/// `GroupInviteNotification::AppendBodyTo`): one cstring, the inviter's name. +pub(super) fn read_group_invite(r: &mut impl Read) -> io::Result { + read_cstring(r) +} + +/// Read `SMSG_GROUP_DECLINE` (VERIFIED vmangos `Group.cpp:112-115`, +/// `GroupDeclineNotification::AppendBodyTo`): one cstring, the declining player's name. +pub(super) fn read_group_decline(r: &mut impl Read) -> io::Result { + read_cstring(r) +} + +/// Read `SMSG_GROUP_SET_LEADER` (VERIFIED vmangos `Group.cpp:150-153`, +/// `GroupSetLeaderNotification::AppendBodyTo`): one cstring, the new leader's name. +pub(super) fn read_group_set_leader(r: &mut impl Read) -> io::Result { + read_cstring(r) +} + +/// Read `SMSG_PARTY_COMMAND_RESULT` (VERIFIED vmangos `Group.cpp:100-105`, +/// `PartyCommandResult::AppendBodyTo`): `u32 operation` ([`party_operation`]), `cstring +/// memberName` (may be empty — e.g. the ignoring-you refusal names no one, `Handlers/ +/// GroupHandler.cpp:466`), `u32 result` ([`party_result`]). +pub(super) fn read_party_command_result(r: &mut impl Read) -> io::Result<(u32, String, u32)> { + Ok((read_u32_le(r)?, read_cstring(r)?, read_u32_le(r)?)) +} + +/// `GROUP_UPDATE_FLAG_*` (VERIFIED vmangos `Group/Group.h:124-151`) — the bits of the leading `u32` +/// mask on `SMSG_PARTY_MEMBER_STATS`/`_FULL`, selecting which fields follow, each in ascending bit +/// order (see [`read_party_member_stats`]). +pub mod party_member_mask { + pub const STATUS: u32 = 0x0000_0001; + pub const CUR_HP: u32 = 0x0000_0002; + pub const MAX_HP: u32 = 0x0000_0004; + pub const POWER_TYPE: u32 = 0x0000_0008; + pub const CUR_POWER: u32 = 0x0000_0010; + pub const MAX_POWER: u32 = 0x0000_0020; + pub const LEVEL: u32 = 0x0000_0040; + pub const ZONE: u32 = 0x0000_0080; + pub const POSITION: u32 = 0x0000_0100; + pub const AURAS: u32 = 0x0000_0200; + pub const AURAS_NEGATIVE: u32 = 0x0000_0400; + pub const PET_GUID: u32 = 0x0000_0800; + pub const PET_NAME: u32 = 0x0000_1000; + pub const PET_MODEL_ID: u32 = 0x0000_2000; + pub const PET_CUR_HP: u32 = 0x0000_4000; + pub const PET_MAX_HP: u32 = 0x0000_8000; + pub const PET_POWER_TYPE: u32 = 0x0001_0000; + pub const PET_CUR_POWER: u32 = 0x0002_0000; + pub const PET_MAX_POWER: u32 = 0x0004_0000; + pub const PET_AURAS: u32 = 0x0008_0000; + pub const PET_AURAS_NEGATIVE: u32 = 0x0010_0000; +} + +/// One `SMSG_PARTY_MEMBER_STATS`/`_FULL` payload — both opcodes decode through the same field +/// layout (the caller picks `full` off which opcode arrived, see [`super::ServerPacket::name`]'s +/// arm): a packed guid, a `u32` field mask ([`party_member_mask`]), then only the fields whose bit +/// is set — `None` otherwise (VERIFIED vmangos `Handlers/GroupHandler.cpp:590-742`, +/// `BuildPartyMemberStatsPacket`). The plain (delta) opcode's mask names only what *changed*; the +/// `_FULL` opcode (our own [`request_party_member_stats`] ask, or the offline-miss reply) sets +/// every bit the target has data for. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PartyMemberStatsInfo { + pub status: Option, + pub cur_hp: Option, + pub max_hp: Option, + pub power_type: Option, + pub cur_power: Option, + pub max_power: Option, + pub level: Option, + pub zone: Option, + /// Raw WoW `(x, y)`, truncated to `i16` on the wire (vmangos casts the live float position, + /// `GroupHandler.cpp:623`). + pub position: Option<(i16, i16)>, + /// Active buff spell ids, ascending bit order — the wire's `u32` position-mask prefix is + /// decoded away (no consumer needs the raw aura slot, only which buffs are up). + pub auras: Option>, + /// Active debuff spell ids, same convention as [`Self::auras`] (the wire mask here is `u16`). + pub auras_negative: Option>, + pub pet_guid: Option, + pub pet_name: Option, + pub pet_model_id: Option, + pub pet_cur_hp: Option, + pub pet_max_hp: Option, + pub pet_power_type: Option, + pub pet_cur_power: Option, + pub pet_max_power: Option, + pub pet_auras: Option>, + pub pet_auras_negative: Option>, +} + +/// Read the aura tail shared by [`party_member_mask::AURAS`]/`PET_AURAS` (a `u32` bit mask) and +/// `AURAS_NEGATIVE`/`PET_AURAS_NEGATIVE` (a `u16` bit mask, passed widened): one `u16` spell id per +/// set bit, ascending bit order (VERIFIED `GroupHandler.cpp:624-630` positive, `634-644` +/// negative/pet-negative shape). +fn read_aura_spells(r: &mut impl Read, mask: u32, bits: u32) -> io::Result> { + let mut spells = Vec::new(); + for bit in 0..bits { + if mask & (1 << bit) != 0 { + spells.push(read_u16_le(r)?); + } + } + Ok(spells) +} + +/// Read one `SMSG_PARTY_MEMBER_STATS`/`_FULL` body → `(guid, info)` (see [`PartyMemberStatsInfo`]). +/// The guid is PACKED (`GroupHandler.cpp:768`, `packet.guid.WriteAsPacked()` / `773` +/// `player->GetPackGUID()` — both build-5875 branches, `SUPPORTED_CLIENT_BUILD > 1_8_4`). +pub(super) fn read_party_member_stats( + r: &mut impl Read, +) -> io::Result<(u64, PartyMemberStatsInfo)> { + let guid = read_packed_guid(r)?; + let mask = read_u32_le(r)?; + let mut info = PartyMemberStatsInfo::default(); + + if mask & party_member_mask::STATUS != 0 { + info.status = Some(read_u8(r)?); + } + if mask & party_member_mask::CUR_HP != 0 { + info.cur_hp = Some(read_u16_le(r)?); + } + if mask & party_member_mask::MAX_HP != 0 { + info.max_hp = Some(read_u16_le(r)?); + } + if mask & party_member_mask::POWER_TYPE != 0 { + info.power_type = Some(read_u8(r)?); + } + if mask & party_member_mask::CUR_POWER != 0 { + info.cur_power = Some(read_u16_le(r)?); + } + if mask & party_member_mask::MAX_POWER != 0 { + info.max_power = Some(read_u16_le(r)?); + } + if mask & party_member_mask::LEVEL != 0 { + info.level = Some(read_u16_le(r)?); + } + if mask & party_member_mask::ZONE != 0 { + info.zone = Some(read_u16_le(r)?); + } + if mask & party_member_mask::POSITION != 0 { + let x = read_u16_le(r)? as i16; + let y = read_u16_le(r)? as i16; + info.position = Some((x, y)); + } + if mask & party_member_mask::AURAS != 0 { + let pos_mask = read_u32_le(r)?; + info.auras = Some(read_aura_spells(r, pos_mask, 32)?); + } + if mask & party_member_mask::AURAS_NEGATIVE != 0 { + let neg_mask = u32::from(read_u16_le(r)?); + info.auras_negative = Some(read_aura_spells(r, neg_mask, 16)?); + } + if mask & party_member_mask::PET_GUID != 0 { + info.pet_guid = Some(read_u64_le(r)?); + } + if mask & party_member_mask::PET_NAME != 0 { + info.pet_name = Some(read_cstring(r)?); + } + if mask & party_member_mask::PET_MODEL_ID != 0 { + info.pet_model_id = Some(read_u16_le(r)?); + } + if mask & party_member_mask::PET_CUR_HP != 0 { + info.pet_cur_hp = Some(read_u16_le(r)?); + } + if mask & party_member_mask::PET_MAX_HP != 0 { + info.pet_max_hp = Some(read_u16_le(r)?); + } + if mask & party_member_mask::PET_POWER_TYPE != 0 { + info.pet_power_type = Some(read_u8(r)?); + } + if mask & party_member_mask::PET_CUR_POWER != 0 { + info.pet_cur_power = Some(read_u16_le(r)?); + } + if mask & party_member_mask::PET_MAX_POWER != 0 { + info.pet_max_power = Some(read_u16_le(r)?); + } + if mask & party_member_mask::PET_AURAS != 0 { + let pos_mask = read_u32_le(r)?; + info.pet_auras = Some(read_aura_spells(r, pos_mask, 32)?); + } + if mask & party_member_mask::PET_AURAS_NEGATIVE != 0 { + let neg_mask = u32::from(read_u16_le(r)?); + info.pet_auras_negative = Some(read_aura_spells(r, neg_mask, 16)?); + } + Ok((guid, info)) +} + +/// Read `MSG_MINIMAP_PING`, the inbound (rebroadcast) shape (VERIFIED vmangos +/// `Handlers/GroupHandler.cpp:382-391`, `HandleMinimapPingOpcode`): full 8-byte `u64 guid` (the +/// server stamps the pinger's own guid on), `f32 x`, `f32 y`. +pub(super) fn read_minimap_ping(r: &mut impl Read) -> io::Result<(u64, f32, f32)> { + Ok((read_u64_le(r)?, read_f32_le(r)?, read_f32_le(r)?)) +} + +/// One decoded `MSG_RAID_TARGET_UPDATE` server body (VERIFIED vmangos `Server/Packets/ +/// Group.cpp:132-147`, `RaidTargetUpdateDelta`/`RaidTargetUpdateAll::AppendBodyTo`): a leading `u8` +/// mode byte picks the shape — `0` one changed icon, `1` the whole current icon set (only +/// currently-set icons ride the list; none marked sends an empty list, not a fixed 8-slot array). +/// Both shapes carry FULL 8-byte guids, not packed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RaidTargetUpdate { + Delta { icon: u8, guid: u64 }, + List(Vec<(u8, u64)>), +} + +/// Read one `MSG_RAID_TARGET_UPDATE` server body (see [`RaidTargetUpdate`]). +pub(super) fn read_raid_target_update(r: &mut &[u8]) -> io::Result { + let mode = read_u8(r)?; + if mode == 0 { + Ok(RaidTargetUpdate::Delta { + icon: read_u8(r)?, + guid: read_u64_le(r)?, + }) + } else { + let mut entries = Vec::new(); + while !r.is_empty() { + entries.push((read_u8(r)?, read_u64_le(r)?)); + } + Ok(RaidTargetUpdate::List(entries)) + } +} + +/// One decoded `MSG_RAID_READY_CHECK` server body (VERIFIED vmangos `Server/Packets/ +/// Group.cpp:94-96` the empty request, `:126-130` the answer): empty = the leader just started a +/// check; non-empty = one member's answer, forwarded to the leader only (`ObjectGuid senderGuid` +/// FULL, not packed, + `u8 state`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadyCheck { + Started, + Answer { guid: u64, ready: u8 }, +} + +/// Read one `MSG_RAID_READY_CHECK` server body (see [`ReadyCheck`]). +pub(super) fn read_ready_check(r: &mut &[u8]) -> io::Result { + if r.is_empty() { + return Ok(ReadyCheck::Started); + } + Ok(ReadyCheck::Answer { + guid: read_u64_le(r)?, + ready: read_u8(r)?, + }) +} + +/// Body of `CMSG_GROUP_INVITE` (VERIFIED vmangos `Group.cpp:4-7`, +/// `GroupInvite::ReadFromWorldPacket`): one cstring, the invited player's name. +pub fn group_invite(member_name: &str) -> Vec { + let mut body = Vec::with_capacity(member_name.len() + 1); + body.extend_from_slice(member_name.as_bytes()); + body.push(0); + body +} + +/// Body of `CMSG_GROUP_ACCEPT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — +/// `NullClientPacket`): empty. +pub fn group_accept() -> Vec { + Vec::new() +} + +/// Body of `CMSG_GROUP_DECLINE` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — +/// `NullClientPacket`): empty. The inviter sees `SMSG_GROUP_DECLINE`. +pub fn group_decline() -> Vec { + Vec::new() +} + +/// Body of `CMSG_GROUP_UNINVITE` (VERIFIED vmangos `Group.cpp:9-12`, +/// `GroupUninvite::ReadFromWorldPacket`): one cstring, the kicked player's name. +pub fn group_uninvite(member_name: &str) -> Vec { + let mut body = Vec::with_capacity(member_name.len() + 1); + body.extend_from_slice(member_name.as_bytes()); + body.push(0); + body +} + +/// Body of `CMSG_GROUP_UNINVITE_GUID` (VERIFIED vmangos `Group.cpp:14-17`, +/// `GroupUninviteGuid::ReadFromWorldPacket`): one full 8-byte guid — the raid-frame right-click kick. +pub fn group_uninvite_guid(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_GROUP_SET_LEADER` (VERIFIED vmangos `Group.h:102-113` + `Group.cpp:57-64`, +/// `GroupSetLeader::ReadFromWorldPacket`, the `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` branch +/// active for 5875): one full 8-byte guid — the 1.12 wire dropped the older name-based form. +pub fn group_set_leader(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_LOOT_METHOD` (VERIFIED vmangos `Group.h:51-60` + `Group.cpp:26-31`, +/// `LootMethod::ReadFromWorldPacket`): `u32 method` (`0` free-for-all, `1` round-robin, `2` +/// master, `3` group, `4` need-before-greed), full 8-byte `u64 loot_master` guid (ignored unless +/// `method == 2`), `u32 threshold` (an `ItemQualities` value, 2..4 in practice). +pub fn loot_method(method: u32, loot_master: u64, threshold: u32) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&method.to_le_bytes()); + body.extend_from_slice(&loot_master.to_le_bytes()); + body.extend_from_slice(&threshold.to_le_bytes()); + body +} + +/// Body of `CMSG_GROUP_DISBAND` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — +/// `NullClientPacket`): empty. +pub fn group_disband() -> Vec { + Vec::new() +} + +/// Body of `CMSG_GROUP_RAID_CONVERT` (VERIFIED vmangos `Server/Protocol/Opcodes.cpp` — +/// `NullClientPacket`): empty — converts the sender's party into a raid. +pub fn group_raid_convert() -> Vec { + Vec::new() +} + +/// Body of `CMSG_REQUEST_PARTY_MEMBER_STATS` (VERIFIED vmangos `Group.h:41-49` + `Group.cpp:20-23`, +/// `RequestPartyMemberStats::ReadFromWorldPacket`): one full 8-byte guid — the target whose stats +/// we're asking for. Answered by `SMSG_PARTY_MEMBER_STATS_FULL` (or its offline-miss shape). +pub fn request_party_member_stats(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_GROUP_CHANGE_SUB_GROUP` (VERIFIED vmangos `Group.cpp:45-49`, +/// `GroupChangeSubGroup::ReadFromWorldPacket`): cstring member name + `u8` destination subgroup +/// index — raid-only (a party has just one subgroup). +pub fn group_change_sub_group(name: &str, group_nr: u8) -> Vec { + let mut body = Vec::with_capacity(name.len() + 2); + body.extend_from_slice(name.as_bytes()); + body.push(0); + body.push(group_nr); + body +} + +/// Body of `CMSG_GROUP_SWAP_SUB_GROUP` (VERIFIED vmangos `Group.cpp:51-55`, +/// `GroupSwapSubGroup::ReadFromWorldPacket`): two cstrings — the member to move, then the member +/// whose subgroup slot it swaps into. +pub fn group_swap_sub_group(name: &str, swap_with: &str) -> Vec { + let mut body = Vec::with_capacity(name.len() + swap_with.len() + 2); + body.extend_from_slice(name.as_bytes()); + body.push(0); + body.extend_from_slice(swap_with.as_bytes()); + body.push(0); + body +} + +/// Body of `CMSG_GROUP_ASSISTANT_LEADER` (VERIFIED vmangos `Group.h:115-127` + `Group.cpp:66-74`, +/// `GroupAssistantLeader::ReadFromWorldPacket`, the 1.12 guid branch): full 8-byte guid + `u8 flag` +/// (`1` grant raid-assistant, `0` revoke). +pub fn group_assistant_leader(guid: u64, grant: bool) -> Vec { + let mut body = Vec::with_capacity(9); + body.extend_from_slice(&guid.to_le_bytes()); + body.push(u8::from(grant)); + body +} + +/// Body of `MSG_MINIMAP_PING`, our own ping (VERIFIED vmangos `Group.cpp:33-37`, +/// `MinimapPing::ReadFromWorldPacket`): `f32 x, f32 y` — no guid (the server stamps ours on before +/// relaying, `Handlers/GroupHandler.cpp:382-391`). +pub fn minimap_ping(x: f32, y: f32) -> Vec { + let mut body = Vec::with_capacity(8); + body.extend_from_slice(&x.to_le_bytes()); + body.extend_from_slice(&y.to_le_bytes()); + body +} + +/// Body of `MSG_RAID_TARGET_UPDATE`, setting/clearing one icon (VERIFIED vmangos +/// `Group.cpp:77-82`, `RaidTargetUpdate::ReadFromWorldPacket`): `u8 icon` (0..7) + full 8-byte +/// `u64 guid` — `guid == 0` clears that icon. The client never sends the server's mode byte +/// (compare [`RaidTargetUpdate`]); [`raid_target_request`] disambiguates purely by icon value. +pub fn raid_target_set(icon: u8, guid: u64) -> Vec { + let mut body = Vec::with_capacity(9); + body.push(icon); + body.extend_from_slice(&guid.to_le_bytes()); + body +} + +/// Body of `MSG_RAID_TARGET_UPDATE`, asking the current icon set (VERIFIED vmangos +/// `Group.cpp:80-81`: `iconId != 0xFF` gates the trailing guid read): the single byte `0xFF` — no +/// guid follows. Answered by the server's mode-1 full list ([`RaidTargetUpdate::List`]). +pub fn raid_target_request() -> Vec { + vec![0xFF] +} + +/// Body of `MSG_RAID_READY_CHECK`, starting a check (VERIFIED vmangos +/// `RaidReadyCheckFromClient::ReadFromWorldPacket`, `Group.cpp:84-92`, the `state` unset case): +/// empty — the leader-only trigger; the server broadcasts the request to the rest of the raid. +pub fn ready_check_start() -> Vec { + Vec::new() +} + +/// Body of `MSG_RAID_READY_CHECK`, answering one (VERIFIED vmangos `Group.cpp:84-92`, the `state` +/// set case): one `u8`, `1` ready / `0` not — no guid; the server already knows the sender from +/// the session and forwards it to the raid leader alone. +pub fn ready_check_answer(ready: bool) -> Vec { + vec![u8::from(ready)] +} diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs new file mode 100644 index 00000000..8d3ebae4 --- /dev/null +++ b/crates/benilla-protocol/src/messages/items.rs @@ -0,0 +1,615 @@ +//! Item messages — the T2 container groundwork (decision 0068's tier ladder: Bagnon needs item +//! identity), widened to the **full** 1.12.1 item template (decision 0274 P1: the tooltip builder +//! needs every line the real client can render). The 1.12 wire carries no item *templates* in +//! descriptors — like unit names, they answer a query pair: `CMSG_ITEM_QUERY_SINGLE` (entry + guid) +//! → `SMSG_ITEM_QUERY_SINGLE_RESPONSE` (VERIFIED vmangos `HandleItemQuerySingleOpcode`; opcodes +//! 86/88 `Opcodes_1_12_1.h`). +//! +//! [`ItemInfo`] now carries the response whole: identity (class/subclass/name — 4 name slots, the +//! server sends 1 + 3 empties — displayInfoID, quality, inventoryType, sheath), the buy/sell +//! economy, every requirement gate (level/skill/spell/honor rank/city rank/reputation, +//! allowable class/race), stacking (maxCount/stackable/containerSlots), the full 10-slot stat +//! block, all 5 damage blocks (block 0 also mirrors into the legacy `dmg_min`/`dmg_max`/`dmg_type` +//! fields existing consumers already key on), armor plus the 6-wide resistance run, ranged data, +//! all 5 spell-trigger slots (the first ON_USE slot still surfaces separately as `use_spell` — the +//! client's own cooldown-scan key), bonding, description, page/lock/material/random-property/set, +//! durability, and the area/map/bagFamily tail (VERIFIED field order vmangos +//! `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every `SUPPORTED_CLIENT_BUILD` +//! conditional in that function evaluates *included* for build 5875). A **miss** (undiscovered/ +//! unknown entry) is the lone `u32` of `entry | 0x8000_0000`, the same shape as the creature miss. + +use std::io; + +use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le, read_u8}; + +/// A full item-template answer (decision 0274 P1: the tooltip builder's source of truth; every +/// field the wire carries, none discarded). (`PartialEq` only: several fields are wire floats — +/// the damage bounds and `ranged_mod_range`.) +#[derive(Debug, Clone, PartialEq)] +pub struct ItemInfo { + pub class: u32, + pub subclass: u32, + pub name: String, + /// `ItemDisplayInfo.dbc` key — the icon/model resolve. + pub display_info_id: u32, + /// 0 poor … 6 artifact (the RF-55 quality-color table's index). + pub quality: u32, + /// `ItemPrototypeFlags` bitmask (conjured, lootable, indestructible, wrapper, no-equip-cooldown, + /// …) — the tooltip's "Unique"/no-sell/no-disenchant lines key on bits here. + pub flags: u32, + /// `BuyPrice` — what a vendor charges per [`crate::messages::VendorItem::buy_count`]-sized + /// stack, in copper. + pub buy_price: u32, + /// `SellPrice` — what a vendor pays per unit, in copper (the bag tooltip's money row while a + /// merchant is open; 0 = unsellable → the "No sell price" line). + pub sell_price: u32, + /// `InventoryType` — the equip-slot family (1 head, 21/22 main/off-hand weapon, …); drives + /// which paperdoll slot an item can go in and which visual-item field it feeds. + pub inventory_type: u32, + /// `AllowableClass` — a class bitmask; the all-bits-set sentinel (`-1`) means no class + /// restriction, so this stays signed rather than reading as the unsigned `0xFFFF_FFFF`. + pub allowable_class: i32, + /// `AllowableRace` — the same bitmask shape as [`Self::allowable_class`], races instead. + pub allowable_race: i32, + /// `ItemLevel` — the repair-cost formula's `DurabilityCosts.dbc` row key (also a tooltip line). + pub item_level: u32, + /// `RequiredLevel` — the tooltip's "Requires Level N" line; 0 = no level requirement. + pub required_level: u32, + /// `RequiredSkill` — id from `SkillLine.dbc`; 0 = no skill requirement. + pub required_skill: u32, + /// `RequiredSkillRank` — the skill value [`Self::required_skill`] must meet or exceed. + pub required_skill_rank: u32, + /// `RequiredSpell` — id from `Spell.dbc`; the item is unusable without knowing this spell. + pub required_spell: u32, + /// `RequiredHonorRank`/`RequiredCityRank` — two more requirement gates the wire carries; the + /// tooltip's requirement-line law for these two is unverified (folds in with decision 0274's + /// §5 line-order dispatch). + pub required_honor_rank: u32, + pub required_city_rank: u32, + /// `RequiredReputationFaction` — id from `Faction.dbc`; 0 = no reputation requirement. + pub required_rep_faction: u32, + /// `RequiredReputationRank` — the wire's own gate: the server sends 0 whenever + /// [`Self::required_rep_faction`] is 0, even if the row has a nonzero rank (VERIFIED vmangos + /// `ItemHandler.cpp:321-322`). + pub required_rep_rank: u32, + /// `MaxCount` — the account-wide cap this item enforces (0 = uncapped); the tooltip's "Unique" + /// family of lines derive from this and [`Self::flags`]. + pub max_count: u32, + /// `Stackable` — the max stack size a single slot can hold (1 = doesn't stack). + pub stackable: u32, + /// `ContainerSlots` — nonzero only for bag items (the number of slots the bag itself grants). + pub container_slots: u32, + /// The 10-slot `ItemStat` block, `(type, value)`, **filtered to nonzero entries** (type or + /// value nonzero) in wire order — the tooltip's "+N Stat" lines (`ItemModType` at this build: + /// 0 mana, 1 health, 3 agility, 4 strength, 5 intellect, 6 spirit, 7 stamina). + pub stats: Vec<(u32, i32)>, + /// The 5-slot `Damage` block, **filtered to entries with `max > 0`**, in wire order — + /// secondary damage lines (e.g. a Fiery weapon's bonus Fire line) beyond the primary + /// [`Self::dmg_min`]/[`Self::dmg_max`]/[`Self::dmg_type`], which always mirror block 0 whether + /// or not it clears this filter. + pub damages: Vec, + /// Damage block 0's per-hit minimum (the tooltip's "X - Y Damage" line; 0 for non-weapons) — + /// kept mirrored from `damages` block 0 for existing consumers. + pub dmg_min: f32, + /// Damage block 0's per-hit maximum. + pub dmg_max: f32, + /// Damage block 0's school (0 physical, 1 Holy … 6 Arcane — the tooltip's school suffix). + pub dmg_type: u32, + /// `Armor` — the first slot of the wire's 7-wide resistance run. + pub armor: u32, + /// The remaining 6 slots of the resistance run, in wire order: `[holy, fire, nature, frost, + /// shadow, arcane]` (`int32` on the wire in vmangos's own `ItemPrototype` — a template's + /// resistance can't go negative in practice, but the sign rides along). + pub resistances: [i32; 6], + /// Attack delay in milliseconds (the tooltip's "Speed" = delay / 1000). + pub delay_ms: u32, + /// `AmmoType` — the projectile family a ranged weapon consumes (0 none, 2 arrow, 3 bullet). + pub ammo_type: u32, + /// `RangedModRange` — a ranged weapon's range multiplier; the tooltip never shows this raw, it + /// feeds the range formula. + pub ranged_mod_range: f32, + /// The 5-slot `ItemSpell` block, **filtered to entries with `spell_id != 0`**, in wire order — + /// every "Use:"/"Equip:"/"Chance on hit:" trigger line the tooltip can render. + pub spells: Vec, + /// The first ON_USE (`SpellTrigger == 0`) spell block — what a right-click/action-bar use + /// casts, and the key the item's cooldown tracks (the client's own 5-slot scan: spell id > 0, + /// trigger == 0 — wow-re `wave-cooldown.md` `GetItemCooldown 0x6e2ed0`). `None` for items with + /// no use effect. A stored view onto [`Self::spells`] (rather than a derived accessor) so + /// existing cooldown/tooltip consumers reading `.use_spell` are untouched. + pub use_spell: Option, + /// `Bonding` — `ItemBondingType` (0 none … 4 quest-bind); the tooltip's "Binds when picked + /// up"/"equipped"/"used" line. + pub bonding: u32, + /// The item's flavor text (the tooltip's italic line under the stat block); empty = none. + pub description: String, + /// `PageText` — a readable item's `PageText.wdb` id (0 = not a book/readable). + pub page_text: u32, + /// `LanguageID` — id from `Languages.dbc`; which in-game language a readable's text renders in. + pub language_id: u32, + /// `PageMaterial` — id from `PageTextMaterial.dbc`; the book-frame background/texture. + pub page_material: u32, + /// `StartQuest` — a quest-starter item's quest id (0 = doesn't start a quest). + pub start_quest: u32, + /// `LockID` — id from `Lock.dbc`; nonzero means the item (a chest/junkbox) needs picking/keying + /// open. + pub lock_id: u32, + /// `Material` — id from `Material.dbc`; drives the item's equip/drop/footstep sound set. + pub material: u32, + /// `Sheath` — the holster style a drawn weapon of this type renders with (vmangos + /// `ItemPrototype::Sheath`; the same vocabulary as [`super::update_object::ObjectFields`]'s + /// virtual-item sheath byte). + pub sheath: u32, + /// `RandomProperty` — id from `ItemRandomProperties.dbc`; a "of the Whale"-style suffix roll + /// (the concrete roll lives on the item *instance*, not the template — this is just which + /// property table applies). + pub random_property: u32, + /// `Block` — a shield's block value (two `u32`s past `Sheath`, after `RandomProperty`). + pub block: u32, + /// `ItemSet` — id from `ItemSet.dbc`; 0 = not part of a set. + pub item_set: u32, + /// `MaxDurability` — 0 for items without durability (never repairable). + pub max_durability: u32, + /// `Area` — id from `AreaTable.dbc`; a zone-bound item's required zone (0 = anywhere). + pub area: u32, + /// `Map` — id from `Map.dbc`; a map-bound item's required map (0 = anywhere). + pub map: u32, + /// `BagFamily` — a bag's accepted-item-type bitmask (soul bag, herb bag, …; 0 = a normal bag). + pub bag_family: u32, +} + +/// One `Damage` block ([`ItemInfo::damages`] — block 0 is also mirrored into +/// [`ItemInfo::dmg_min`]/[`ItemInfo::dmg_max`]/[`ItemInfo::dmg_type`]). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ItemDamage { + pub min: f32, + pub max: f32, + /// 0 physical, 1 Holy … 6 Arcane (`Resistances.dbc` id). + pub school: u32, +} + +/// One item-template spell block ([`ItemInfo::spells`]) — the full 6-word wire shape, not just the +/// resolved ON_USE cooldown pair ([`ItemUseSpell`]). `charges`: positive = consumed only while +/// charges last, negative = the item itself is consumed once charges run out (vmangos +/// `ItemPrototype::_ItemSpell::SpellCharges`). The cooldown pair is **server-resolved** the same way +/// as [`ItemUseSpell`]'s (VERIFIED vmangos `ItemHandler.cpp:354-391`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ItemSpellEntry { + pub spell_id: u32, + /// `ItemSpelltriggerType`: 0 ON_USE, 1 ON_EQUIP, 2 CHANCE_ON_HIT. + pub trigger: u32, + pub charges: i32, + /// Use-cooldown ms; negative = the spell's own `RecoveryTime`. + pub cooldown_ms: i32, + /// Shared-cooldown category (potions 4, …); the wire's resolved value. + pub category: u32, + /// Category cooldown ms; negative = the spell's own `CategoryRecoveryTime`. + pub category_cooldown_ms: i32, +} + +/// The first ON_USE spell block ([`ItemInfo::use_spell`]) — a resolved-cooldown view of whichever +/// [`ItemSpellEntry`] has `trigger == 0`. The cooldown pair is **server-resolved** (VERIFIED vmangos +/// `ItemHandler.cpp:354-380`: the `item_template` override when its value is `>= 0`, else the +/// spell's own `RecoveryTime`/`Category`/`CategoryRecoveryTime`) — but a lone `-1` can still ride +/// next to a set override, so the fields stay signed and a negative means "use the spell's own +/// Spell.dbc value" (the client's `>= 0` pick in `StartCooldown 0x6e2c60`, wow-re +/// `wave-cooldown.md`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ItemUseSpell { + pub spell_id: u32, + /// Use-cooldown ms; negative = the spell's own `RecoveryTime`. + pub cooldown_ms: i32, + /// Shared-cooldown category (potions 4, …); the wire's resolved value. + pub category: u32, + /// Category cooldown ms; negative = the spell's own `CategoryRecoveryTime`. + pub category_cooldown_ms: i32, +} + +/// Read `SMSG_ITEM_QUERY_SINGLE_RESPONSE` → `(entry, Some(head))`, or `(entry, None)` on a miss +/// (VERIFIED field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`). +pub(super) fn read_item_query_response(r: &mut &[u8]) -> io::Result<(u32, Option)> { + let entry = read_u32_le(r)?; + if entry & 0x8000_0000 != 0 { + return Ok((entry & 0x7FFF_FFFF, None)); + } + let class = read_u32_le(r)?; + let subclass = read_u32_le(r)?; + let name = read_cstring(r)?; + for _ in 0..3 { + let _ = read_cstring(r)?; // name2..name4 — the server sends empties + } + let display_info_id = read_u32_le(r)?; + let quality = read_u32_le(r)?; + let flags = read_u32_le(r)?; + let buy_price = read_u32_le(r)?; + let sell_price = read_u32_le(r)?; + let inventory_type = read_u32_le(r)?; + let allowable_class = read_i32_le(r)?; + let allowable_race = read_i32_le(r)?; + let item_level = read_u32_le(r)?; + let required_level = read_u32_le(r)?; + let required_skill = read_u32_le(r)?; + let required_skill_rank = read_u32_le(r)?; + let required_spell = read_u32_le(r)?; + let required_honor_rank = read_u32_le(r)?; + let required_city_rank = read_u32_le(r)?; + let required_rep_faction = read_u32_le(r)?; + let required_rep_rank = read_u32_le(r)?; + let max_count = read_u32_le(r)?; + let stackable = read_u32_le(r)?; + let container_slots = read_u32_le(r)?; + + // 10x ItemStat { type, value } — kept only where either half is nonzero (an all-zero slot is a + // genuinely unused one), wire order preserved. + let mut stats = Vec::new(); + for _ in 0..10 { + let stat_type = read_u32_le(r)?; + let stat_value = read_i32_le(r)?; + if stat_type != 0 || stat_value != 0 { + stats.push((stat_type, stat_value)); + } + } + + // 5x Damage { min f32, max f32, type u32 }, wire order. Block 0 is the tooltip's primary + // damage line — always mirrored into the legacy dmg_min/dmg_max/dmg_type fields for existing + // consumers, whether or not it clears the `max > 0` filter below (a non-weapon's block 0 is a + // real 0/0/0, not a missing value). + let dmg_min = read_f32_le(r)?; + let dmg_max = read_f32_le(r)?; + let dmg_type = read_u32_le(r)?; + let mut damages = Vec::new(); + if dmg_max > 0.0 { + damages.push(ItemDamage { + min: dmg_min, + max: dmg_max, + school: dmg_type, + }); + } + for _ in 0..4 { + let min = read_f32_le(r)?; + let max = read_f32_le(r)?; + let school = read_u32_le(r)?; + if max > 0.0 { + damages.push(ItemDamage { min, max, school }); + } + } + + // Armor is its own field; the remaining 6-wide resistance run (Holy/Fire/Nature/Frost/ + // Shadow/Arcane) lands in `resistances` in wire order. + let armor = read_u32_le(r)?; + let holy_res = read_i32_le(r)?; + let fire_res = read_i32_le(r)?; + let nature_res = read_i32_le(r)?; + let frost_res = read_i32_le(r)?; + let shadow_res = read_i32_le(r)?; + let arcane_res = read_i32_le(r)?; + let resistances = [ + holy_res, fire_res, nature_res, frost_res, shadow_res, arcane_res, + ]; + + let delay_ms = read_u32_le(r)?; + let ammo_type = read_u32_le(r)?; + let ranged_mod_range = read_f32_le(r)?; + + // 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown } + // (VERIFIED vmangos `ItemHandler.cpp:354-391`) — the server always writes all six words; a slot + // with no resolvable spell sends the sentinel 0,0,0,-1,0,-1. Kept in `spells` wherever + // `spell_id != 0`; the first ON_USE (trigger 0) slot also surfaces as `use_spell` — the + // client's own 5-slot scan. + let mut spells = Vec::new(); + let mut use_spell = None; + for _ in 0..5 { + let spell_id = read_u32_le(r)?; + let trigger = read_u32_le(r)?; + let charges = read_i32_le(r)?; + let cooldown_ms = read_i32_le(r)?; + let category = read_u32_le(r)?; + let category_cooldown_ms = read_i32_le(r)?; + if spell_id != 0 { + spells.push(ItemSpellEntry { + spell_id, + trigger, + charges, + cooldown_ms, + category, + category_cooldown_ms, + }); + if use_spell.is_none() && trigger == 0 { + use_spell = Some(ItemUseSpell { + spell_id, + cooldown_ms, + category, + category_cooldown_ms, + }); + } + } + } + + let bonding = read_u32_le(r)?; + let description = read_cstring(r)?; + let page_text = read_u32_le(r)?; + let language_id = read_u32_le(r)?; + let page_material = read_u32_le(r)?; + let start_quest = read_u32_le(r)?; + let lock_id = read_u32_le(r)?; + let material = read_u32_le(r)?; + let sheath = read_u32_le(r)?; + let random_property = read_u32_le(r)?; + let block = read_u32_le(r)?; + let item_set = read_u32_le(r)?; + let max_durability = read_u32_le(r)?; + let area = read_u32_le(r)?; + let map = read_u32_le(r)?; + let bag_family = read_u32_le(r)?; + + Ok(( + entry, + Some(ItemInfo { + class, + subclass, + name, + display_info_id, + quality, + flags, + buy_price, + sell_price, + inventory_type, + allowable_class, + allowable_race, + item_level, + required_level, + required_skill, + required_skill_rank, + required_spell, + required_honor_rank, + required_city_rank, + required_rep_faction, + required_rep_rank, + max_count, + stackable, + container_slots, + stats, + damages, + dmg_min, + dmg_max, + dmg_type, + armor, + resistances, + delay_ms, + ammo_type, + ranged_mod_range, + spells, + use_spell, + bonding, + description, + page_text, + language_id, + page_material, + start_quest, + lock_id, + material, + sheath, + random_property, + block, + item_set, + max_durability, + area, + map, + bag_family, + }), + )) +} + +/// Body of `CMSG_ITEM_QUERY_SINGLE` (vmangos `QueryItem::ReadFromWorldPacket`): the template +/// `entry` + a full 8-byte item guid (0 when asking about a template with no instance in hand) — +/// the exact shape of the creature query. +pub fn item_query(entry: u32, guid: u64) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&entry.to_le_bytes()); + body.extend_from_slice(&guid.to_le_bytes()); + body +} + +/// The wire's "the player's own inventory" bag index (`INVENTORY_SLOT_BAG_0`): with it, `slot` +/// addresses the player descriptor's item array directly — equipment 0–18, bag slots 19–22, the +/// backpack 23–38 (VERIFIED vmangos `Player.h` slot enums; the same 23-slot base the descriptor's +/// `PACK_SLOT_1` offset encodes). +pub const BAG_PLAYER_INVENTORY: u8 = 255; +/// The backpack's first player-array slot (`INVENTORY_SLOT_ITEM_START`). +pub const SLOT_PACK_FIRST: u8 = 23; +/// The first equipped-bag player-array slot (`INVENTORY_SLOT_BAG_START`; bags occupy 19–22). +pub const SLOT_BAG_FIRST: u8 = 19; + +/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171 +/// `Opcodes_1_12_1.h`): `bagIndex` (a bag's player-array slot 19–22, or [`BAG_PLAYER_INVENTORY`] +/// with an absolute `slot`), `slot` (0-based within the bag), `spellSlot` (which of the template's +/// 5 spell effects — 0, the "use" effect, for a plain use), then a self-shaped cast-target block +/// (mask 0 — consumables resolve their own implicit target server-side). +pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8) -> Vec { + let mut body = Vec::with_capacity(5); + body.push(bag_index); + body.push(slot); + body.push(spell_slot); + body.extend_from_slice(&0u16.to_le_bytes()); + body +} + +/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`, +/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source +/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client +/// sends this — not USE_ITEM — when the clicked bag item is *equippable* (the equip-vs-use fork is +/// client-side); the server picks the destination slot itself. Refusals answer +/// `SMSG_INVENTORY_CHANGE_FAILURE`. +pub fn auto_equip_item(bag_index: u8, slot: u8) -> Vec { + vec![bag_index, slot] +} + +/// Body of `CMSG_AUTOSTORE_BAG_ITEM` (VERIFIED vmangos `AutoStoreBagItem::ReadFromWorldPacket`, +/// `Server/Packets/Item.cpp:23-28` + `.h:41-49`; opcode 267 `Opcodes_1_12_1.h:270`): `srcbag`, +/// `srcslot`, `dstbag` — all `uint8`. "Auto-store this item into that bag, server picks the slot." +/// Builder only tonight (backpack-internal moves take [`swap_inv_item`]); no UI path yet. +pub fn auto_store_bag_item(src_bag: u8, src_slot: u8, dst_bag: u8) -> Vec { + vec![src_bag, src_slot, dst_bag] +} + +/// Body of `CMSG_SWAP_ITEM` (VERIFIED vmangos `SwapItem::ReadFromWorldPacket`, +/// `Server/Packets/Item.cpp:30-36` + `.h:51-61`; opcode 268 `Opcodes_1_12_1.h:271`): `dstbag`, +/// `dstslot`, `srcbag`, `srcslot` — all `uint8`, **destination FIRST**. The general bag↔bag move +/// (either endpoint an equipped bag). Builder only tonight; the windowed backpack's internal moves +/// go out as [`swap_inv_item`]. +pub fn swap_item(dst_bag: u8, dst_slot: u8, src_bag: u8, src_slot: u8) -> Vec { + vec![dst_bag, dst_slot, src_bag, src_slot] +} + +/// Body of `CMSG_SWAP_INV_ITEM` (VERIFIED vmangos `SwapInvItem::ReadFromWorldPacket`, +/// `Server/Packets/Item.cpp:38-42` + `.h:63-72`; opcode 269 `Opcodes_1_12_1.h:272`): `srcslot`, +/// `dstslot` — two `uint8` player-array slots, both implicitly on the player itself +/// (`INVENTORY_SLOT_BAG_0`). This is the wire for a backpack-internal pick/place/swap: both slots +/// are `INVENTORY_SLOT_ITEM_START`+i (see [`SLOT_PACK_FIRST`]). An empty destination is still a +/// swap on this wire — the server treats it as a move. +pub fn swap_inv_item(src_slot: u8, dst_slot: u8) -> Vec { + vec![src_slot, dst_slot] +} + +/// Body of `CMSG_SPLIT_ITEM` (VERIFIED vmangos `SplitItem::ReadFromWorldPacket`, +/// `Server/Packets/Item.cpp:44-51` + `.h:74-85`; opcode 270 `Opcodes_1_12_1.h:273`): `srcbag`, +/// `srcslot`, `dstbag`, `dstslot`, `count` — all `uint8`. Builder only: the UI split dialog is out +/// of scope, but the wire is pinned so a later stack-split slice has a byte-exact starting point. +pub fn split_item(src_bag: u8, src_slot: u8, dst_bag: u8, dst_slot: u8, count: u8) -> Vec { + vec![src_bag, src_slot, dst_bag, dst_slot, count] +} + +/// Body of `CMSG_DESTROYITEM` (VERIFIED vmangos `Packets/Item.cpp:59-68`; opcode 273 +/// `Opcodes_1_12_1.h`): `bag`, `slot`, `count` (0 = the whole stack — matches [`split_item`]'s +/// count and the app's `container_destroys` triple), then THREE more `uint8`s the server reads +/// off the wire and discards — the real client sends them, so the body stays 6 bytes rather than +/// a shorter, non-matching one. Decision 0216 §3: the delete-confirm popup's `OnAccept` +/// (`DeleteCursorItem`). +pub fn destroy_item(bag: u8, slot: u8, count: u8) -> Vec { + vec![bag, slot, count, 0, 0, 0] +} + +/// Body of `CMSG_SET_AMMO` (VERIFIED wow-re `cursor-dragdrop-slots.md`: the client's auto-equip +/// sender `0x5e1480` forks ammo-class → opcode `0x268`, body `{itemEntry}` (a single `u32`); the +/// vmangos handler `HandleSetAmmoOpcode` reads the same lone `uint32` entry). Unlike every other +/// item CMSG this is NOT a `(bag, slot)` address — ammo is loaded by item *entry*, and the stack +/// stays put in the bag (`PLAYER_AMMO_ID` just references it). The server refuses a mismatch +/// (`EQUIP_ERR_ONLY_AMMO_CAN_GO_HERE` &c.) via `SMSG_INVENTORY_CHANGE_FAILURE`. Decision 0526. +pub fn set_ammo(entry: u32) -> Vec { + entry.to_le_bytes().to_vec() +} + +/// Read `SMSG_INVENTORY_CHANGE_FAILURE` (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`): +/// `u8 reason` (`InventoryResult`; 0 = OK, no tail), then — only when failed — a `u32` required +/// level *iff* `reason == 1` (`CANT_EQUIP_LEVEL_I`), the two full item guids, and the bag subslot. +/// Returns `(reason, required_level, item_guid)`. +pub(super) fn read_inventory_change_failure(r: &mut &[u8]) -> io::Result<(u8, Option, u64)> { + let reason = read_u8(r)?; + if reason == 0 { + return Ok((0, None, 0)); + } + let required_level = if reason == 1 { + Some(read_u32_le(r)?) + } else { + None + }; + let item_guid = read_u64_le(r)?; + let _item2 = read_u64_le(r)?; + let _bag_subslot = read_u8(r)?; + Ok((reason, required_level, item_guid)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The `SMSG_ITEM_QUERY_SINGLE_RESPONSE` parse goldens (hit + miss, byte-exact against the + // vmangos field order) live in `tests/items.rs` — one home, no drifting twins. + + // Byte-exact encode goldens — the item-move CMSG bodies (VERIFIED field order + widths against + // vmangos `Server/Packets/Item.cpp` `ReadFromWorldPacket`s; every field is a `uint8`). + + #[test] + fn auto_equip_item_body() { + // 266: srcbag, srcslot (Item.cpp:17-21). + assert_eq!(auto_equip_item(255, 30), vec![255, 30]); + } + + #[test] + fn auto_store_bag_item_body() { + // 267: srcbag, srcslot, dstbag (Item.cpp:23-28). + assert_eq!(auto_store_bag_item(255, 30, 19), vec![255, 30, 19]); + } + + #[test] + fn swap_item_body_destination_first() { + // 268: dstbag, dstslot, srcbag, srcslot — destination pair FIRST (Item.cpp:30-36). + assert_eq!(swap_item(19, 3, 255, 30), vec![19, 3, 255, 30]); + } + + #[test] + fn swap_inv_item_body() { + // 269: srcslot, dstslot (Item.cpp:38-42). Backpack slot 1↔2 = player-array 23↔24. + assert_eq!(swap_inv_item(23, 24), vec![23, 24]); + } + + #[test] + fn set_ammo_body() { + // 616 (0x268): a lone little-endian u32 item entry (wow-re cursor-dragdrop-slots.md). + assert_eq!(set_ammo(0x0001_6b74), vec![0x74, 0x6b, 0x01, 0x00]); + } + + #[test] + fn split_item_body() { + // 270: srcbag, srcslot, dstbag, dstslot, count (Item.cpp:44-51). + assert_eq!(split_item(255, 23, 255, 24, 5), vec![255, 23, 255, 24, 5]); + } + + #[test] + fn destroy_item_body() { + // 273: bag, slot, count, then three ignored trailing bytes (Item.cpp:59-68). + assert_eq!(destroy_item(255, 23, 0), vec![255, 23, 0, 0, 0, 0]); + } + + // SMSG_INVENTORY_CHANGE_FAILURE parse — both branches of the conditional `requiredLevel u32` + // (VERIFIED vmangos `InventoryChangeFailure::AppendBodyTo`, `Item.cpp:198-209`; + // EQUIP_ERR_CANT_EQUIP_LEVEL_I = 1, `Objects/ItemDefines.h`). + + #[test] + fn inventory_failure_ok_reason_is_bare() { + // reason 0 (EQUIP_ERR_OK) ships no tail. + let buf = [0u8]; + let mut r = &buf[..]; + assert_eq!(read_inventory_change_failure(&mut r).unwrap(), (0, None, 0)); + } + + #[test] + fn inventory_failure_level_branch_reads_the_u32() { + // reason 1 (CANT_EQUIP_LEVEL_I): requiredLevel u32, item1Guid u64, item2Guid u64, bagSlot u8. + let mut buf = Vec::new(); + buf.push(1u8); // reason + buf.extend_from_slice(&40u32.to_le_bytes()); // requiredLevel + buf.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); // item1 + buf.extend_from_slice(&0u64.to_le_bytes()); // item2 + buf.push(7); // bagSlot + let mut r = &buf[..]; + assert_eq!( + read_inventory_change_failure(&mut r).unwrap(), + (1, Some(40), 0x1122_3344_5566_7788) + ); + } + + #[test] + fn inventory_failure_nonlevel_branch_has_no_u32() { + // Any failed reason != 1 skips requiredLevel: item1Guid u64, item2Guid u64, bagSlot u8. + let mut buf = Vec::new(); + buf.push(3u8); // reason (ITEM_DOESNT_GO_TO_SLOT) — no requiredLevel + buf.extend_from_slice(&0xDEAD_BEEF_0000_0001u64.to_le_bytes()); // item1 + buf.extend_from_slice(&0u64.to_le_bytes()); // item2 + buf.push(0); // bagSlot + let mut r = &buf[..]; + assert_eq!( + read_inventory_change_failure(&mut r).unwrap(), + (3, None, 0xDEAD_BEEF_0000_0001) + ); + } +} diff --git a/crates/benilla-protocol/src/messages/loot.rs b/crates/benilla-protocol/src/messages/loot.rs new file mode 100644 index 00000000..30532cb3 --- /dev/null +++ b/crates/benilla-protocol/src/messages/loot.rs @@ -0,0 +1,802 @@ +//! Loot messages — the solo-loot wire family (opcodes 264, 349-355, 357-358; VERIFIED vmangos +//! `Server/Protocol/Opcodes_1_12_1.h:267,350-359`). Bodies from vmangos `Server/Packets/Loot.{h,cpp}` +//! (the light client-read / server-append shapes) + the hand-serialized `LootMgr.cpp` +//! (`operator<<(LootItem)`/`operator<<(LootView)`) and `Player.cpp` (`SendLoot`/`SendLootError`). +//! The group-roll family (opcodes 670-674 — `SMSG_LOOT_START_ROLL`/`SMSG_LOOT_ROLL`/ +//! `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` + our `CMSG_LOOT_ROLL`) rides here too, from +//! vmangos `Group/Group.cpp`'s four senders (decision 0591). Master loot (`CMSG_LOOT_MASTER_GIVE` +//! 675 / `SMSG_LOOT_MASTER_LIST` 676) remains out of scope. + +use std::io; + +use crate::wire::{read_u32_le, read_u64_le, read_u8}; + +/// One item row on the normal shape of `SMSG_LOOT_RESPONSE` (vmangos `LootMgr.cpp:837-845` the +/// `LootItem` write, wrapped by the slot index + trailing slot-type byte the caller appends around +/// it — `LootMgr.cpp:900-912`, `ALL_PERMISSION` branch, the solo-loot path). `slot` is the wire's +/// 0-based loot-window slot — what [`autostore_loot_item`] addresses back; a quest item rides the +/// same list with `slot = items.len() + i` (`LootMgr.cpp:970`, out of the solo drop-loot path but +/// parsed the same way if ever present). The wire's `randomSuffix` field is **not** a real per-item +/// value — the server always writes a literal `0` there (`LootMgr.cpp:841`) — so this parser reads +/// and discards it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootItem { + pub slot: u8, + pub item_id: u32, + pub count: u32, + /// `item_template.display_id` → icon via `ItemDisplayInfo.dbc`. + pub display_info_id: u32, + pub random_property_id: u32, + /// A [`slot_type`] code. Solo paths (`ALL_PERMISSION`/`OWNER_PERMISSION`) always write + /// `ALLOW_LOOT` (`LootMgr.cpp:918-926`); the others are group-loot only, out of scope for v1. + pub slot_type: u8, +} + +/// `LootSlotType` (VERIFIED vmangos `LootMgr.h:75-83`) — the `u8` riding after each [`LootItem`] on +/// `SMSG_LOOT_RESPONSE`. +pub mod slot_type { + /// The player can loot the item. + pub const ALLOW_LOOT: u8 = 0; + /// A group roll is ongoing on this item — shown, not yet lootable. + pub const ROLL_ONGOING: u8 = 1; + /// Only the group's loot master can distribute this item. + pub const MASTER: u8 = 2; + /// Shown red — not lootable (a missing-requirement item in a group). + pub const LOCKED: u8 = 3; + /// Owner-permission solo looting, binding checks skipped. + pub const OWNER: u8 = 4; +} + +/// `LootType` (VERIFIED vmangos `LootMgr.h:49-61`) — the `u8` naming what's being looted on the +/// normal shape of `SMSG_LOOT_RESPONSE`. Only 4 values ever reach the wire: `Player::SendLoot` +/// (`Player.cpp:8117-8131`) remaps `SKINNING`(6)/`INSIGNIA`(22) to `PICKPOCKETING` and +/// `FISHING_HOLE`(20)/`FISHING_FAIL`(21) to `FISHING` before sending — the client has no separate +/// UI state for those. `CMSG_LOOT` (a corpse/creature loot request) always answers with `CORPSE` +/// (`LootHandler.cpp:340-354`, `HandleLootOpcode` → `SendLoot(guid, LOOT_CORPSE)` unconditionally); +/// the rest arrive only from other loot sources (pickpocket spell, fishing, disenchant), out of +/// scope for this slice but pinned for completeness. +pub mod loot_type { + pub const CORPSE: u8 = 1; + pub const PICKPOCKETING: u8 = 2; + pub const FISHING: u8 = 3; + pub const DISENCHANTING: u8 = 4; +} + +/// `LootError` (VERIFIED vmangos `LootMgr.h:85-100`) — the `u8` on the error shape of +/// `SMSG_LOOT_RESPONSE` (`Player::SendLootError`, `Player.cpp:7744-7750`). A plain `CMSG_LOOT` on a +/// creature corpse can only surface a subset: `DIDNT_KILL` (guid isn't a lootable +/// creature/player/corpse, or the creature check fails — `LootHandler.cpp:342-346`, +/// `Player.cpp:7938-7943`), `PLAYER_NOT_FOUND` (dead/not-in-world — `LootHandler.cpp:349-353`), +/// `PLAY_TIME_EXCEEDED`, `NOTSTANDING`, `STUNNED` (`LootHandler.cpp:358-373`), and `TOO_FAR` +/// (range check in `Player.cpp:7938-7947`). The rest (`BAD_FACING`/`LOCKED`/the `MASTER_*` +/// trio/`ALREADY_PICKPOCKETED`/`NOT_WHILE_SHAPESHIFTED`) ride other loot sources or group/master +/// loot — out of scope for v1, pinned for completeness. +pub mod loot_error { + pub const DIDNT_KILL: u8 = 0; + pub const TOO_FAR: u8 = 4; + pub const BAD_FACING: u8 = 5; + pub const LOCKED: u8 = 6; + pub const NOTSTANDING: u8 = 8; + pub const STUNNED: u8 = 9; + pub const PLAYER_NOT_FOUND: u8 = 10; + pub const PLAY_TIME_EXCEEDED: u8 = 11; + pub const MASTER_INV_FULL: u8 = 12; + pub const MASTER_UNIQUE_ITEM: u8 = 13; + pub const MASTER_OTHER: u8 = 14; + pub const ALREADY_PICKPOCKETED: u8 = 15; + pub const NOT_WHILE_SHAPESHIFTED: u8 = 16; +} + +/// The two shapes `SMSG_LOOT_RESPONSE` can take, disambiguated by the `lootType` byte right after +/// the guid: `0` means "no window — this is an error", anything else is a real loot window. +#[derive(Debug, Clone, PartialEq)] +pub enum LootResponseBody { + /// The normal shape (VERIFIED vmangos `Player.cpp:8135-8138` + `operator<<(LootView)`, + /// `LootMgr.cpp:848-873`): `u32 gold, u8 itemCount`, then `itemCount` rows of [`LootItem`]. + Items { + loot_type: u8, + gold: u32, + items: Vec, + }, + /// The error shape (VERIFIED vmangos `Player::SendLootError`, `Player.cpp:7744-7750`): just + /// the trailing `u8` error code, nothing else follows. + Error { error: u8 }, +} + +/// Body of `CMSG_LOOT` (VERIFIED vmangos `Server/Packets/Loot.cpp:8-11`, +/// `LootUnit::ReadFromWorldPacket`): one full 8-byte guid — the corpse/creature/player being +/// opened. Always answered by `SMSG_LOOT_RESPONSE` (one shape or the other). +pub fn loot(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_AUTOSTORE_LOOT_ITEM` (VERIFIED vmangos `Server/Packets/Loot.cpp:3-6`, +/// `AutoStoreLootItem::ReadFromWorldPacket`): one `u8` — the 0-based wire loot slot (see +/// [`LootItem::slot`]). The server auto-places the item into the first free bag slot +/// (`LootHandler.cpp:183-206`, `CanStoreNewItem`/`StoreNewItem`); no specific-slot variant exists +/// for loot (unlike vendor buy). +pub fn autostore_loot_item(loot_slot: u8) -> Vec { + vec![loot_slot] +} + +/// Body of `CMSG_LOOT_MONEY` (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:351` — the +/// handler `WorldSession::HandleLootMoneyOpcode` takes a `NullClientPacket`): empty. +pub fn loot_money() -> Vec { + Vec::new() +} + +/// Body of `CMSG_LOOT_RELEASE` (VERIFIED vmangos `Server/Packets/Loot.cpp:13-16`, +/// `LootRelease::ReadFromWorldPacket`): one full 8-byte guid, though the server ignores it and +/// releases whatever loot guid it has stored for us instead (`Server/Packets/Loot.h:31-36`: "not +/// used by server"; handler `HandleLootReleaseOpcode`, `LootHandler.cpp:383-389`). +pub fn loot_release(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// `RollVote` (VERIFIED vmangos `Group/Group.h:88-99`) — the `u8` vote a client may cast on +/// `CMSG_LOOT_ROLL`. The server hard-rejects anything `>= 3` (`MAX_ROLL_FROM_CLIENT`, +/// `GroupHandler.cpp:367-368`), so these three are the whole client-side vocabulary; `Group.h`'s +/// `ROLL_NOT_EMITED_YET`(3)/`ROLL_NOT_VALID`(4) are server bookkeeping that never reaches us. +/// +/// These same values also ride *back* as `SMSG_LOOT_ROLL`/`SMSG_LOOT_ROLL_WON`'s `roll_type` — +/// but there they are **not** self-describing: see [`LootRoll::is_dice`] for the disambiguation. +pub mod roll_vote { + pub const PASS: u8 = 0; + pub const NEED: u8 = 1; + pub const GREED: u8 = 2; +} + +/// Body of `CMSG_LOOT_ROLL` (VERIFIED vmangos `Server/Packets/Loot.cpp:18-23`, +/// `LootRoll::ReadFromWorldPacket`): `u64 lootedTarget, u32 itemSlot, u8 rollType` — the corpse +/// guid + wire loot slot that identify *which* roll, and our [`roll_vote`]. The pair +/// `(lootedTarget, itemSlot)` is the roll's server-side identity; the client-side `rollID` the +/// FrameXML API passes around is ours alone and never reaches the wire. +pub fn loot_roll(looted_target: u64, item_slot: u32, roll_type: u8) -> Vec { + let mut body = looted_target.to_le_bytes().to_vec(); + body.extend_from_slice(&item_slot.to_le_bytes()); + body.push(roll_type); + body +} + +/// `SMSG_LOOT_START_ROLL` (VERIFIED vmangos `Server/Packets/Loot.{h:78-90,cpp:43-51}`) — a group +/// roll opened on one drop; sent to every eligible roller. `countdown_ms` is the server's +/// `LOOT_ROLL_TIMEOUT`, a flat `1*MINUTE*IN_MILLISECONDS` = `60_000` at 5875 +/// (`Group/Group.cpp:67`), but it is read from the wire rather than assumed. +/// +/// The wire's `randomSuffix` field is a literal `0` the server never fills (`Group.cpp:764`), so +/// this parser reads and discards it — same treatment as [`LootItem`]'s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootStartRoll { + /// The corpse/container being looted — with `item_slot`, the roll's wire identity. + pub looted_target: u64, + /// The 0-based **wire** loot slot (the same vocabulary as [`LootItem::slot`], widened to `u32` + /// here because this packet carries it as one). + pub item_slot: u32, + pub item_id: u32, + pub random_property_id: u32, + /// How long the roll stays open, in milliseconds. + pub countdown_ms: u32, +} + +/// `SMSG_LOOT_ROLL` (VERIFIED vmangos `Server/Packets/Loot.{h:92-106,cpp:53-63}`) — one +/// announcement about one roller, broadcast to every eligible roller. +/// +/// **The `(roll_number, roll_type)` pair is overloaded** and this is the load-bearing subtlety of +/// the whole family. vmangos emits exactly four shapes (VERIFIED `Group/Group.cpp:970-990` for the +/// votes, `:1163`/`:1214` for the dice): +/// +/// | `roll_number` | `roll_type` | meaning | +/// |---------------|-------------|--------------------------------------------| +/// | `0` | `0` | that player **voted** Need | +/// | `128` | `128` | that player **voted** Pass | +/// | `128` | `2` | that player **voted** Greed | +/// | `1..=100` | `1` or `2` | that player's **dice result** (Need/Greed) | +/// +/// So `roll_type` alone cannot tell a Greed *vote* (`128, 2`) from a Greed *dice roll* +/// (`57, 2`) — `roll_number` is the discriminator. [`LootRoll::is_dice`] encodes it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootRoll { + pub looted_target: u64, + pub item_slot: u32, + /// The player this announcement is about — compare against our own guid for the "You …" + /// phrasing. + pub roller: u64, + pub item_id: u32, + pub random_property_id: u32, + /// `1..=100` for a dice result; `0`/`128` for a vote announcement (see the type docs). + pub roll_number: u8, + /// A [`roll_vote`] value for a dice result; `0`/`2`/`128` for a vote (see the type docs). + pub roll_type: u8, +} + +impl LootRoll { + /// Whether this is an actual **dice result** (`roll_number` in `1..=100`) rather than one of + /// the three vote announcements (`roll_number` `0` or `128`). See the type docs for the table + /// this encodes — vmangos only ever emits `urand(1, 100)` for real rolls (`Group.cpp:1162`, + /// `:1213`), and only `0`/`128` for votes, so the ranges cannot collide. + pub fn is_dice(&self) -> bool { + (1..=100).contains(&self.roll_number) + } + + /// The vote this announcement reports when it is *not* a dice result — a [`roll_vote`] value. + /// `None` for a dice result (there `roll_type` is already a true [`roll_vote`]) or for a pair + /// outside the four shapes vmangos emits. + pub fn vote(&self) -> Option { + match (self.roll_number, self.roll_type) { + (0, 0) => Some(roll_vote::NEED), + (128, 128) => Some(roll_vote::PASS), + (128, roll_vote::GREED) => Some(roll_vote::GREED), + _ => None, + } + } +} + +/// `SMSG_LOOT_ROLL_WON` (VERIFIED vmangos `Server/Packets/Loot.{h:108-122,cpp:65-75}`) — the roll +/// resolved and `winner` took the item. Note the field order differs from [`LootRoll`]: the winner +/// guid lands *after* the item block, not before it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootRollWon { + pub looted_target: u64, + pub item_slot: u32, + pub item_id: u32, + pub random_property_id: u32, + pub winner: u64, + /// The winning dice value (`1..=100`), or a literal `100` on the uncontested + /// single-eligible-roller short-circuit (`Group.cpp:1110`, sent with `ROLL_NEED`). + pub roll_number: u8, + /// A [`roll_vote`] value — which kind of roll won. + pub roll_type: u8, +} + +/// `SMSG_LOOT_ALL_PASSED` (VERIFIED vmangos `Server/Packets/Loot.{h:124-134,cpp:77-84}`) — nobody +/// wanted it; the roll closes and the item returns to the corpse for ordinary looting. +/// +/// **Field-order quirk:** unlike every other packet in this family, the append order here is +/// `itemRandomPropId` **then** `randomSuffixId` (`Loot.cpp:79-83`) — the two `u32`s are swapped +/// relative to [`LootStartRoll`]/[`LootRoll`]/[`LootRollWon`]. Both are effectively `0` in +/// practice (the server never fills `randomSuffixId`), so the swap is invisible on the wire today, +/// but the parser follows the source order rather than the family's. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LootAllPassed { + pub looted_target: u64, + pub item_slot: u32, + pub item_id: u32, + pub random_property_id: u32, +} + +/// Read `SMSG_LOOT_START_ROLL` (see [`LootStartRoll`]). +pub(super) fn read_loot_start_roll(r: &mut &[u8]) -> io::Result { + let looted_target = read_u64_le(r)?; + let item_slot = read_u32_le(r)?; + let item_id = read_u32_le(r)?; + let _random_suffix = read_u32_le(r)?; // literal 0 on the wire (Group.cpp:764) + let random_property_id = read_u32_le(r)?; + let countdown_ms = read_u32_le(r)?; + Ok(LootStartRoll { + looted_target, + item_slot, + item_id, + random_property_id, + countdown_ms, + }) +} + +/// Read `SMSG_LOOT_ROLL` (see [`LootRoll`] — including the overloaded `(roll_number, roll_type)`). +pub(super) fn read_loot_roll(r: &mut &[u8]) -> io::Result { + let looted_target = read_u64_le(r)?; + let item_slot = read_u32_le(r)?; + let roller = read_u64_le(r)?; + let item_id = read_u32_le(r)?; + let _random_suffix = read_u32_le(r)?; // literal 0 on the wire (Group.cpp:794) + let random_property_id = read_u32_le(r)?; + let roll_number = read_u8(r)?; + let roll_type = read_u8(r)?; + Ok(LootRoll { + looted_target, + item_slot, + roller, + item_id, + random_property_id, + roll_number, + roll_type, + }) +} + +/// Read `SMSG_LOOT_ROLL_WON` (see [`LootRollWon`] — the winner guid follows the item block). +pub(super) fn read_loot_roll_won(r: &mut &[u8]) -> io::Result { + let looted_target = read_u64_le(r)?; + let item_slot = read_u32_le(r)?; + let item_id = read_u32_le(r)?; + let _random_suffix = read_u32_le(r)?; // literal 0 on the wire (Group.cpp:822) + let random_property_id = read_u32_le(r)?; + let winner = read_u64_le(r)?; + let roll_number = read_u8(r)?; + let roll_type = read_u8(r)?; + Ok(LootRollWon { + looted_target, + item_slot, + item_id, + random_property_id, + winner, + roll_number, + roll_type, + }) +} + +/// Read `SMSG_LOOT_ALL_PASSED` (see [`LootAllPassed`] — note the swapped `u32` pair). +pub(super) fn read_loot_all_passed(r: &mut &[u8]) -> io::Result { + let looted_target = read_u64_le(r)?; + let item_slot = read_u32_le(r)?; + let item_id = read_u32_le(r)?; + let random_property_id = read_u32_le(r)?; + let _random_suffix = read_u32_le(r)?; // the swapped tail — a literal 0 (Group.cpp:849) + Ok(LootAllPassed { + looted_target, + item_slot, + item_id, + random_property_id, + }) +} + +/// Read `SMSG_LOOT_RESPONSE` — both shapes (see [`LootResponseBody`]). Wire order (VERIFIED +/// vmangos `Player.cpp:8135-8138`): `u64 guid, u8 lootType`, then either the error tail (`lootType +/// == 0`) or `u32 gold, u8 itemCount` + `itemCount` × [`LootItem`] rows (each `u8 slot, u32 +/// itemid, u32 count, u32 displayInfoID, u32 0(randomSuffix, discarded), u32 randomPropertyId, u8 +/// slotType` — `LootMgr.cpp:848-873,900-912`, the `ALL_PERMISSION` solo branch). +pub(super) fn read_loot_response(r: &mut &[u8]) -> io::Result<(u64, LootResponseBody)> { + let guid = read_u64_le(r)?; + let loot_type = read_u8(r)?; + if loot_type == 0 { + let error = read_u8(r)?; + return Ok((guid, LootResponseBody::Error { error })); + } + let gold = read_u32_le(r)?; + let count = read_u8(r)?; + let mut items = Vec::with_capacity(count as usize); + for _ in 0..count { + let slot = read_u8(r)?; + let item_id = read_u32_le(r)?; + let item_count = read_u32_le(r)?; + let display_info_id = read_u32_le(r)?; + let _random_suffix = read_u32_le(r)?; // always a literal 0 on the wire (LootMgr.cpp:841) + let random_property_id = read_u32_le(r)?; + let slot_type = read_u8(r)?; + items.push(LootItem { + slot, + item_id, + count: item_count, + display_info_id, + random_property_id, + slot_type, + }); + } + Ok(( + guid, + LootResponseBody::Items { + loot_type, + gold, + items, + }, + )) +} + +/// Read `SMSG_LOOT_RELEASE_RESPONSE` (VERIFIED vmangos `Server/Packets/Loot.h:137-145`, +/// `Player::SendLootRelease`, `Player.cpp:7736-7742`): `u64 guid, u8 result` — `result` is always +/// `1` (the struct's default; vmangos never sets it otherwise). +pub(super) fn read_loot_release_response(r: &mut &[u8]) -> io::Result<(u64, u8)> { + Ok((read_u64_le(r)?, read_u8(r)?)) +} + +/// Read `SMSG_LOOT_REMOVED` (VERIFIED vmangos `Server/Packets/Loot.h:147-154`): one `u8` — the wire +/// loot slot that was just taken (by anyone; the row disappears from every current looter's +/// window, `Loot::NotifyItemRemoved`). +pub(super) fn read_loot_removed(r: &mut &[u8]) -> io::Result { + read_u8(r) +} + +/// Read `SMSG_LOOT_MONEY_NOTIFY` (VERIFIED vmangos `Server/Packets/Loot.h:69-76`): one `u32` — the +/// requester's share of the coin pile (an equal split among current looters; solo looting is the +/// degenerate 1-looter case, the requester's whole share). +pub(super) fn read_loot_money_notify(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// `SMSG_ITEM_PUSH_RESULT` (VERIFIED vmangos `Server/Packets/Item.h:324-345` + +/// `Item.cpp:211-224`; both `SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` conditionals evaluate +/// *included* for build 5875) — drives the "You receive loot: …" / "You receive item: …" chat +/// line. `item_slot` is `0xFFFF_FFFF` when the item stacked onto an existing slot instead of +/// landing in a fresh one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ItemPushResult { + pub player_guid: u64, + /// `false` = looted, `true` = received from an NPC (vendor purchase, quest reward). + pub from_npc: bool, + /// `true` = the item was CREATED (crafting's `EffectCreateItem`, a conjure aura — vmangos + /// `SendNewItem`'s own comment "0=received, 1=created"; the only `created=true` senders are + /// the create-item paths) → the client's "You create: …" line. NOT a stack-merge flag. + pub created: bool, + pub show_in_chat: bool, + /// Which bag slot the item went to (`NULL_BAG`/`INVENTORY_SLOT_BAG_0`-style index — the same + /// vocabulary as [`super::items::BAG_PLAYER_INVENTORY`]/[`super::items::SLOT_BAG_FIRST`]). + pub bag_slot: u8, + pub item_slot: u32, + pub item_entry: u32, + pub suffix_factor: u32, + pub random_property_id: u32, + pub count: u32, +} + +/// Read `SMSG_ITEM_PUSH_RESULT` (see [`ItemPushResult`] for the field-inclusion verification). +pub(super) fn read_item_push_result(r: &mut &[u8]) -> io::Result { + let player_guid = read_u64_le(r)?; + let received = read_u32_le(r)?; + let created = read_u32_le(r)?; + let show_in_chat = read_u32_le(r)?; + let bag_slot = read_u8(r)?; + let item_slot = read_u32_le(r)?; + let item_entry = read_u32_le(r)?; + let suffix_factor = read_u32_le(r)?; + let random_property_id = read_u32_le(r)?; + let count = read_u32_le(r)?; + Ok(ItemPushResult { + player_guid, + from_npc: received != 0, + created: created != 0, + show_in_chat: show_in_chat != 0, + bag_slot, + item_slot, + item_entry, + suffix_factor, + random_property_id, + count, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::{opcode, parse_server, ServerPacket}; + + fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + #[test] + fn cmsg_bodies_golden() { + // CMSG_LOOT (Server/Packets/Loot.cpp:8-11): a full guid. + assert_eq!( + loot(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_LOOT body" + ); + + // CMSG_AUTOSTORE_LOOT_ITEM (Loot.cpp:3-6): one u8 wire slot. + assert_eq!( + autostore_loot_item(3), + hx("03"), + "CMSG_AUTOSTORE_LOOT_ITEM body" + ); + + // CMSG_LOOT_MONEY: empty. + assert_eq!(loot_money(), Vec::::new(), "CMSG_LOOT_MONEY body"); + + // CMSG_LOOT_RELEASE (Loot.cpp:13-16): a full guid (server ignores it). + assert_eq!( + loot_release(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_LOOT_RELEASE body" + ); + + // CMSG_LOOT_ROLL (Loot.cpp:18-23): u64 lootedTarget, u32 itemSlot, u8 rollType. + assert_eq!( + loot_roll(0x1234_5678_9abc_def0, 2, roll_vote::GREED), + hx("f0debc9a785634120200000002"), + "CMSG_LOOT_ROLL body" + ); + } + + /// The five group-roll opcode values (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`). + #[test] + fn roll_opcode_values() { + assert_eq!(opcode::SMSG_LOOT_ALL_PASSED, 670); + assert_eq!(opcode::SMSG_LOOT_ROLL_WON, 671); + assert_eq!(opcode::CMSG_LOOT_ROLL, 672); + assert_eq!(opcode::SMSG_LOOT_START_ROLL, 673); + assert_eq!(opcode::SMSG_LOOT_ROLL, 674); + } + + #[test] + fn loot_start_roll_decodes() { + // Group.cpp:759-772 / Loot.cpp:43-51: guid, itemSlot, itemEntry, randomSuffix(0), + // randomPropId, countdown. + let mut body = 0xAAu64.to_le_bytes().to_vec(); + body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot + body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffix — always 0 + body.extend_from_slice(&0u32.to_le_bytes()); // itemRandomPropId + body.extend_from_slice(&60_000u32.to_le_bytes()); // LOOT_ROLL_TIMEOUT (Group.cpp:67) + + match parse_server(opcode::SMSG_LOOT_START_ROLL, &body).unwrap() { + ServerPacket::LootStartRoll(p) => assert_eq!( + p, + LootStartRoll { + looted_target: 0xAA, + item_slot: 1, + item_id: 17182, + random_property_id: 0, + countdown_ms: 60_000, + } + ), + other => panic!("expected LootStartRoll, got {}", other.name()), + } + } + + /// The four `(roll_number, roll_type)` shapes vmangos actually emits — the overloaded pair + /// [`LootRoll::is_dice`]/[`LootRoll::vote`] disentangle. The Greed pair is the load-bearing + /// case: a Greed *vote* is `(128, 2)` and a Greed *dice roll* is `(1..=100, 2)`, identical in + /// `roll_type`. + #[test] + fn loot_roll_decodes_and_disambiguates() { + let announce = |roll_number: u8, roll_type: u8| { + let mut body = 0xAAu64.to_le_bytes().to_vec(); + body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot + body.extend_from_slice(&0xBBu64.to_le_bytes()); // rollerGuid + body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffix + body.extend_from_slice(&0u32.to_le_bytes()); // itemRandomPropId + body.push(roll_number); + body.push(roll_type); + match parse_server(opcode::SMSG_LOOT_ROLL, &body).unwrap() { + ServerPacket::LootRoll(p) => p, + other => panic!("expected LootRoll, got {}", other.name()), + } + }; + + // Field placement, once, off the Need-vote shape. + let need_vote = announce(0, 0); + assert_eq!( + need_vote, + LootRoll { + looted_target: 0xAA, + item_slot: 1, + roller: 0xBB, + item_id: 17182, + random_property_id: 0, + roll_number: 0, + roll_type: 0, + } + ); + + // The three vote announcements (Group.cpp:970-990). + assert!(!need_vote.is_dice()); + assert_eq!(need_vote.vote(), Some(roll_vote::NEED)); + + let pass_vote = announce(128, 128); + assert!(!pass_vote.is_dice()); + assert_eq!(pass_vote.vote(), Some(roll_vote::PASS)); + + let greed_vote = announce(128, roll_vote::GREED); + assert!(!greed_vote.is_dice()); + assert_eq!(greed_vote.vote(), Some(roll_vote::GREED)); + + // The dice results (Group.cpp:1163 / :1214) — urand(1, 100), so both bounds are legal. + for roll_number in [1u8, 57, 100] { + for roll_type in [roll_vote::NEED, roll_vote::GREED] { + let dice = announce(roll_number, roll_type); + assert!(dice.is_dice(), "roll {roll_number} type {roll_type}"); + assert_eq!(dice.vote(), None, "roll {roll_number} type {roll_type}"); + assert_eq!(dice.roll_type, roll_type); + } + } + } + + #[test] + fn loot_roll_won_decodes() { + // Loot.cpp:65-75 — note the winner guid lands AFTER the item block, unlike SMSG_LOOT_ROLL. + let mut body = 0xAAu64.to_le_bytes().to_vec(); + body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot + body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffix + body.extend_from_slice(&0u32.to_le_bytes()); // itemRandomPropId + body.extend_from_slice(&0xBBu64.to_le_bytes()); // winnerGuid + body.push(84); // rollNumber + body.push(roll_vote::NEED); + + match parse_server(opcode::SMSG_LOOT_ROLL_WON, &body).unwrap() { + ServerPacket::LootRollWon(p) => assert_eq!( + p, + LootRollWon { + looted_target: 0xAA, + item_slot: 1, + item_id: 17182, + random_property_id: 0, + winner: 0xBB, + roll_number: 84, + roll_type: roll_vote::NEED, + } + ), + other => panic!("expected LootRollWon, got {}", other.name()), + } + } + + #[test] + fn loot_all_passed_decodes() { + // Loot.cpp:77-84 — the SWAPPED tail: itemRandomPropId THEN randomSuffixId, the reverse of + // every sibling in this family. A non-zero prop id proves the parser follows source order. + let mut body = 0xAAu64.to_le_bytes().to_vec(); + body.extend_from_slice(&1u32.to_le_bytes()); // itemSlot + body.extend_from_slice(&17182u32.to_le_bytes()); // itemEntryId + body.extend_from_slice(&7u32.to_le_bytes()); // itemRandomPropId — read + body.extend_from_slice(&0u32.to_le_bytes()); // randomSuffixId — discarded + + match parse_server(opcode::SMSG_LOOT_ALL_PASSED, &body).unwrap() { + ServerPacket::LootAllPassed(p) => assert_eq!( + p, + LootAllPassed { + looted_target: 0xAA, + item_slot: 1, + item_id: 17182, + random_property_id: 7, + } + ), + other => panic!("expected LootAllPassed, got {}", other.name()), + } + } + + #[test] + fn loot_response_items_shape_decodes() { + // SMSG_LOOT_RESPONSE, normal shape: guid, lootType=CORPSE, gold, itemCount, 2 rows. + let mut body = 0xAAu64.to_le_bytes().to_vec(); + body.push(loot_type::CORPSE); + body.extend_from_slice(&1234u32.to_le_bytes()); // gold + body.push(2); // item count + // item 0: slot 0, entry 117, count 1, display 123, randomSuffix(0), randomPropertyId 0, ALLOW_LOOT + body.push(0); + body.extend_from_slice(&117u32.to_le_bytes()); + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&123u32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + body.push(slot_type::ALLOW_LOOT); + // item 1: slot 1, entry 6948 (Hearthstone), count 1, display 4500, randomSuffix(0), randomPropertyId 0, LOCKED + body.push(1); + body.extend_from_slice(&6948u32.to_le_bytes()); + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&4500u32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); + body.push(slot_type::LOCKED); + + match parse_server(opcode::SMSG_LOOT_RESPONSE, &body).unwrap() { + ServerPacket::LootResponse { + guid, + loot_type, + gold, + items, + } => { + assert_eq!(guid, 0xAA); + assert_eq!(loot_type, loot_type::CORPSE); + assert_eq!(gold, 1234); + assert_eq!( + items, + vec![ + LootItem { + slot: 0, + item_id: 117, + count: 1, + display_info_id: 123, + random_property_id: 0, + slot_type: slot_type::ALLOW_LOOT, + }, + LootItem { + slot: 1, + item_id: 6948, + count: 1, + display_info_id: 4500, + random_property_id: 0, + slot_type: slot_type::LOCKED, + }, + ] + ); + } + other => panic!("expected LootResponse, got {}", other.name()), + } + } + + #[test] + fn loot_response_error_shape_decodes() { + // SMSG_LOOT_RESPONSE, error shape: guid, lootType=0, error code. Nothing else follows — + // the parser must not try to read gold/itemCount off a slice this short. + let mut body = 0xBBu64.to_le_bytes().to_vec(); + body.push(0); // lootType 0 ⇒ error shape + body.push(loot_error::TOO_FAR); + + match parse_server(opcode::SMSG_LOOT_RESPONSE, &body).unwrap() { + ServerPacket::LootError { guid, error } => { + assert_eq!(guid, 0xBB); + assert_eq!(error, loot_error::TOO_FAR); + } + other => panic!("expected LootError, got {}", other.name()), + } + } + + #[test] + fn loot_release_response_wire() { + let mut body = 0xCCu64.to_le_bytes().to_vec(); + body.push(1); + match parse_server(opcode::SMSG_LOOT_RELEASE_RESPONSE, &body).unwrap() { + ServerPacket::LootReleaseResponse { guid, result } => { + assert_eq!(guid, 0xCC); + assert_eq!(result, 1); + } + other => panic!("expected LootReleaseResponse, got {}", other.name()), + } + } + + #[test] + fn loot_removed_wire() { + let body = vec![5u8]; + match parse_server(opcode::SMSG_LOOT_REMOVED, &body).unwrap() { + ServerPacket::LootRemoved { slot } => assert_eq!(slot, 5), + other => panic!("expected LootRemoved, got {}", other.name()), + } + } + + #[test] + fn loot_money_notify_wire() { + let body = 777u32.to_le_bytes().to_vec(); + match parse_server(opcode::SMSG_LOOT_MONEY_NOTIFY, &body).unwrap() { + ServerPacket::LootMoneyNotify { amount } => assert_eq!(amount, 777), + other => panic!("expected LootMoneyNotify, got {}", other.name()), + } + } + + #[test] + fn loot_clear_money_wire() { + match parse_server(opcode::SMSG_LOOT_CLEAR_MONEY, &[]).unwrap() { + ServerPacket::LootClearMoney => {} + other => panic!("expected LootClearMoney, got {}", other.name()), + } + } + + #[test] + fn item_push_result_wire() { + // SMSG_ITEM_PUSH_RESULT (Item.cpp:211-224, both build-5875 conditionals included): + // playerGuid, received, created, showInChat, bagSlot, itemSlot, itemEntry, suffixFactor, + // randomPropertyId, count. + let mut body = 0x42u64.to_le_bytes().to_vec(); + body.extend_from_slice(&0u32.to_le_bytes()); // received: looted + body.extend_from_slice(&1u32.to_le_bytes()); // created: new item + body.extend_from_slice(&1u32.to_le_bytes()); // showInChat + body.push(255); // bagSlot: player backpack + body.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // itemSlot: stacked + body.extend_from_slice(&117u32.to_le_bytes()); // itemEntry + body.extend_from_slice(&0u32.to_le_bytes()); // suffixFactor + body.extend_from_slice(&0u32.to_le_bytes()); // randomPropertyId + body.extend_from_slice(&5u32.to_le_bytes()); // count + + match parse_server(opcode::SMSG_ITEM_PUSH_RESULT, &body).unwrap() { + ServerPacket::ItemPushResult(p) => { + assert_eq!( + p, + ItemPushResult { + player_guid: 0x42, + from_npc: false, + created: true, + show_in_chat: true, + bag_slot: 255, + item_slot: 0xFFFF_FFFF, + item_entry: 117, + suffix_factor: 0, + random_property_id: 0, + count: 5, + } + ); + } + other => panic!("expected ItemPushResult, got {}", other.name()), + } + } +} diff --git a/crates/benilla-protocol/src/messages/mail.rs b/crates/benilla-protocol/src/messages/mail.rs new file mode 100644 index 00000000..4529d4f6 --- /dev/null +++ b/crates/benilla-protocol/src/messages/mail.rs @@ -0,0 +1,316 @@ +//! The mail arc's wire layer (decision 0544 phase P0): the mailbox window's send/inbox/take/ +//! item-text/arrival opcodes (vmangos `Handlers/MailHandler.cpp`, `Mail/Mail.{h,cpp}`, +//! `Server/Packets/Mail.{h,cpp}`, `Misc.{h,cpp}`, `Opcodes_1_12_1.h`, VERIFIED at decision time). +//! There is no `SMSG_SHOW_MAILBOX` on 5875 — the window open is entirely client-side (0544's +//! recorded fact); every mail CMSG below carries the mailbox guid at its head and the server +//! re-validates the 5 yd interact distance (`CheckMailBox`) on each one independently. + +use std::io; + +use crate::wire::{read_cstring, read_f32_le, read_u32_le, read_u64_le, read_u8}; + +/// `MAIL_ITEM (5)` on [`MailListEntry::message_type`] carries no sender bytes at all — the only +/// branch of the four that reads nothing before the subject cstring (VERIFIED vmangos +/// `WorldSession::HandleGetMailList`, `Handlers/MailHandler.cpp`). +pub mod mail_message_type { + /// A player sender — the sender field is a plain 8-byte guid. + pub const NORMAL: u8 = 0; + /// An auction-house notice — the sender field is a u32 auction id. + pub const AUCTION: u8 = 2; + /// A creature/NPC sender — the sender field is a u32 creature entry. + pub const CREATURE: u8 = 3; + /// A GameObject sender — the sender field is a u32 GameObject entry. + pub const GAMEOBJECT: u8 = 4; + /// A pre-made "item" mail (e.g. a GM/starter mail) — NO sender field rides the wire at all. + pub const ITEM: u8 = 5; +} + +/// `SMSG_SEND_MAIL_RESULT`'s `action` field (VERIFIED vmangos `Mail.h` `MailResponseType`). +pub mod mail_action { + pub const SEND: u32 = 0; + pub const MONEY_TAKEN: u32 = 1; + pub const ITEM_TAKEN: u32 = 2; + pub const RETURNED: u32 = 3; + pub const DELETED: u32 = 4; + pub const MADE_PERMANENT: u32 = 5; +} + +/// `SMSG_SEND_MAIL_RESULT`'s `error` field (VERIFIED vmangos `Mail.h` `MailResponseResult`). +pub mod mail_error { + pub const OK: u32 = 0; + /// The trailing `equipError` tail rides only this one. + pub const EQUIP_ERROR: u32 = 1; + pub const CANNOT_SEND_TO_SELF: u32 = 2; + pub const NOT_ENOUGH_MONEY: u32 = 3; + pub const RECIPIENT_NOT_FOUND: u32 = 4; + pub const NOT_YOUR_TEAM: u32 = 5; + pub const INTERNAL_ERROR: u32 = 6; + pub const TRIAL_ACCOUNT: u32 = 14; + pub const TOO_MANY_ATTACHMENTS: u32 = 15; +} + +/// The single attachment slot a mail can carry (`MAX_MAIL_ITEMS == 1` on 5875) — the fixed-width +/// item block that always rides `SMSG_MAIL_LIST_RESULT`'s per-record tail, zeroed when the mail +/// carries no item. `entry == 0` on the wire is the "no item" signal; [`read_mail_list_result`] +/// folds that into `None` rather than a zeroed `Some`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MailAttachment { + pub entry: u32, + pub perm_enchant: u32, + pub random_prop_id: u32, + pub suffix_factor: u32, + pub count: u8, + pub charges: u32, + pub durability_max: u32, + pub durability: u32, +} + +/// One `SMSG_MAIL_LIST_RESULT` row (VERIFIED vmangos `WorldSession::HandleGetMailList`'s +/// hand-serialization, `Handlers/MailHandler.cpp`). `sender_guid`/`sender_id` are +/// mutually exclusive branches of one wire field, keyed by `message_type` +/// ([`mail_message_type`]): a player sender is a guid, an auction/creature/GameObject sender is a +/// u32 id, and a pre-made item mail (`ITEM`) sends neither. +#[derive(Debug, Clone, PartialEq)] +pub struct MailListEntry { + pub message_id: u32, + pub message_type: u8, + /// `Some` only for [`mail_message_type::NORMAL`]. + pub sender_guid: Option, + /// `Some` for [`mail_message_type::AUCTION`]/`CREATURE`/`GAMEOBJECT`. + pub sender_id: Option, + pub subject: String, + /// `0` = no letter body to fetch; nonzero = a [`super::item_text_query`] key. + pub item_text_id: u32, + pub stationery: u32, + /// `None` iff the wire's item-block `entry` is `0`. + pub item: Option, + pub money: u32, + pub cod: u32, + /// `checked` bit masks (VERIFIED vmangos `Mail.h`): READ `0x1`, RETURNED `0x2`, COPIED `0x4`, + /// COD_PAYMENT `0x8`, HAS_BODY `0x10`. + pub checked: u32, + pub expire_days: f32, + pub mail_template_id: u32, +} + +/// Body of `CMSG_GET_MAIL_LIST` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `GetMailList::ReadFromWorldPacket`): one full 8-byte mailbox guid. Answered by +/// `SMSG_MAIL_LIST_RESULT` ([`read_mail_list_result`]). +pub fn get_mail_list(mailbox: u64) -> Vec { + mailbox.to_le_bytes().to_vec() +} + +/// Body of `CMSG_SEND_MAIL` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `SendMail::ReadFromWorldPacket`): `u64 mailbox, cstr receiver, cstr subject, cstr body, u32 +/// stationery, u32 package, u64 itemGuid, u32 money, u32 COD`, then **9 zero bytes** (a `u64 0` + +/// a `u8 0`) — the 1.12.1 tail vmangos read-skips without interpreting. vmangos *discards* the +/// client's `stationery`/`package` choice: player mail is always stored `MAIL_STATIONERY_DEFAULT` +/// (41), so both fields exist only to keep the reader aligned. +#[allow(clippy::too_many_arguments)] +pub fn send_mail( + mailbox: u64, + receiver: &str, + subject: &str, + body: &str, + stationery: u32, + package: u32, + item_guid: u64, + money: u32, + cod: u32, +) -> Vec { + let mut out = Vec::with_capacity( + 8 + receiver.len() + 1 + subject.len() + 1 + body.len() + 1 + 4 + 4 + 8 + 4 + 4 + 9, + ); + out.extend_from_slice(&mailbox.to_le_bytes()); + out.extend_from_slice(receiver.as_bytes()); + out.push(0); + out.extend_from_slice(subject.as_bytes()); + out.push(0); + out.extend_from_slice(body.as_bytes()); + out.push(0); + out.extend_from_slice(&stationery.to_le_bytes()); + out.extend_from_slice(&package.to_le_bytes()); + out.extend_from_slice(&item_guid.to_le_bytes()); + out.extend_from_slice(&money.to_le_bytes()); + out.extend_from_slice(&cod.to_le_bytes()); + // The 9-byte read-skipped tail: a u64 then a u8, both zero, both discarded server-side. + out.extend_from_slice(&0u64.to_le_bytes()); + out.push(0); + out +} + +/// The shared `u64 mailbox, u32 mailId` shape of the five take/mark/return/delete CMSGs below. +fn mailbox_and_mail_id(mailbox: u64, mail_id: u32) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&mailbox.to_le_bytes()); + body.extend_from_slice(&mail_id.to_le_bytes()); + body +} + +/// Body of `CMSG_MAIL_TAKE_MONEY`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` +/// (action [`mail_action::MONEY_TAKEN`]). +pub fn mail_take_money(mailbox: u64, mail_id: u32) -> Vec { + mailbox_and_mail_id(mailbox, mail_id) +} + +/// Body of `CMSG_MAIL_TAKE_ITEM`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` +/// (action [`mail_action::ITEM_TAKEN`], carrying the taken item's entry + count on success). +pub fn mail_take_item(mailbox: u64, mail_id: u32) -> Vec { + mailbox_and_mail_id(mailbox, mail_id) +} + +/// Body of `CMSG_MAIL_MARK_AS_READ`: `u64 mailbox, u32 mailId`. **No response packet** — the app +/// flips the local `checked` READ bit and repaints. +pub fn mail_mark_as_read(mailbox: u64, mail_id: u32) -> Vec { + mailbox_and_mail_id(mailbox, mail_id) +} + +/// Body of `CMSG_MAIL_RETURN_TO_SENDER`: `u64 mailbox, u32 mailId`. Answered by +/// `SMSG_SEND_MAIL_RESULT` (action [`mail_action::RETURNED`]). +pub fn mail_return_to_sender(mailbox: u64, mail_id: u32) -> Vec { + mailbox_and_mail_id(mailbox, mail_id) +} + +/// Body of `CMSG_MAIL_DELETE`: `u64 mailbox, u32 mailId`. Answered by `SMSG_SEND_MAIL_RESULT` +/// (action [`mail_action::DELETED`]). +pub fn mail_delete(mailbox: u64, mail_id: u32) -> Vec { + mailbox_and_mail_id(mailbox, mail_id) +} + +/// Body of `CMSG_MAIL_CREATE_TEXT_ITEM` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `MailCreateTextItem::ReadFromWorldPacket`): `u64 mailbox, u32 mailId, u32 mailTemplateId` — +/// the letter button's "permanent copy" verb; `mailTemplateId` is always `0` for player mail (no +/// mail templates in 1.12 player mail; vmangos only reads it on builds > 1.9.4). Answered by +/// `SMSG_SEND_MAIL_RESULT` (action [`mail_action::MADE_PERMANENT`]); on OK the server also stamps +/// the mail's `checked` COPIED bit — the next list re-sync flips `textCreated`. +pub fn mail_create_text_item(mailbox: u64, mail_id: u32) -> Vec { + let mut body = mailbox_and_mail_id(mailbox, mail_id); + body.extend_from_slice(&0u32.to_le_bytes()); // mailTemplateId + body +} + +/// Body of `CMSG_ITEM_TEXT_QUERY` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `ItemTextQuery::ReadFromWorldPacket`): `u32 textId, u32 mailId, u32 unk` — vmangos resolves the +/// text from `textId` alone and never reads `mailId`/`unk` back out, but the real client writes +/// all three. Answered by `SMSG_ITEM_TEXT_QUERY_RESPONSE` ([`read_item_text_query_response`]). +pub fn item_text_query(text_id: u32, mail_id: u32) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&text_id.to_le_bytes()); + body.extend_from_slice(&mail_id.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // the unk vmangos never reads back + body +} + +/// Read `SMSG_MAIL_LIST_RESULT` (VERIFIED vmangos `WorldSession::HandleGetMailList`, +/// hand-serialized in `Handlers/MailHandler.cpp`): `u8 count` (cap 254; ≤100 by business rule), then `count` +/// × [`MailListEntry`] rows. Each row: `u32 messageId, u8 messageType`, the sender branch keyed by +/// `messageType` ([`mail_message_type`]), `cstr subject, u32 itemTextId, u32 package(dropped — +/// always 0, no consumer), u32 stationery`, the fixed-width item block (ALWAYS present; `entry == +/// 0` folds to [`MailListEntry::item`] being `None`), `u32 money, u32 COD, u32 checked, f32 +/// expireDays, u32 mailTemplateId`. +pub(super) fn read_mail_list_result(r: &mut &[u8]) -> io::Result> { + let count = read_u8(r)?; + let mut mails = Vec::with_capacity(count as usize); + for _ in 0..count { + let message_id = read_u32_le(r)?; + let message_type = read_u8(r)?; + let (sender_guid, sender_id) = match message_type { + mail_message_type::NORMAL => (Some(read_u64_le(r)?), None), + mail_message_type::AUCTION + | mail_message_type::CREATURE + | mail_message_type::GAMEOBJECT => (None, Some(read_u32_le(r)?)), + // MAIL_ITEM (5) and anything else unrecognized: no sender bytes at all. + _ => (None, None), + }; + let subject = read_cstring(r)?; + let item_text_id = read_u32_le(r)?; + let _package = read_u32_le(r)?; // always 0 on 5875 (vmangos hardcodes it); no consumer + let stationery = read_u32_le(r)?; + let entry = read_u32_le(r)?; + let perm_enchant = read_u32_le(r)?; + let random_prop_id = read_u32_le(r)?; + let suffix_factor = read_u32_le(r)?; + let count_stack = read_u8(r)?; + let charges = read_u32_le(r)?; + let durability_max = read_u32_le(r)?; + let durability = read_u32_le(r)?; + let item = (entry != 0).then_some(MailAttachment { + entry, + perm_enchant, + random_prop_id, + suffix_factor, + count: count_stack, + charges, + durability_max, + durability, + }); + let money = read_u32_le(r)?; + let cod = read_u32_le(r)?; + let checked = read_u32_le(r)?; + let expire_days = read_f32_le(r)?; + let mail_template_id = read_u32_le(r)?; + mails.push(MailListEntry { + message_id, + message_type, + sender_guid, + sender_id, + subject, + item_text_id, + stationery, + item, + money, + cod, + checked, + expire_days, + mail_template_id, + }); + } + Ok(mails) +} + +/// Read `SMSG_SEND_MAIL_RESULT` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `SendMailResult::AppendBodyTo`): `u32 mailId, u32 action ([`mail_action`]), u32 error +/// ([`mail_error`])`, then a conditional tail — `u32 equipError` iff `error == +/// `[`mail_error::EQUIP_ERROR`]`, else `u32 itemEntry, u32 itemCount` iff `action == +/// `[`mail_action::ITEM_TAKEN`]` and `error == `[`mail_error::OK`]``. Neither tail rides any other +/// action/error combination; the remaining-length guard mirrors how the codec's other +/// conditional tails (e.g. `SMSG_TRANSFER_PENDING`'s transport block) stay robust to a body that +/// doesn't carry the tail its fields would otherwise predict. +#[allow(clippy::type_complexity)] +pub(super) fn read_send_mail_result( + r: &mut &[u8], +) -> io::Result<(u32, u32, u32, Option, Option<(u32, u32)>)> { + let mail_id = read_u32_le(r)?; + let action = read_u32_le(r)?; + let error = read_u32_le(r)?; + let mut equip_error = None; + let mut item = None; + if error == mail_error::EQUIP_ERROR && !r.is_empty() { + equip_error = Some(read_u32_le(r)?); + } else if action == mail_action::ITEM_TAKEN && error == mail_error::OK && !r.is_empty() { + item = Some((read_u32_le(r)?, read_u32_le(r)?)); + } + Ok((mail_id, action, error, equip_error, item)) +} + +/// Read `SMSG_ITEM_TEXT_QUERY_RESPONSE` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `ItemTextQueryResponse::AppendBodyTo`): `u32 textId, cstr text` — the letter body's ask-once +/// fetch, answering [`item_text_query`]. +pub(super) fn read_item_text_query_response(r: &mut &[u8]) -> io::Result<(u32, String)> { + Ok((read_u32_le(r)?, read_cstring(r)?)) +} + +/// Read `SMSG_RECEIVED_MAIL` (VERIFIED vmangos `Server/Packets/Mail.cpp`, +/// `ReceivedMail::AppendBodyTo`): one `u32`, always `0` on vmangos — kept for fidelity though no +/// consumer reads it back. Sent when a mail *arrives* (instant for text-only, on the delivery +/// timer's expiry otherwise). +pub(super) fn read_received_mail(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// Read `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode both directions — our request is an EMPTY +/// body, [`super::opcode::MSG_QUERY_NEXT_MAIL_TIME`]): one `f32` — `0.0` = unread mail waiting, +/// `-86400.0` = none. +pub(super) fn read_query_next_mail_time(r: &mut &[u8]) -> io::Result { + read_f32_le(r) +} diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs new file mode 100644 index 00000000..4cfcaa26 --- /dev/null +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -0,0 +1,181 @@ +//! The world (`mangosd`) message layer — in-repo replacement for `wow_world_messages` (decision +//! 0021), scoped to the opcodes benilla sends and receives. +//! +//! World packets are framed by a header-encrypted size+opcode (see [`crate::world`]); this module owns +//! only the *bodies*: parsing the server packets benilla decodes into [`ServerPacket`], and building +//! the client packet bodies benilla sends. The one genuinely complex packet is `SMSG_UPDATE_OBJECT` +//! (and its zlib twin) — an object list where each entry carries a [`MovementBlock`]-shaped position +//! and a sparse [`ObjectFields`] of descriptor fields; its decode lives in [`update_object`], and +//! [`crate::events`] pulls out the handful of fields the renderer uses. +//! The [`ServerPacket`] enum itself lives in `packet`; the opcode→variant dispatch +//! ([`parse_server`]) in `parse`. +//! +//! Proven byte-for-byte against `wow_world_messages` during the decision-0021 migration (oracle test +//! in git history); ongoing coverage is the oracle-free golden/fixture tests in `tests/*.rs` (split by +//! domain — client, movement, items, spells, update_object, simple_packets — sharing fixtures via +//! `tests/common`). + +mod bank; +mod channel; +mod chat; +mod client; +mod death; +mod gameobject; +mod gossip; +mod group; +mod items; +mod loot; +mod mail; +mod monster_move; +mod movement; +pub mod opcode; +mod opcode_names; +mod packet; +mod parse; +mod quest; +mod roster; +mod skills; +mod spells; +mod taxi; +mod trade; +mod trainer; +mod update_object; +mod vendor; +pub use bank::{ + autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot, +}; +pub use channel::{channel_notice, ChannelNoticeTail, ChannelNotify}; +pub use chat::{ + chat_tag, ChatMessage, CHAT_MSG_AFK, CHAT_MSG_BATTLEGROUND, CHAT_MSG_BATTLEGROUND_LEADER, + CHAT_MSG_CHANNEL, CHAT_MSG_DND, CHAT_MSG_EMOTE, CHAT_MSG_GUILD, CHAT_MSG_IGNORED, + CHAT_MSG_MONSTER_EMOTE, CHAT_MSG_MONSTER_SAY, CHAT_MSG_MONSTER_WHISPER, CHAT_MSG_MONSTER_YELL, + CHAT_MSG_OFFICER, CHAT_MSG_PARTY, CHAT_MSG_RAID, CHAT_MSG_RAID_BOSS_EMOTE, + CHAT_MSG_RAID_BOSS_WHISPER, CHAT_MSG_RAID_LEADER, CHAT_MSG_RAID_WARNING, CHAT_MSG_SAY, + CHAT_MSG_SYSTEM, CHAT_MSG_WHISPER, CHAT_MSG_WHISPER_INFORM, CHAT_MSG_YELL, +}; +pub use client::{ + auth_session, channel_announcements, channel_ban, channel_invite, channel_kick, channel_list, + channel_moderate, channel_moderator, channel_mute, channel_owner, channel_password, + channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create, + creature_query, force_speed_ack, full_guid, join_channel, leave_channel, messagechat, + messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack, move_spline_done, + movement, ping, played_time, random_roll, stand_state_change, teleport_ack, text_emote, +}; +pub use death::{ + reclaim_corpse, resurrect_response, spirit_healer_activate, CorpseLocation, + ResurrectRequestBody, +}; +pub use gameobject::{gameobj_use, gameobject_query, GameObjectQueryInfo}; +pub use gossip::{gossip_hello, gossip_select_option, npc_text_query, GossipOption, QuestOption}; +pub use group::{ + group_accept, group_assistant_leader, group_change_sub_group, group_decline, group_disband, + group_invite, group_raid_convert, group_set_leader, group_swap_sub_group, group_uninvite, + group_uninvite_guid, loot_method, member_status, minimap_ping, party_member_mask, + party_operation, party_result, raid_target_request, raid_target_set, ready_check_answer, + ready_check_start, request_party_member_stats, GroupLootInfo, GroupMemberEntry, + PartyMemberStatsInfo, RaidTargetUpdate, ReadyCheck, GROUP_MEMBER_ASSISTANT, +}; +pub use items::{ + auto_equip_item, auto_store_bag_item, destroy_item, item_query, set_ammo, split_item, + swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell, + BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST, SLOT_PACK_FIRST, +}; +pub use loot::{ + autostore_loot_item, loot, loot_error, loot_money, loot_release, loot_roll, loot_type, + roll_vote, slot_type, ItemPushResult, LootAllPassed, LootItem, LootResponseBody, LootRoll, + LootRollWon, LootStartRoll, +}; +pub use mail::{ + get_mail_list, item_text_query, mail_action, mail_create_text_item, mail_delete, mail_error, + mail_mark_as_read, mail_message_type, mail_return_to_sender, mail_take_item, mail_take_money, + send_mail, MailAttachment, MailListEntry, +}; +pub use movement::{JumpInfo, MovementInfo, SpeedKind, TransportPose}; +pub use opcode_names::opcode_name; +pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket}; +pub use parse::parse_server; +pub use quest::{ + dialog_status, quest_query, questgiver_accept_quest, questgiver_choose_reward, + questgiver_complete_quest, questgiver_hello, questgiver_query_quest, questgiver_request_reward, + questgiver_status_query, questlog_remove_quest, questlog_swap_quest, QuestComplete, + QuestDetails, QuestGiverList, QuestListEntry, QuestObjective, QuestOfferReward, + QuestRequestItems, QuestRequiredItem, QuestRewardItem, QuestTemplate, QUEST_EMOTE_COUNT, + QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, +}; +pub use roster::{ + CharCreateReq, CharEnumItem, Character, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, + CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS, + CHAR_DELETE_SUCCESS, CLASS_WARRIOR, GENDER_MALE, RACE_HUMAN, +}; +pub use skills::unlearn_skill; +pub use spells::{ + attack_swing, cancel_aura, cast_spell, cast_spell_gameobject, cast_spell_item, learn_talent, + set_action_button, set_sheathed, ActionButton, AttackerState, CastOutcome, DamageShield, + EnvironmentalDamageLog, LevelUpInfo, PeriodicAuraLog, PeriodicTick, SpellCastTargets, + SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, + SpellStart, XpGain, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL, +}; +pub use taxi::{ + activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes, + taxi_reply, TaxiMask, +}; +pub use trade::{ + accept_trade, clear_trade_item, initiate_trade, set_trade_gold, set_trade_item, TradeItem, + TradeStatus, TradeStatusExtended, TRADE_SLOT_COUNT, TRADE_SLOT_NONTRADED, + TRADE_SLOT_TRADED_COUNT, +}; +pub use trainer::{train_fail, trainer_buy_spell, trainer_list, trainer_spell_state, TrainerSpell}; +pub use update_object::{ + quest_slot_state, MovementBlock, Object, ObjectFields, ObjectType, PlayerSkillSlot, + QuestLogSlot, UnitAuraSlot, AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, + PLAYER_EXPLORED_ZONES_SLOTS, PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, + UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, +}; +pub use vendor::{ + buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result, + VendorItem, +}; + +/// `SMSG_AUTH_RESPONSE` AuthOk result. +pub const AUTH_OK: u8 = 0x0C; +/// `LogoutResult::Success` (`SMSG_LOGOUT_RESPONSE`). +pub const LOGOUT_SUCCESS: u32 = 0x0; +/// Chat `Language` wire ids (VERIFIED vmangos `SharedDefines.h:256-261`): the faction tongues. +/// `Universal` (0) is server-reserved — vmangos rejects it from clients for ordinary chat. +pub const LANGUAGE_COMMON: u32 = 0x7; +pub const LANGUAGE_ORCISH: u32 = 0x1; + +/// The language a character speaks by default — its faction tongue. Every send must carry a +/// language the character *knows*: vmangos drops the whole message (including a `.command` +/// payload, which is intercepted downstream of the check) with a "not learned" notification +/// otherwise (`HandleChatMessageOpcode`'s `KnowsLanguage` gate, `Handlers/ChatHandler.cpp`). +/// Race → tongue VERIFIED against the live world DB (`playercreateinfo_spell`): races 1/3/4/7 +/// (Alliance) learn spell 668 Language Common, races 2/5/6/8 (Horde) learn 669 Language Orcish. +pub fn faction_language(race: u8) -> u32 { + match race { + 2 | 5 | 6 | 8 => LANGUAGE_ORCISH, // orc, undead, tauren, troll + _ => LANGUAGE_COMMON, // human, dwarf, night elf, gnome + } +} +/// `ChatMsg` wire values benilla sends, widened to `u32` (VERIFIED vmangos `SharedDefines.h: +/// 1191-1301`) — `CMSG_MESSAGECHAT`'s `type` field is a `u32` on the wire +/// (`WorldPackets::Chat::ChatMessage::type`, `Server/Packets/Chat.h:12`), unlike the inbound +/// `SMSG_MESSAGECHAT` decode's `u8` (hence the separate, narrower [`CHAT_MSG_SAY`] etc. set there): +/// two constant sets for the same enum because the two wire fields are different widths. +pub const CHAT_TYPE_SAY: u32 = 0x0; +pub const CHAT_TYPE_PARTY: u32 = 0x1; +pub const CHAT_TYPE_RAID: u32 = 0x2; +pub const CHAT_TYPE_GUILD: u32 = 0x3; +pub const CHAT_TYPE_OFFICER: u32 = 0x4; +pub const CHAT_TYPE_YELL: u32 = 0x5; +pub const CHAT_TYPE_WHISPER: u32 = 0x6; +pub const CHAT_TYPE_EMOTE: u32 = 0x8; +pub const CHAT_TYPE_CHANNEL: u32 = 0xE; +pub const CHAT_TYPE_AFK: u32 = 0x14; +pub const CHAT_TYPE_DND: u32 = 0x15; +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_10_2` — active for 5875. +pub const CHAT_TYPE_RAID_LEADER: u32 = 0x57; +pub const CHAT_TYPE_RAID_WARNING: u32 = 0x58; +/// `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2` — active for 5875. +pub const CHAT_TYPE_BATTLEGROUND: u32 = 0x5C; +pub const CHAT_TYPE_BATTLEGROUND_LEADER: u32 = 0x5D; diff --git a/crates/benilla-protocol/src/messages/monster_move.rs b/crates/benilla-protocol/src/messages/monster_move.rs new file mode 100644 index 00000000..a34ffc24 --- /dev/null +++ b/crates/benilla-protocol/src/messages/monster_move.rs @@ -0,0 +1,299 @@ +//! `SMSG_MONSTER_MOVE` — the server-dictated creature movement path. +//! +//! One packet: a mover, its `start`, a spline id, a `moveType`-switched final facing, then (unless it is +//! a `Stop`) the spline block — a flag word, a duration, and the waypoints. Decodes into the full +//! travel-order polyline [`ServerPacket::MonsterMove::path`] the app rides at constant (arc-length) speed +//! (decision 0097). The wire packs the waypoints two ways, keyed by the `Flying`/`Mask_CatmullRom` flag — +//! see [`read_monster_move_spline`]. + +use std::io; + +use crate::messages::{MonsterMoveFacing, ServerPacket}; +use crate::wire::{ + packed_to_vector3d, read_f32_le, read_i32_le, read_packed_guid, read_u32_le, read_u64_le, + read_u8, Vector3d, +}; + +/// `MonsterMoveType::Stop` (`SMSG_MONSTER_MOVE`). +const MONSTER_MOVE_STOP: u8 = 0x1; +/// `SplineFlags::Flying` in the `SMSG_MONSTER_MOVE` spline-flags word. When set, the unit follows the +/// path's **own Z** (a 3-D flight path, points sent explicit); when clear, the path is a *ground* walk +/// and the real client **discards the spline Z and re-derives it from the terrain** under the unit (the +/// swept-collision down-probe — byte-verified in wow-5875-re: the grounded fork zeroes the spline's +/// Z-delta at `0x616cb0` and the WALK resolver `0x634040 → 0x6367b0` reads Z off the world trace +/// `0x6721b0`). benilla mirrors that split: a non-flying spline is terrain-clamped, a flying one keeps +/// its Z. The client gates on this same bit (`0x6018f0: test ah,0x2`). It is also `Mask_CatmullRom` — +/// flying paths interpolate (and wire-encode) as a curve, ground paths as straight segments. +const SPLINE_FLAG_FLYING: u32 = 0x200; + +/// Parse an `SMSG_MONSTER_MOVE` body into [`ServerPacket::MonsterMove`]. The head (mover, `start`, spline +/// id, `moveType` + its final facing) is always present; a **stop** (`moveType 1`) ends there (the client +/// reads no flags/duration/points and implies `flags=0x100, count=1, duration=0` — wow-re RF-0049; +/// reading a tail anyway over-ran the body, the `0x00dd: failed to fill whole buffer` skip that dropped +/// every creature stop). Otherwise the spline block yields the full polyline `[start, …waypoints…, +/// endpoint]`. +pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result { + let guid = read_packed_guid(r)?; + let start = Vector3d::read(r)?; + // The server's per-move spline counter. Discarded for a creature (nothing acks its walk), but + // the client echoes it back in `CMSG_MOVE_SPLINE_DONE` when a spline drives its OWN player + // (Charge/knockback/taxi): the server validates the ack against the newest spline id. + let spline_id = read_u32_le(r)?; + let move_type = read_u8(r)?; + // The `moveType`-switched final facing (jumptable `0x602114`): 2 = spot, 3 = target guid, 4 = angle. + // The real client snaps the unit to it (`0x7c6f30`); benilla applies it in the net bridge. A plain + // move (0) / stop (1) dictates no facing. + let facing = match move_type { + 2 => { + let spot = Vector3d::read(r)?; + MonsterMoveFacing::Spot([spot.x, spot.y, spot.z]) + } + 3 => MonsterMoveFacing::Target(read_u64_le(r)?), + 4 => MonsterMoveFacing::Angle(read_f32_le(r)?), + _ => MonsterMoveFacing::None, + }; + Ok(if move_type == MONSTER_MOVE_STOP { + ServerPacket::MonsterMove { + guid, + start, + spline_id, + path: Vec::new(), + facing, + stop: true, + duration_ms: 0, + flying: false, + } + } else { + let spline_flags = read_u32_le(r)?; + let duration_ms = read_u32_le(r)?; + let flying = spline_flags & SPLINE_FLAG_FLYING != 0; + // The decoded waypoints *after* the start (see [`read_monster_move_spline`]): a ground path's + // first packed point re-encodes `start` (quantized), so drop it and anchor the path with the + // exact wire `start`; a flying path sends only post-start points, kept whole. The result is the + // full travel-order polyline `[start, …waypoints…, endpoint]`. + let tail = read_monster_move_spline(r, flying)?; + let path = if tail.is_empty() { + Vec::new() + } else { + let skip = usize::from(!flying && tail.len() >= 2); + std::iter::once(start) + .chain(tail.into_iter().skip(skip)) + .collect() + }; + ServerPacket::MonsterMove { + guid, + start, + spline_id, + path, + facing, + stop: false, + duration_ms, + flying, + } + }) +} + +/// `SMSG_MONSTER_MOVE` spline points → the reconstructed **absolute** waypoints the block carries, in +/// travel order (the point *after* the start … the endpoint). Two wire layouts, keyed by `catmull_rom` +/// (the `Mask_CatmullRom` = `Flying` spline flag), both from vmangos `PacketBuilder`: +/// +/// - **Ground (linear, `WriteLinearPath`):** a `u32` count, then the endpoint as an absolute `Vector3d`, +/// then `count − 1` packed `i32` offsets — `endpoint − waypoint` per point (see [`packed_to_vector3d`]), +/// in travel order. We invert each (`waypoint = endpoint − offset`) and append the endpoint, so the +/// returned list is `[waypoint₀ ≈ start, waypoint₁, …, endpoint]`. `waypoint₀` re-encodes the packet's +/// `start` (¼-yd quantized); the caller drops it in favour of the exact `start`. +/// - **Flying (Catmull-Rom, `WriteCatmullRomPath`):** a `u32` count, then `count` **absolute** `Vector3d`s +/// (the control points from `getPoint(2)` on — the post-start waypoints through the endpoint). Returned +/// verbatim. (Reading these as packed offsets — the old single-layout path — mis-sized the body and +/// dropped every flight move.) +/// +/// The client interpolates a ground path **piecewise-linearly, arc-length-parameterised** through all of +/// these (wow-re curvemath RF-0048/RF-0052: the creature follow's point-at-t is `linear_pos_diff`, not the +/// Catmull-Rom blend — only flying units take the curved family). +fn read_monster_move_spline(r: &mut &[u8], catmull_rom: bool) -> io::Result> { + let count = read_u32_le(r)?; + // Cap the *pre-allocation* (not the read) at a sane bound — a corrupt `count` must not reserve GBs; + // the read itself still errors the instant the (bounded) body underruns. + let cap = count.min(0xFFFF) as usize; + if catmull_rom { + // Absolute control points, verbatim. + let mut points = Vec::with_capacity(cap); + for _ in 0..count { + points.push(Vector3d::read(r)?); + } + return Ok(points); + } + // Endpoint (absolute) + packed offsets from it; invert to absolute, endpoint last. + if count == 0 { + return Ok(Vec::new()); + } + let endpoint = Vector3d::read(r)?; + let mut points = Vec::with_capacity(cap); + for _ in 1..count { + let off = packed_to_vector3d(read_i32_le(r)?); + points.push(Vector3d { + x: endpoint.x - off.x, + y: endpoint.y - off.y, + z: endpoint.z - off.z, + }); + } + points.push(endpoint); + Ok(points) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::messages::{opcode, parse_server}; + use crate::wire::write_packed_guid; + + /// The fixed head of a `SMSG_MONSTER_MOVE`: packed guid, start pos, splineId, moveType. + fn head(guid: u64, start: [f32; 3], move_type: u8) -> Vec { + let mut b = Vec::new(); + write_packed_guid(guid, &mut b).unwrap(); + for f in start { + b.extend_from_slice(&f.to_le_bytes()); + } + b.extend_from_slice(&7u32.to_le_bytes()); // splineId + b.push(move_type); + b + } + + /// The producer's linear encoder, transcribed from vmangos `PacketBuilder::WriteLinearPath`: + /// count = number of points, the **endpoint** written absolute, then each earlier point as a packed + /// `endpoint − point` offset in travel order. `path` is the full `[start, …mids…, endpoint]`. + fn append_linear_path(body: &mut Vec, path: &[[f32; 3]]) { + let (&endpoint, leading) = path.split_last().expect("a path has an endpoint"); + body.extend_from_slice(&(path.len() as u32).to_le_bytes()); + for f in endpoint { + body.extend_from_slice(&f.to_le_bytes()); + } + // `leading` = [start, …mids…]; each packed as endpoint − point (¼-yd quantized). + for &p in leading { + let pack = |v: f32, shift: u32, mask: i32| ((v * 4.0).round() as i32 & mask) << shift; + let off = [endpoint[0] - p[0], endpoint[1] - p[1], endpoint[2] - p[2]]; + let packed = pack(off[0], 0, 0x7FF) | pack(off[1], 11, 0x7FF) | pack(off[2], 22, 0x3FF); + body.extend_from_slice(&packed.to_le_bytes()); + } + } + + #[test] + fn monster_move_stop_has_no_tail() { + // A STOP (moveType 1) is head-only: no flags/duration/points. Parsing the tail anyway + // over-ran the body and skipped the packet (`0x00dd: failed to fill whole buffer`). + let body = head(0x1234, [1.0, 2.0, 3.0], MONSTER_MOVE_STOP); + let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a stop parses head-only"); + match p { + ServerPacket::MonsterMove { stop, path, .. } => { + assert!(stop, "moveType 1 is a stop"); + assert!(path.is_empty(), "a stop carries no path"); + } + _ => panic!("expected MonsterMove"), + } + } + + #[test] + fn monster_move_facing_angle_is_captured() { + // A facing-angle move (moveType 4): head, angle, then flags/duration/count + one point. + let mut body = head(0x55, [0.0, 0.0, 0.0], 4); + body.extend_from_slice(&1.25f32.to_le_bytes()); // facing angle + body.extend_from_slice(&0u32.to_le_bytes()); // spline flags (ground) + body.extend_from_slice(&500u32.to_le_bytes()); // duration + body.extend_from_slice(&1u32.to_le_bytes()); // count = 1 → endpoint only, no packed + for f in [10.0f32, 0.0, 0.0] { + body.extend_from_slice(&f.to_le_bytes()); // the single (absolute) endpoint + } + let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a facing move parses"); + match p { + ServerPacket::MonsterMove { + facing, + path, + duration_ms, + stop, + .. + } => { + assert!(!stop); + assert_eq!(duration_ms, 500); + // A count-1 (endpoint-only) move is the two-point straight hop `[start, endpoint]`. + assert_eq!(path.len(), 2, "start + endpoint"); + assert!((path[0].x - 0.0).abs() < 1e-6, "anchored at the wire start"); + assert!((path[1].x - 10.0).abs() < 1e-6, "endpoint verbatim"); + match facing { + MonsterMoveFacing::Angle(a) => assert!((a - 1.25).abs() < 1e-6), + other => panic!("expected an Angle facing, got {other:?}"), + } + } + _ => panic!("expected MonsterMove"), + } + } + + #[test] + fn monster_move_ground_path_decodes_every_waypoint() { + // A four-waypoint ground patrol, encoded exactly as vmangos `WriteLinearPath` ships it, must + // round-trip to the full travel-order polyline — not collapse to `start → endpoint`. Points are + // ¼-yd multiples so the packed quantization is exact. The start is anchored from the wire `start` + // (the redundant quantized first packed point is dropped). + let want = [ + [0.0f32, 0.0, 0.0], // start + [10.0, 0.0, 0.0], // corner east + [10.0, 10.0, 0.0], // corner north + [10.0, 10.0, 5.0], // endpoint, up a step + ]; + let mut body = head(0xABCD, want[0], 0); // moveType 0 (normal) + body.extend_from_slice(&0u32.to_le_bytes()); // spline flags (ground/linear) + body.extend_from_slice(&4000u32.to_le_bytes()); // duration + append_linear_path(&mut body, &want); + let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a ground path parses"); + match p { + ServerPacket::MonsterMove { + path, flying, stop, .. + } => { + assert!(!stop && !flying, "a normal ground move"); + assert_eq!( + path.len(), + 4, + "all four waypoints survive, not just the endpoint" + ); + for (got, exp) in path.iter().zip(want.iter()) { + assert!( + (got.x - exp[0]).abs() < 1e-4 + && (got.y - exp[1]).abs() < 1e-4 + && (got.z - exp[2]).abs() < 1e-4, + "waypoint {got:?} != {exp:?}" + ); + } + } + _ => panic!("expected MonsterMove"), + } + } + + #[test] + fn monster_move_flying_path_reads_absolute_points() { + // A flying (`Mask_CatmullRom`) move ships its waypoints ABSOLUTE (vmangos `WriteCatmullRomPath`), + // not as packed offsets. The parser must switch layouts on the flag — the old single-layout read + // mis-sized the body and dropped every flight. `count` = post-start points; the start prepends. + let mids = [[3.0f32, 4.0, 50.0], [6.0, 8.0, 55.0]]; + let mut body = head(0x77, [0.0, 0.0, 40.0], 0); + body.extend_from_slice(&SPLINE_FLAG_FLYING.to_le_bytes()); // flying ⇒ catmull-rom layout + body.extend_from_slice(&3000u32.to_le_bytes()); // duration + body.extend_from_slice(&(mids.len() as u32).to_le_bytes()); // count = absolute points + for pt in mids { + for f in pt { + body.extend_from_slice(&f.to_le_bytes()); + } + } + let p = parse_server(opcode::SMSG_MONSTER_MOVE, &body).expect("a flying path parses"); + match p { + ServerPacket::MonsterMove { path, flying, .. } => { + assert!(flying, "the FLYING flag drives the catmull-rom layout"); + assert_eq!(path.len(), 3, "start + two absolute waypoints"); + assert!( + (path[0].z - 40.0).abs() < 1e-6, + "anchored at the wire start (Z kept — flying)" + ); + assert!((path[2].x - 6.0).abs() < 1e-6 && (path[2].z - 55.0).abs() < 1e-6); + } + _ => panic!("expected MonsterMove"), + } + } +} diff --git a/crates/benilla-protocol/src/messages/movement.rs b/crates/benilla-protocol/src/messages/movement.rs new file mode 100644 index 00000000..547bf003 --- /dev/null +++ b/crates/benilla-protocol/src/messages/movement.rs @@ -0,0 +1,326 @@ +//! The shared movement vocabulary: the `MovementInfo` body (+ its `JumpInfo` ballistic tail and +//! `TransportPose` rider tail) every `MSG_MOVE_*` relay carries, its flag bits, and the parse — read by +//! the update-object living block and [`super`]'s relay arms, written by [`super::client`]'s outbound +//! movement. + +use std::io::{self, Read}; + +use crate::wire::{read_f32_le, read_u32_le, read_u64_le, Vector3d}; + +// MovementFlags bits, shared by the living [`MovementBlock`](super::MovementBlock) (in +// `update_object`) and the client [`MovementInfo`] parse ([`read_movement_info`]) below. +// ON_TRANSPORT is 1.12's bit 25 (vmangos `MovementInfo.h:56` `MOVEFLAG_ONTRANSPORT = 0x02000000`) +// — NOT the TBC-era 0x200, which in vanilla is `MOVEFLAG_UNUSED10`. The original transcription +// carried 0x200; caught 2026-07-17 against the vmangos source when riding (0438 phase 2) landed. +pub(super) const MOVEMENT_FLAG_ON_TRANSPORT: u32 = 0x0200_0000; +pub(super) const MOVEMENT_FLAG_JUMPING: u32 = 0x2000; +pub(super) const MOVEMENT_FLAG_SWIMMING: u32 = 0x20_0000; +pub(super) const MOVEMENT_FLAG_SPLINE_ENABLED: u32 = 0x40_0000; +pub(super) const MOVEMENT_FLAG_SPLINE_ELEVATION: u32 = 0x400_0000; + +/// Read a wire `MovementInfo` — the body shared by every `MSG_MOVE_*` (and the teleport ack). Surfaces +/// `flags`/`position`/`orientation`/`timestamp`/`fall_time`, the **transport pose** ([`TransportPose`], +/// present iff `MOVEFLAG_ON_TRANSPORT` — this is how a boarded rider's `MSG_MOVE_*` heartbeat carries +/// its local frame, decision 0438 "Riding is the mover's platform frame"), the **swim pitch** +/// ([`Self::pitch`], present iff `MOVEFLAG_SWIMMING`), and the **jump tail** ([`JumpInfo`], so an +/// observer can replay a jump arc); the spline-elevation tail is parsed to stay aligned but discarded. +/// +/// VERIFIED byte-for-byte against vmangos `MovementInfo::Read` (build 1.12.1): note 1.12 has **no** +/// transport-time field after the transport pose — that was added in TBC. (The `update_object` +/// movement-block transport read, proven against `wow_world_messages`, omits it for the same reason.) +/// The **swim pitch** is a lone `f32` after the (optional) transport pose and before `fallTime`, gated on +/// `MOVEFLAG_SWIMMING`. `fallTime` is a `u32` and the jump tail (gated on `MOVEFLAG_JUMPING`) is `zspeed, +/// cosAngle, sinAngle, xyspeed`, after `fallTime` and before the spline-elevation float. +pub(super) fn read_movement_info(r: &mut impl Read) -> io::Result { + let flags = read_u32_le(r)?; + let timestamp = read_u32_le(r)?; + let position = Vector3d::read(r)?; + let orientation = read_f32_le(r)?; + let transport = if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { + Some(TransportPose { + // A FULL u64, not packed: vmangos `data >> t_guid` resolves to the plain + // `ObjectGuid` operator (`ObjectGuid.cpp:180`, `buf.read()`); the packed + // reader is a distinct operator this path never uses. + guid: read_u64_le(r)?, + pos: Vector3d::read(r)?, + orientation: read_f32_le(r)?, + }) + } else { + None + }; + let pitch = if flags & MOVEMENT_FLAG_SWIMMING != 0 { + read_f32_le(r)? + } else { + 0.0 + }; + let fall_time = read_u32_le(r)?; + let jump = if flags & MOVEMENT_FLAG_JUMPING != 0 { + Some(JumpInfo { + zspeed: read_f32_le(r)?, + cos_angle: read_f32_le(r)?, + sin_angle: read_f32_le(r)?, + xy_speed: read_f32_le(r)?, + }) + } else { + None + }; + if flags & MOVEMENT_FLAG_SPLINE_ELEVATION != 0 { + let _ = read_f32_le(r)?; + } + Ok(MovementInfo { + flags, + timestamp, + position, + orientation, + transport, + pitch, + fall_time, + jump, + }) +} + +// --- shared movement vocabulary (read by the arms above, written by `client`) --------------------- + +/// Which of a mover's six movement speeds a force-speed-change packet addresses — the wire family's +/// shared axis (each kind is its own SMSG/CMSG opcode pair; see [`super::opcode`]'s force-speed +/// block). Order matches the `LIVING` block's speed array and vmangos `UnitMoveType`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpeedKind { + Walk, + Run, + RunBack, + Swim, + SwimBack, + TurnRate, +} + +impl SpeedKind { + /// The `CMSG_FORCE_*_SPEED_CHANGE_ACK` opcode answering this kind's SMSG. + pub fn ack_opcode(self) -> u16 { + use super::opcode; + match self { + SpeedKind::Walk => opcode::CMSG_FORCE_WALK_SPEED_CHANGE_ACK, + SpeedKind::Run => opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK, + SpeedKind::RunBack => opcode::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK, + SpeedKind::Swim => opcode::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK, + SpeedKind::SwimBack => opcode::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK, + SpeedKind::TurnRate => opcode::CMSG_FORCE_TURN_RATE_CHANGE_ACK, + } + } +} + +/// One jump's ballistic launch parameters — the conditional `MovementInfo` tail present iff the +/// `MOVEFLAG_JUMPING` (0x2000) flag is set. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` +/// (build 1.12.1): wire order is `zspeed, cosAngle, sinAngle, xyspeed` (note cos *before* sin). The +/// horizontal launch velocity is `(cos_angle, sin_angle) · xy_speed` in **world XY** (absolute, frozen +/// at take-off) and `zspeed` is the constant take-off vertical speed (+Z up); the resulting arc is +/// `pos += horiz·t`, `z += zspeed·t − ½·g·t²` (the same `g = 19.291105` the controller uses). This is +/// what lets a receiver replay a jump as one ballistic event between packets rather than snapping its +/// height at the packet rate (decision 0053). Corroborated by vmangos `Unit.cpp` `ExtrapolateMovement`, +/// which integrates exactly this arc from the same fields. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct JumpInfo { + /// Take-off vertical speed (yd/s), constant for the whole arc, **down-positive**: the real 1.12.1 + /// client sends `-7.955547` for a *rising* jump (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`). + /// The receiver's up-speed is therefore `-zspeed` (decision 0054). + pub zspeed: f32, + /// Cosine of the horizontal launch heading (the X component of the unit launch direction, world XY). + pub cos_angle: f32, + /// Sine of the horizontal launch heading (the Y component, world XY). + pub sin_angle: f32, + /// Horizontal launch speed (yd/s); the frozen ground speed at take-off. + pub xy_speed: f32, +} + +/// A rider's pose **relative to the transport it's on** — the `MOVEFLAG_ON_TRANSPORT` (0x0200_0000) +/// conditional tail carried by both a `LIVING` update-object movement block (`MovementBlock::transport`, +/// in `update_object`) and a `MSG_MOVE_*` relay's `MovementInfo` ([`Self::transport`]): `[u64 +/// transport guid][local x, y, z][local o]`. VERIFIED byte-for-byte against vmangos `MovementInfo::Read` +/// (build 1.12.1) — the guid is a FULL u64 (never packed here), and 1.12 carries **no** +/// transport-time field after this (added TBC). +/// `pos`/`orientation` are in the transport's own local frame (decision 0438 "Riding is the mover's +/// platform frame"): a consumer composes `world = transport_matrix × local` off the named transport's +/// live pose each frame, never treats these as raw world coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransportPose { + /// The transport GameObject's guid (`HIGH_MO_TRANSPORT` or `HIGH_TRANSPORT` — [`crate::guid`]). + pub guid: u64, + pub pos: Vector3d, + pub orientation: f32, +} + +/// A wire `MovementInfo` — the body shared by every `MSG_MOVE_*` (both directions) and the teleport +/// ack. benilla sets the base directional/turn/walk flags, `JUMPING` while airborne, `SWIMMING` +/// while in the water, and `ON_TRANSPORT` (+ the [`TransportPose`] tail) while riding a boat/zepp +/// (decision 0438 phase 2). The transport, swim-pitch, and jump tails are conditional +/// outbound, gated on their flags. Inbound, every conditional tail is parsed (see +/// [`read_movement_info`]) — the transport pose into [`Self::transport`], the swim pitch into +/// [`Self::pitch`], the jump tail into [`Self::jump`], and the spline-elevation float to stay aligned +/// only (no consumer needs it). +pub struct MovementInfo { + pub flags: u32, + pub timestamp: u32, + pub position: Vector3d, + pub orientation: f32, + /// The sender's pose on a transport — `Some` iff `flags & MOVEMENT_FLAG_ON_TRANSPORT`, carried + /// right after `orientation` and before the swim-pitch tail (VERIFIED order vs vmangos + /// `MovementInfo::Read`). This is how another rider's `MSG_MOVE_*` heartbeat tells us they're on a + /// boat/elevator and where, in that transport's local frame. + pub transport: Option, + /// The **swim pitch** (radians, +up) — the conditional `f32` tail present iff `flags & + /// MOVEMENT_FLAG_SWIMMING`, carried after the orientation/transport pose and before `fall_time` + /// (VERIFIED order vs vmangos `MovementInfo::Read`). It is the vertical angle of the swim heading, so + /// observers can pitch a swimming body up/down the way the local client does. `0.0` (level) when not + /// swimming — the serializer only emits it while the `SWIMMING` flag is set, so a zero here on a + /// non-swimming packet costs nothing. + pub pitch: f32, + /// Milliseconds airborne — a **`u32`** on the wire (VERIFIED vmangos `MovementInfo::fallTime`; it + /// was read+written as an f32 while discarded, harmless then but wrong once consumed). The + /// receiver's ballistic clock: current vertical speed is `zspeed − g·(fall_time/1000)`. + pub fall_time: u32, + /// The jump tail — `Some` iff `flags & MOVEMENT_FLAG_JUMPING`. The launch params an observer + /// replays the arc from. + pub jump: Option, +} + +impl MovementInfo { + pub(super) fn write(&self, w: &mut Vec) { + w.extend_from_slice(&self.flags.to_le_bytes()); + w.extend_from_slice(&self.timestamp.to_le_bytes()); + w.extend_from_slice(&self.position.x.to_le_bytes()); + w.extend_from_slice(&self.position.y.to_le_bytes()); + w.extend_from_slice(&self.position.z.to_le_bytes()); + w.extend_from_slice(&self.orientation.to_le_bytes()); + // The transport tail rides iff ON_TRANSPORT is set — right after `orientation`, before the + // swim pitch, matching vmangos `MovementInfo::Read` exactly: `[u64 guid][local x,y,z][local + // o]` (the guid is a FULL u64 — see `read_movement_info`). Flag and tail must travel + // together or the server's read desyncs; the caller sets both from one rider state + // (decision 0438 "Riding is the mover's platform frame"). + if self.flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { + let t = self.transport.unwrap_or(TransportPose { + guid: 0, + pos: Vector3d { + x: 0.0, + y: 0.0, + z: 0.0, + }, + orientation: 0.0, + }); + w.extend_from_slice(&t.guid.to_le_bytes()); + w.extend_from_slice(&t.pos.x.to_le_bytes()); + w.extend_from_slice(&t.pos.y.to_le_bytes()); + w.extend_from_slice(&t.pos.z.to_le_bytes()); + w.extend_from_slice(&t.orientation.to_le_bytes()); + } + // The swim-pitch tail rides iff SWIMMING is set — after the transport pose and before + // `fall_time`, matching `read_movement_info`'s gate and order exactly: setting the flag + // without serializing the pitch (or vice-versa) desyncs the server's `MovementInfo::Read`, + // the reason this bit was masked off the wire until now. + if self.flags & MOVEMENT_FLAG_SWIMMING != 0 { + w.extend_from_slice(&self.pitch.to_le_bytes()); + } + // fall_time follows the transport + swim-pitch tails. + w.extend_from_slice(&self.fall_time.to_le_bytes()); + // The jump tail rides iff JUMPING is set — matching the parser's gate exactly: setting the flag + // without serializing the tail (or vice-versa) desyncs the server's `MovementInfo::Read`. Order + // matches vmangos: zspeed, cosAngle, sinAngle, xyspeed. + if self.flags & MOVEMENT_FLAG_JUMPING != 0 { + let j = self.jump.unwrap_or_default(); + w.extend_from_slice(&j.zspeed.to_le_bytes()); + w.extend_from_slice(&j.cos_angle.to_le_bytes()); + w.extend_from_slice(&j.sin_angle.to_le_bytes()); + w.extend_from_slice(&j.xy_speed.to_le_bytes()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::Vector3d; + + fn info(flags: u32) -> MovementInfo { + MovementInfo { + flags, + timestamp: 12345, + position: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + transport: None, + pitch: -0.7, + fall_time: 42, + jump: None, + } + } + + /// The swim-pitch tail round-trips byte-exactly through `write` → `read_movement_info` when + /// SWIMMING is set: the reason the flag was masked off the wire (an unwritten tail desyncing the + /// server's `MovementInfo::Read`) is closed only if write and read agree on gate + order. + #[test] + fn swim_pitch_round_trips_when_swimming() { + let mut bytes = Vec::new(); + info(MOVEMENT_FLAG_SWIMMING).write(&mut bytes); + let back = read_movement_info(&mut &bytes[..]).expect("parse"); + assert_eq!(back.flags, MOVEMENT_FLAG_SWIMMING); + assert_eq!(back.pitch, -0.7, "the swim pitch survives the round-trip"); + assert_eq!(back.orientation, 0.5); + assert_eq!( + back.fall_time, 42, + "fall_time still lands after the pitch tail" + ); + } + + /// The transport tail round-trips byte-exactly through `write` → `read_movement_info` when + /// ON_TRANSPORT is set: `[u64 guid][x,y,z][o]` right after `orientation` (vmangos + /// `MovementInfo::Read`/`Write` order), 24 bytes exactly — a packed guid or the TBC 0x200 + /// flag value would both shift `fall_time` and desync the server. + #[test] + fn transport_tail_round_trips_when_riding() { + let mut riding = info(MOVEMENT_FLAG_ON_TRANSPORT); + riding.transport = Some(TransportPose { + guid: 0x1FC0_0000_0000_2B10, // a HIGH_MO_TRANSPORT-shaped guid + pos: Vector3d { + x: -5.5, + y: 2.25, + z: 8.0, + }, + orientation: 1.5, + }); + let mut bytes = Vec::new(); + riding.write(&mut bytes); + let mut walking = Vec::new(); + info(0).write(&mut walking); + assert_eq!( + bytes.len() - walking.len(), + 24, + "the transport tail adds exactly u64 + 4×f32" + ); + let back = read_movement_info(&mut &bytes[..]).expect("parse"); + let t = back.transport.expect("transport pose survives"); + assert_eq!(t.guid, 0x1FC0_0000_0000_2B10); + assert_eq!((t.pos.x, t.pos.y, t.pos.z), (-5.5, 2.25, 8.0)); + assert_eq!(t.orientation, 1.5); + assert_eq!(back.fall_time, 42, "fall_time lands after the tail"); + } + + /// Non-swimming packets carry NO pitch tail — the four pitch bytes are absent, so the wire stays + /// byte-identical to before this tail existed (and `fall_time` reads from the right offset). + #[test] + fn no_pitch_tail_when_not_swimming() { + let mut swimming = Vec::new(); + info(MOVEMENT_FLAG_SWIMMING).write(&mut swimming); + let mut dry = Vec::new(); + info(0).write(&mut dry); + assert_eq!( + swimming.len() - dry.len(), + 4, + "the swim pitch adds exactly one f32 to the packet, and only when swimming" + ); + let back = read_movement_info(&mut &dry[..]).expect("parse"); + assert_eq!(back.pitch, 0.0, "a dry packet parses a level (0) pitch"); + assert_eq!(back.fall_time, 42); + } +} diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs new file mode 100644 index 00000000..1082ff62 --- /dev/null +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -0,0 +1,610 @@ +//! World opcode numbers (verified against `wow_world_messages` 0.3 / wowdev + vmangos +//! `Opcodes_1_12_1.h`). All are 16-bit values — the client packet header's opcode field is 4 bytes +//! wide but every value fits `u16`, so we keep one natural type and the senders widen to the wire field. + +// Server → client. +pub const SMSG_CHAR_CREATE: u16 = 0x003A; +pub const SMSG_CHAR_ENUM: u16 = 0x003B; +// The delete pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 56/60): CMSG body a full u64 guid, +// SMSG body the result byte (`CHAR_DELETE_SUCCESS` = 0x39, `SharedDefines.h` ResponseCodes). +pub const CMSG_CHAR_DELETE: u16 = 0x0038; +pub const SMSG_CHAR_DELETE: u16 = 0x003C; +pub const SMSG_NAME_QUERY_RESPONSE: u16 = 0x0051; // 81 +pub const SMSG_CREATURE_QUERY_RESPONSE: u16 = 0x0061; // 97 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 95 (decision 0236). Answers `CMSG_GAMEOBJECT_QUERY`; body +/// (`GameObjectQueryInfo`) in [`super::gameobject`]. +pub const SMSG_GAMEOBJECT_QUERY_RESPONSE: u16 = 0x005F; // 95 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 93. Answers `CMSG_QUEST_QUERY`; body in +/// [`super::quest::read_quest_query_response`] ([`super::QuestTemplate`]). +pub const SMSG_QUEST_QUERY_RESPONSE: u16 = 0x005D; // 93 +pub const SMSG_NEW_WORLD: u16 = 0x003E; +// The far-teleport preamble pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 63/64). TRANSFER_PENDING's +// optional transport block decides how NEW_WORLD's coordinates read — boat-local when present, +// world otherwise (decision 0455; vmangos `Player.cpp:2065-2068` / `SendNewWorld`). +pub const SMSG_TRANSFER_PENDING: u16 = 0x003F; +pub const SMSG_TRANSFER_ABORTED: u16 = 0x0040; +pub const SMSG_LOGIN_SETTIMESPEED: u16 = 0x0042; +pub const SMSG_BINDPOINTUPDATE: u16 = 0x0155; +/// 295 — the per-item-class proficiency mask (VERIFIED vmangos `Opcodes_1_12_1.h` + Skill.h). +pub const SMSG_SET_PROFICIENCY: u16 = 0x0127; +pub const SMSG_LOGOUT_RESPONSE: u16 = 0x004C; +pub const SMSG_LOGOUT_COMPLETE: u16 = 0x004D; +pub const SMSG_UPDATE_OBJECT: u16 = 0x00A9; +pub const SMSG_DESTROY_OBJECT: u16 = 0x00AA; +// The cinematic pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 250/252). The server sends the trigger +// on a character's first-ever login (the race intro) and for GameObject type-13 cameras; while one +// runs UNACKED, vmangos re-anchors object visibility to the flying cinematic camera +// (`Player::UpdateCinematic`), despawning everything around the body — so the client must answer. +pub const SMSG_TRIGGER_CINEMATIC: u16 = 0x00FA; +pub const CMSG_COMPLETE_CINEMATIC: u16 = 0x00FC; +pub const SMSG_MONSTER_MOVE: u16 = 0x00DD; +pub const SMSG_INITIALIZE_FACTIONS: u16 = 0x0122; +pub const SMSG_SET_FACTION_STANDING: u16 = 0x0124; +pub const SMSG_AUTH_CHALLENGE: u16 = 0x01EC; +pub const SMSG_AUTH_RESPONSE: u16 = 0x01EE; +/// The Warden anticheat challenge. A server that sends this starts a response-timeout clock +/// (vmangos `Warden::BeginTimeoutClock`, `Warden.ClientResponseDelay` — default 30 s) and kicks +/// unconditionally when it expires (`Warden::Update`), so a client that can't answer cannot stay +/// in the world. benilla does not implement Warden; [`crate::WorldSession::connect`] refuses such +/// a server at the handshake rather than entering a 30-second kick/reconnect cycle. +pub const SMSG_WARDEN_DATA: u16 = 0x02E6; +pub const SMSG_COMPRESSED_UPDATE_OBJECT: u16 = 0x01F6; +pub const SMSG_LOGIN_VERIFY_WORLD: u16 = 0x0236; +// The server-pushed sound trio (1.12.1 values VERIFIED vmangos `Opcodes_1_12_1.h`: +// 631/632/722). +pub const SMSG_PLAY_MUSIC: u16 = 0x0277; +pub const SMSG_PLAY_OBJECT_SOUND: u16 = 0x0278; +pub const SMSG_PLAY_SOUND: u16 = 0x02D2; +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 756. +pub const SMSG_WEATHER: u16 = 0x02F4; +// The emote pair + our send side (VERIFIED vmangos `Opcodes_1_12_1.h`: 259/260/261). +pub const SMSG_EMOTE: u16 = 0x0103; +pub const CMSG_TEXT_EMOTE: u16 = 0x0104; +pub const SMSG_TEXT_EMOTE: u16 = 0x0105; +// The spell-book/action-bar/cast/attack set (VERIFIED vmangos `Opcodes_1_12_1.h`: +// 297/298/304/323/324 in, 302/321/322 out). Bodies in [`super::spells`]. +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 88. Body in [`items`]. +pub const SMSG_ITEM_QUERY_SINGLE_RESPONSE: u16 = 0x0058; +// The item-move CMSG family (VERIFIED vmangos `Opcodes_1_12_1.h:269-273`; bodies in [`items`]). +pub const CMSG_AUTOEQUIP_ITEM: u16 = 0x010A; // 266 +pub const CMSG_AUTOSTORE_BAG_ITEM: u16 = 0x010B; // 267 +pub const CMSG_SWAP_ITEM: u16 = 0x010C; // 268 +pub const CMSG_SWAP_INV_ITEM: u16 = 0x010D; // 269 +pub const CMSG_SPLIT_ITEM: u16 = 0x010E; // 270 +pub const CMSG_DESTROYITEM: u16 = 0x0111; // 273 — the popup-confirmed world-drop delete (0216 §3) +pub const SMSG_INVENTORY_CHANGE_FAILURE: u16 = 0x0112; // 274 + +// The player-to-player trade family (VERIFIED vmangos `Opcodes_1_12_1.h:278-289`, +// `Handlers/TradeHandler.cpp`, `Server/Packets/Trade.{h,cpp}`; bodies/parses in [`trade`], +// decision 0592 P0). CMSG bodies: INITIATE = u64 target; ACCEPT = u32 (read-skipped); +// SET_ITEM = 3×u8 (tradeSlot, bag, slot); CLEAR_ITEM = u8 tradeSlot; SET_GOLD = u32 copper; +// BEGIN/BUSY/IGNORE/UNACCEPT/CANCEL are empty. SMSG: TRADE_STATUS = u32 status (+ a per-status +// tail); TRADE_STATUS_EXTENDED = the 444-byte item/gold snapshot. +pub const CMSG_INITIATE_TRADE: u16 = 0x0116; // 278 +pub const CMSG_BEGIN_TRADE: u16 = 0x0117; // 279 +pub const CMSG_BUSY_TRADE: u16 = 0x0118; // 280 +pub const CMSG_IGNORE_TRADE: u16 = 0x0119; // 281 +pub const CMSG_ACCEPT_TRADE: u16 = 0x011A; // 282 +pub const CMSG_UNACCEPT_TRADE: u16 = 0x011B; // 283 +pub const CMSG_CANCEL_TRADE: u16 = 0x011C; // 284 +pub const CMSG_SET_TRADE_ITEM: u16 = 0x011D; // 285 +pub const CMSG_CLEAR_TRADE_ITEM: u16 = 0x011E; // 286 +pub const CMSG_SET_TRADE_GOLD: u16 = 0x011F; // 287 +pub const SMSG_TRADE_STATUS: u16 = 0x0120; // 288 +pub const SMSG_TRADE_STATUS_EXTENDED: u16 = 0x0121; // 289 +/// Load ammo into the ammo "slot" (`PLAYER_AMMO_ID`) — VERIFIED wow-re `cursor-dragdrop-slots.md` +/// (the client's auto-equip sender `0x5e1480` forks ammo-class here) + vmangos `Opcodes_1_12_1.h`: +/// 616. Body in [`items::set_ammo`]. Note the hex/decimal trap: `0x0268` (this) ≠ `0x010C` +/// (`CMSG_SWAP_ITEM`, "opcode 268" in decimal). Decision 0526. +pub const CMSG_SET_AMMO: u16 = 0x0268; // 616 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 296 — `MiscHandler.cpp:885`'s `HandleSetActionButtonOpcode` +/// (decision 0216 §7/0218 §4). Body in [`super::spells::set_action_button`]. +pub const CMSG_SET_ACTION_BUTTON: u16 = 0x0128; // 296 +pub const SMSG_ACTION_BUTTONS: u16 = 0x0129; // 297 +pub const SMSG_INITIAL_SPELLS: u16 = 0x012A; // 298 + +// The incremental spell-learn pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 299/300) — a spell added to +// the book after login (trainer purchase / quest reward / level-up), and the rank-up in-place swap. +// Bodies in [`super::spells`] (decision 0237). +pub const SMSG_LEARNED_SPELL: u16 = 0x012B; // 299 +pub const SMSG_SUPERCEDED_SPELL: u16 = 0x012C; // 300 +pub const SMSG_CAST_RESULT: u16 = 0x0130; // 304 + +/// The client's ack that a server-authored spline (`SMSG_MONSTER_MOVE` to our own guid — Charge, +/// knockback, taxi) finished. Body: a `MovementInfo` at the endpoint, the `splineId` being acked, +/// and a trailing float the server `read_skip`s (VERIFIED vmangos `Opcodes_1_12_1.h`: 713; +/// `MoveSplineDone::ReadFromWorldPacket`). +pub const CMSG_MOVE_SPLINE_DONE: u16 = 0x02C9; // 713 +pub const SMSG_ATTACKSTART: u16 = 0x0143; // 323 +pub const SMSG_ATTACKSTOP: u16 = 0x0144; // 324 +pub const SMSG_ATTACKERSTATEUPDATE: u16 = 0x014A; // 330 +/// Creature aggro/alert flare (VERIFIED vmangos `Opcodes_1_12_1.h`: 316; body in +/// [`super::spells::read_ai_reaction`]). +pub const SMSG_AI_REACTION: u16 = 0x013C; // 316 + +// The spell-visual pipeline wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 305/306/499/668/678; +// decision 0099 phase 1). Bodies in [`super::spells`]. +pub const SMSG_SPELL_START: u16 = 0x0131; // 305 +pub const SMSG_SPELL_GO: u16 = 0x0132; // 306 +pub const SMSG_PLAY_SPELL_VISUAL: u16 = 0x01F3; // 499 +pub const SMSG_CANCEL_AUTO_REPEAT: u16 = 0x029C; // 668 +pub const SMSG_SPELL_FAILED_OTHER: u16 = 0x02A6; // 678 + +// The cooldown wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 308/176/309/478/481; the client +// handlers are byte-verified in wow-re `wave-handlers.md` — 0x6e9460/0x6e95d0/0x6e9670/0x6e9730; +// decision 0137 phase 4). Bodies in [`super::spells`]. +pub const SMSG_SPELL_COOLDOWN: u16 = 0x0134; // 308 +pub const SMSG_ITEM_COOLDOWN: u16 = 0x00B0; // 176 +pub const SMSG_COOLDOWN_EVENT: u16 = 0x0135; // 309 +pub const SMSG_CLEAR_COOLDOWN: u16 = 0x01DE; // 478 +pub const SMSG_COOLDOWN_CHEAT: u16 = 0x01E1; // 481 + +/// Pushback (VERIFIED vmangos `Opcodes_1_12_1.h`: 482): sent to the caster when a cast takes damage +/// and the server delays it (`Spell::Delayed`, `Spell.cpp:7472` — a raw `u64` caster guid + `u32` +/// delaytime ms). The cast bar slides its window out by it (`SPELLCAST_DELAYED`); a normal hit never +/// interrupts a cast, it only pushes it back (decision 0256). +pub const SMSG_SPELL_DELAYED: u16 = 0x01E2; // 482 + +// The self-only channel UI pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 313/314; sent only to the +// casting player — `Spell::SendChannelStart` {spellId u32, duration_ms u32}, +// `Player::SendChannelUpdate` {remaining_ms u32, 0 = the channel is over}; decision 0137 phase 1, +// the cast bar's channel half). Bodies in [`super::spells`]. +pub const MSG_CHANNEL_START: u16 = 0x0139; // 313 +pub const MSG_CHANNEL_UPDATE: u16 = 0x013A; // 314 + +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 311. Body in [`super::spells::read_update_aura_duration`] — +/// `{u8 slot, u32 remaining_ms}`. **The only aura fact not carried by the descriptor**, and it is +/// self-only: `SpellAuraHolder::UpdateAuraDuration` (`SpellAuras.cpp:7511-7523`) writes it to the +/// aura's *target* (gated on `TYPEID_PLAYER`) and skips permanent auras entirely. So we learn how +/// long our own buffs last, and never how long anyone else's do. It is also sent **before** the +/// `UNIT_FIELD_AURA` delta that names the slot's spell — the server writes the descriptor dirty +/// (flushed at end of tick) and then sends this immediately (decision 0255). +pub const SMSG_UPDATE_AURA_DURATION: u16 = 0x0137; // 311 + +/// The ding (VERIFIED vmangos `Opcodes_1_12_1.h`: 468) — our own level-up, self-addressed only +/// (`Player::GiveLevel` builds it with no guid and sends straight to the leveling session). Body +/// in [`super::spells::read_level_up_info`] — decision 0304. +pub const SMSG_LEVELUP_INFO: u16 = 0x01D4; // 468 + +// The combat-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 464/587/590/591/592) — decision 0137 +// phase 2's floating-combat-text data feed. Bodies in [`super::spells`]. +pub const SMSG_ENVIRONMENTALDAMAGELOG: u16 = 0x01FC; // 508 +pub const SMSG_LOG_XPGAIN: u16 = 0x01D0; // 464 +pub const SMSG_SPELLLOGMISS: u16 = 0x024B; // 587 +pub const SMSG_PERIODICAURALOG: u16 = 0x024E; // 590 +pub const SMSG_SPELLDAMAGESHIELD: u16 = 0x024F; // 591 +pub const SMSG_SPELLNONMELEEDAMAGELOG: u16 = 0x0250; // 592 + +// The heal/energize pair (VERIFIED vmangos `Opcodes_1_12_1.h`: 336/337) — the center combat +// text's HEAL / power-gain feed (decision 0578). Bodies in [`super::spells`]. +pub const SMSG_SPELLHEALLOG: u16 = 0x0150; // 336 +pub const SMSG_SPELLENERGIZELOG: u16 = 0x0151; // 337 + +// Client → server. +pub const CMSG_CHAR_CREATE: u16 = 0x0036; +pub const CMSG_CHAR_ENUM: u16 = 0x0037; +pub const CMSG_PLAYER_LOGIN: u16 = 0x003D; +pub const CMSG_LOGOUT_REQUEST: u16 = 0x004B; +pub const CMSG_NAME_QUERY: u16 = 0x0050; // 80 +pub const CMSG_CREATURE_QUERY: u16 = 0x0060; // 96 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in +/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to +/// `CMSG_CREATURE_QUERY`. +pub const CMSG_GAMEOBJECT_QUERY: u16 = 0x005E; // 94 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 92. Body in [`super::quest::quest_query`]. +pub const CMSG_QUEST_QUERY: u16 = 0x005C; // 92 +pub const CMSG_ITEM_QUERY_SINGLE: u16 = 0x0056; // 86 +pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 177. Body in [`super::gameobj_use`] — a full guid, the +/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a +/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode. +pub const CMSG_GAMEOBJ_USE: u16 = 0x00B1; // 177 +pub const CMSG_MESSAGECHAT: u16 = 0x0095; +pub const SMSG_MESSAGECHAT: u16 = 0x0096; +// The channel family (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`, decimal +// 151-168): join/leave/list + the whole moderation set. Every CMSG here is `cstring channelName [+ +// cstring playerName]` (bodies in [`super::channel`]/[`super::client`]); no channel id rides the +// 1.12 wire (that's a TBC+ addition). `SMSG_CHANNEL_NOTIFY`/`_LIST` layouts in [`super::channel`]. +pub const CMSG_JOIN_CHANNEL: u16 = 0x0097; // 151 +pub const CMSG_LEAVE_CHANNEL: u16 = 0x0098; // 152 +pub const SMSG_CHANNEL_NOTIFY: u16 = 0x0099; // 153 +pub const CMSG_CHANNEL_LIST: u16 = 0x009A; // 154 +pub const SMSG_CHANNEL_LIST: u16 = 0x009B; // 155 +pub const CMSG_CHANNEL_PASSWORD: u16 = 0x009C; // 156 +pub const CMSG_CHANNEL_SET_OWNER: u16 = 0x009D; // 157 +pub const CMSG_CHANNEL_OWNER: u16 = 0x009E; // 158 +pub const CMSG_CHANNEL_MODERATOR: u16 = 0x009F; // 159 +pub const CMSG_CHANNEL_UNMODERATOR: u16 = 0x00A0; // 160 +pub const CMSG_CHANNEL_MUTE: u16 = 0x00A1; // 161 +pub const CMSG_CHANNEL_UNMUTE: u16 = 0x00A2; // 162 +pub const CMSG_CHANNEL_INVITE: u16 = 0x00A3; // 163 +pub const CMSG_CHANNEL_KICK: u16 = 0x00A4; // 164 +pub const CMSG_CHANNEL_BAN: u16 = 0x00A5; // 165 +pub const CMSG_CHANNEL_UNBAN: u16 = 0x00A6; // 166 +pub const CMSG_CHANNEL_ANNOUNCEMENTS: u16 = 0x00A7; // 167 +pub const CMSG_CHANNEL_MODERATE: u16 = 0x00A8; // 168 + +// The remaining decision-0288 "wire completeness" opcodes — small, single-purpose chat-adjacent +// packets that don't belong to any of the families above. +/// The ignore-list self-notice ("Name is now ignoring you" — VERIFIED vmangos `Opcodes_1_12_1.h`: +/// 549). Body in [`super::client::chat_ignored`] — a raw `u64` guid +/// (`WorldPackets::Misc::ChatIgnored::ReadFromWorldPacket`, `Server/Packets/Misc.cpp:127-130`). +pub const CMSG_CHAT_IGNORED: u16 = 0x0225; // 549 +/// A whisper target wasn't found online (VERIFIED vmangos `Opcodes_1_12_1.h`: 681). Body: +/// [`super::chat::read_chat_player_not_found`] (cstring name, `Server/Packets/Chat.cpp:26-29`). +pub const SMSG_CHAT_PLAYER_NOT_FOUND: u16 = 0x02A9; // 681 +/// A cross-faction whisper was refused (VERIFIED vmangos `Opcodes_1_12_1.h`: 537); empty body +/// (`WorldPackets::Chat::ChatWrongFaction::AppendBodyTo`, `Server/Packets/Chat.cpp:16-18`). +pub const SMSG_CHAT_WRONG_FACTION: u16 = 0x0219; // 537 +/// A server notice the real client flashes in the red UIErrorsFrame (VERIFIED vmangos +/// `Opcodes_1_12_1.h`: 459): one cstring (`WorldSession::SendNotification`, +/// `Server/WorldSession.cpp:900-915`) — "You do not know that language", trade refusals, and kin. +pub const SMSG_NOTIFICATION: u16 = 0x01CB; // 459 +/// `/played` (VERIFIED vmangos `Opcodes_1_12_1.h`: 460/461): empty CMSG body +/// (`NullClientPacket`), SMSG body `u32 total + u32 level` seconds +/// (`WorldPackets::Misc::PlayedTime::AppendBodyTo`, `Server/Packets/Misc.cpp:278-282`; layout in +/// [`super::chat::read_played_time`]). +pub const CMSG_PLAYED_TIME: u16 = 0x01CC; // 460 +pub const SMSG_PLAYED_TIME: u16 = 0x01CD; // 461 +/// `/random` (VERIFIED vmangos `Opcodes_1_12_1.h`: 507) — same opcode both directions, different +/// bodies: client sends `u32 min + u32 max` ([`super::client::random_roll`], +/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`); the +/// server broadcasts `u32 min + u32 max + u32 roll + u64 guid` +/// (`WorldSession::HandleRandomRollOpcode`, `Handlers/GroupHandler.cpp:394-422`; layout in +/// [`super::chat::read_random_roll`]). +pub const MSG_RANDOM_ROLL: u16 = 0x01FB; // 507 + +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 257. Body in [`super::stand_state_change`]. +pub const CMSG_STANDSTATECHANGE: u16 = 0x0101; // 257 +pub const CMSG_CAST_SPELL: u16 = 0x012E; // 302 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 310. Body in [`super::spells::cancel_aura`] — a lone `u32` +/// spell id (`Server/Packets/Spell.h:55-62`): the server cancels **by spell, never by slot**, and +/// its handler (`SpellHandler.cpp:333-405`) refuses passives, `SPELL_ATTR_NO_AURA_CANCEL` spells, +/// and debuffs. wow-re independently records the real client's sender (`Spell_C::CancelAura` +/// `0x6e7040`, `push 0x136`). Decision 0255. +/// Body: one `u32` spell id (vmangos `HandleCancelCastOpcode`). The wand-only auto-repeat +/// handoff sends it for the cached wand Shoot before the local cancel (`0x6095b8`, wow-re +/// `nocked-ammo-cancel.md` §Q-B-5). +pub const CMSG_CANCEL_CAST: u16 = 0x012F; // 303 +pub const CMSG_CANCEL_AURA: u16 = 0x0136; // 310 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 315. Body: one `u32` spell id — the server ignores it +/// (`Server/Packets/Spell.h:69` "not used by server"; `HandleCancelChanneling` interrupts the +/// current channel unconditionally) but the real client writes it, so ours does too. Sent by the +/// cast bar's local self-cancel (`benilla::ui_cast`) when movement/Esc ends our own channel. +pub const CMSG_CANCEL_CHANNELLING: u16 = 0x013B; // 315 +pub const CMSG_SET_SELECTION: u16 = 0x013D; // 317 +pub const CMSG_ATTACKSWING: u16 = 0x0141; // 321 +pub const CMSG_ATTACKSTOP: u16 = 0x0142; // 322 +/// VERIFIED vmangos `Opcodes_1_12_1.h`: 480. Body in [`super::spells::set_sheathed`]. +pub const CMSG_SETSHEATHED: u16 = 0x01E0; // 480 +pub const CMSG_AUTH_SESSION: u16 = 0x01ED; +pub const CMSG_SET_ACTIVE_MOVER: u16 = 0x026A; +/// Empty body. The real client sends it from exactly ONE site — inside the local auto-repeat +/// cancel `0x6ea080` (`0x6ea0c6`) — so it rides along with *every* cancel trigger (wow-re +/// `nocked-ammo-cancel.md`). vmangos `HandleCancelAutoRepeatSpellOpcode` interrupts the held +/// auto-repeat spell (idempotent when the server already cancelled it first). +pub const CMSG_CANCEL_AUTO_REPEAT_SPELL: u16 = 0x026D; // 621 + +// The keepalive pair (VERIFIED twice: vmangos `Opcodes_1_12_1.h` 476/477 AND wow-re net W1 — +// `SendPing 0x537e10` pushes opcode 0x1dc, `HandlePong 0x537d60` matches 0x1dd). The real client +// pings every 30 000 ms (wow-re `0x537ff0`, the connection drain's cadence check); vmangos kicks a +// player socket that pings *faster* than 27 s apart more than `MaxOverspeedPings` (default 2) times +// (`WorldSocket::_HandlePing`). Bodies: CMSG `{u32 sequence, u32 lastRtt}`; SMSG echoes `{u32 +// sequence}`. vmangos handles the ping inline on the socket (pre-session-queue) and stores the +// latency field for `.server info`. +pub const CMSG_PING: u16 = 0x01DC; // 476 +pub const SMSG_PONG: u16 = 0x01DD; // 477 + +// The force-speed-change family (VERIFIED vmangos `Opcodes_1_12_1.h`: 226-231 + 730-735): the +// server tells the CONTROLLER of a unit its speed changed (an aura slow, a mount, GM `.modify +// speed`) as `[packed mover guid][u32 movementCounter][f32 newSpeed]` (flat yd/s — +// `MovementPacketSender::SendSpeedChangeToController`, 5875 = the `> 1_9_4` branch), and the client +// MUST answer the matching ACK: `[u64 FULL guid][u32 movementCounter][MovementInfo][f32 speed]` +// (`MoveSpeedAck::ReadFromWorldPacket` — plain `ObjectGuid` extraction reads a full 8 bytes, +// `ObjectGuid.cpp:180`; the echoed speed must match the sent one ±0.01 and the counter must match +// a pending change, `Unit::FindPendingMovementSpeedChange`). Unacked, the server force-resolves +// after `Movement.PendingAckResponseTime` (4 s default) and flags the anticheat's +// `OnFailedToAckChange` — every speed change desyncs for 4 s and looks like cheating. +pub const SMSG_FORCE_RUN_SPEED_CHANGE: u16 = 0x00E2; // 226 +pub const CMSG_FORCE_RUN_SPEED_CHANGE_ACK: u16 = 0x00E3; // 227 +pub const SMSG_FORCE_RUN_BACK_SPEED_CHANGE: u16 = 0x00E4; // 228 +pub const CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK: u16 = 0x00E5; // 229 +pub const SMSG_FORCE_SWIM_SPEED_CHANGE: u16 = 0x00E6; // 230 +pub const CMSG_FORCE_SWIM_SPEED_CHANGE_ACK: u16 = 0x00E7; // 231 +pub const SMSG_FORCE_WALK_SPEED_CHANGE: u16 = 0x02DA; // 730 +pub const CMSG_FORCE_WALK_SPEED_CHANGE_ACK: u16 = 0x02DB; // 731 +pub const SMSG_FORCE_SWIM_BACK_SPEED_CHANGE: u16 = 0x02DC; // 732 +pub const CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK: u16 = 0x02DD; // 733 +pub const SMSG_FORCE_TURN_RATE_CHANGE: u16 = 0x02DE; // 734 +pub const CMSG_FORCE_TURN_RATE_CHANGE_ACK: u16 = 0x02DF; // 735 + +// The OBSERVER speed-change legs (VERIFIED vmangos `MovementPacketSender`, decision 0441): a +// speed change on a unit we don't control reaches us one of two ways, neither acked. A +// server-controlled mover (creature), or a player mid-spline, broadcasts +// `SMSG_SPLINE_SET_*_SPEED` = `[packed guid][f32 speed]` (`SendSpeedChangeToAll` / the +// non-finalized `SendSpeedChangeToObservers` branch; 766-771). A freely-moving player's change — +// the common case: another player mounting up — broadcasts `MSG_MOVE_SET_*_SPEED` = +// `[packed guid][MovementInfo][f32 speed]` (the finalized-spline branch; 205-216, gaps real), +// which also carries a fresh authoritative pose. +pub const SMSG_SPLINE_SET_RUN_SPEED: u16 = 0x02FE; // 766 +pub const SMSG_SPLINE_SET_RUN_BACK_SPEED: u16 = 0x02FF; // 767 +pub const SMSG_SPLINE_SET_SWIM_SPEED: u16 = 0x0300; // 768 +pub const SMSG_SPLINE_SET_WALK_SPEED: u16 = 0x0301; // 769 +pub const SMSG_SPLINE_SET_SWIM_BACK_SPEED: u16 = 0x0302; // 770 +pub const SMSG_SPLINE_SET_TURN_RATE: u16 = 0x0303; // 771 +pub const MSG_MOVE_SET_RUN_SPEED: u16 = 0x00CD; // 205 +pub const MSG_MOVE_SET_RUN_BACK_SPEED: u16 = 0x00CF; // 207 +pub const MSG_MOVE_SET_WALK_SPEED: u16 = 0x00D1; // 209 +pub const MSG_MOVE_SET_SWIM_SPEED: u16 = 0x00D3; // 211 +pub const MSG_MOVE_SET_SWIM_BACK_SPEED: u16 = 0x00D5; // 213 +pub const MSG_MOVE_SET_TURN_RATE: u16 = 0x00D8; // 216 + +// Mount feedback (VERIFIED vmangos `Opcodes_1_12_1.h` 366-367, `Player::SendMountResult` / +// `SendDismountResult` — one u32 result code; decision 0441). The success codes +// (`MOUNTRESULT_OK` = 10 / `DISMOUNTRESULT_OK` = 3) are silent in the reference; the failure +// codes map to red error lines (a P2 trimming — decoded and logged for now). +pub const SMSG_MOUNTRESULT: u16 = 0x016E; // 366 +pub const SMSG_DISMOUNTRESULT: u16 = 0x016F; // 367 + +// The mounted space-bar flourish (VERIFIED vmangos `HandleMountSpecialAnimOpcode`, +// `MovementHandler.cpp:967`): the CMSG is an EMPTY body; the server rebroadcasts +// `SMSG_MOUNTSPECIAL_ANIM` — one raw u64 guid — to everyone in range, INCLUDING the sender +// on vmangos's non-broadcaster delivery path (its `self=false` only gates cheat-logging; +// live-verified by the double-flourish probe 2026-07-17). The app plays its own flourish +// locally at send time and self-suppresses the echo; receivers play MountSpecial(94) on +// that unit's mount (decision 0441 P2). +pub const CMSG_MOUNTSPECIAL_ANIM: u16 = 0x0171; // 369 +pub const SMSG_MOUNTSPECIAL_ANIM: u16 = 0x0172; // 370 + +// Movement (`MSG_MOVE_*`, both directions). The player-movement relay set: the server rebroadcasts +// each opcode bound to its `HandleMovementOpcodes` (VERIFIED vmangos `Opcodes.cpp`) to nearby clients +// as `[packed mover guid][MovementInfo]`, same opcode. We *send* the locomotion subset to drive our +// own avatar and *receive* the whole set as other players' movement (see [`is_movement_relay`]). The +// two acks (TELEPORT/WORLDPORT) ride the same family but carry different bodies, handled separately. +pub const MSG_MOVE_START_FORWARD: u16 = 0x00B5; // 181 +pub const MSG_MOVE_START_BACKWARD: u16 = 0x00B6; // 182 +pub const MSG_MOVE_STOP: u16 = 0x00B7; // 183 +pub const MSG_MOVE_START_STRAFE_LEFT: u16 = 0x00B8; // 184 +pub const MSG_MOVE_START_STRAFE_RIGHT: u16 = 0x00B9; // 185 +pub const MSG_MOVE_STOP_STRAFE: u16 = 0x00BA; // 186 +pub const MSG_MOVE_JUMP: u16 = 0x00BB; // 187 +pub const MSG_MOVE_START_TURN_LEFT: u16 = 0x00BC; // 188 +pub const MSG_MOVE_START_TURN_RIGHT: u16 = 0x00BD; // 189 +pub const MSG_MOVE_STOP_TURN: u16 = 0x00BE; // 190 +pub const MSG_MOVE_START_PITCH_UP: u16 = 0x00BF; // 191 +pub const MSG_MOVE_START_PITCH_DOWN: u16 = 0x00C0; // 192 +pub const MSG_MOVE_STOP_PITCH: u16 = 0x00C1; // 193 +pub const MSG_MOVE_SET_RUN_MODE: u16 = 0x00C2; // 194 +pub const MSG_MOVE_SET_WALK_MODE: u16 = 0x00C3; // 195 +pub const MSG_MOVE_TELEPORT_ACK: u16 = 0x00C7; // 199 +pub const MSG_MOVE_FALL_LAND: u16 = 0x00C9; // 201 +pub const MSG_MOVE_START_SWIM: u16 = 0x00CA; // 202 +pub const MSG_MOVE_STOP_SWIM: u16 = 0x00CB; // 203 +pub const MSG_MOVE_WORLDPORT_ACK: u16 = 0x00DC; // 220 +pub const MSG_MOVE_SET_FACING: u16 = 0x00DA; // 218 +pub const MSG_MOVE_SET_PITCH: u16 = 0x00DB; // 219 +pub const MSG_MOVE_HEARTBEAT: u16 = 0x00EE; // 238 + +// The gossip/vendor interaction set — "right-click a friendly NPC → dialog/vendor" (VERIFIED +// vmangos `Opcodes_1_12_1.h`: 379-384 gossip/NPC-text, 414-421 vendor). Bodies in +// [`super::gossip`] (gossip + NPC text) and [`super::vendor`] (vendor list/buy/sell). +pub const CMSG_GOSSIP_HELLO: u16 = 0x017B; // 379 +pub const CMSG_GOSSIP_SELECT_OPTION: u16 = 0x017C; // 380 +pub const SMSG_GOSSIP_MESSAGE: u16 = 0x017D; // 381 +pub const SMSG_GOSSIP_COMPLETE: u16 = 0x017E; // 382 +pub const CMSG_NPC_TEXT_QUERY: u16 = 0x017F; // 383 +pub const SMSG_NPC_TEXT_UPDATE: u16 = 0x0180; // 384 + // The questgiver panel set — "accept/turn-in a quest at an NPC" (VERIFIED vmangos + // `Opcodes_1_12_1.h`: 386-402). Bodies + SMSG layouts in [`super::quest`] (decision 0088). +pub const CMSG_QUESTGIVER_STATUS_QUERY: u16 = 0x0182; // 386 +pub const SMSG_QUESTGIVER_STATUS: u16 = 0x0183; // 387 +pub const CMSG_QUESTGIVER_HELLO: u16 = 0x0184; // 388 +pub const SMSG_QUESTGIVER_QUEST_LIST: u16 = 0x0185; // 389 +pub const CMSG_QUESTGIVER_QUERY_QUEST: u16 = 0x0186; // 390 +pub const SMSG_QUESTGIVER_QUEST_DETAILS: u16 = 0x0188; // 392 +pub const CMSG_QUESTGIVER_ACCEPT_QUEST: u16 = 0x0189; // 393 +pub const CMSG_QUESTGIVER_COMPLETE_QUEST: u16 = 0x018A; // 394 +pub const SMSG_QUESTGIVER_REQUEST_ITEMS: u16 = 0x018B; // 395 +pub const CMSG_QUESTGIVER_REQUEST_REWARD: u16 = 0x018C; // 396 +pub const SMSG_QUESTGIVER_OFFER_REWARD: u16 = 0x018D; // 397 +pub const CMSG_QUESTGIVER_CHOOSE_REWARD: u16 = 0x018E; // 398 +pub const SMSG_QUESTGIVER_QUEST_INVALID: u16 = 0x018F; // 399 +pub const SMSG_QUESTGIVER_QUEST_COMPLETE: u16 = 0x0191; // 401 +pub const SMSG_QUESTGIVER_QUEST_FAILED: u16 = 0x0192; // 402 + +// The quest-log wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 403-410) — swap/abandon a logged +// quest, the full-log refusal, and the log's live progress/state pushes. Bodies + the +// `SMSG_QUEST_QUERY_RESPONSE` full template in [`super::quest`]. +pub const CMSG_QUESTLOG_SWAP_QUEST: u16 = 0x0193; // 403 +pub const CMSG_QUESTLOG_REMOVE_QUEST: u16 = 0x0194; // 404 +pub const SMSG_QUESTLOG_FULL: u16 = 0x0195; // 405 +pub const SMSG_QUESTUPDATE_FAILED: u16 = 0x0196; // 406 +pub const SMSG_QUESTUPDATE_FAILEDTIMER: u16 = 0x0197; // 407 +pub const SMSG_QUESTUPDATE_COMPLETE: u16 = 0x0198; // 408 +pub const SMSG_QUESTUPDATE_ADD_KILL: u16 = 0x0199; // 409 +pub const SMSG_QUESTUPDATE_ADD_ITEM: u16 = 0x019A; // 410 + +pub const CMSG_LIST_INVENTORY: u16 = 0x019E; // 414 +pub const SMSG_LIST_INVENTORY: u16 = 0x019F; // 415 +pub const CMSG_SELL_ITEM: u16 = 0x01A0; // 416 +pub const SMSG_SELL_ITEM: u16 = 0x01A1; // 417 +pub const CMSG_BUY_ITEM: u16 = 0x01A2; // 418 +/// Buy into a specific bag slot — value pinned for completeness; unused (out of scope, plain +/// `CMSG_BUY_ITEM` auto-place is sufficient for v1, decision 0081). +pub const CMSG_BUY_ITEM_IN_SLOT: u16 = 0x01A3; // 419 +pub const SMSG_BUY_ITEM: u16 = 0x01A4; // 420 +pub const SMSG_BUY_FAILED: u16 = 0x01A5; // 421 +/// Buy a sold item back (VERIFIED wow-re `BuybackItem 0x4fb950`'s PutUint32 opcode immediate, +/// ui/scratch/buyback-data-path.md + vmangos `Opcodes_1_12_1.h` 656 — NOT in the 0x19E vendor run). +pub const CMSG_BUYBACK_ITEM: u16 = 0x0290; // 656 +/// Repair one item (guid) or everything (guid 0) at a repair-capable vendor (VERIFIED wow-re +/// ui/scratch/repair-machinery.md: `68 a8 02 00 00` at all 4 client send sites; vmangos 680). +pub const CMSG_REPAIR_ITEM: u16 = 0x02A8; // 680 + +// The taxi/flight-master set (VERIFIED vmangos `Opcodes_1_12_1.h`: 425-431 the status/menu/ +// activate family, 786 the express-route activate — added later in the opcode table but the same +// wire family, kept here rather than off with the vendor-era outliers). Guid fields on every one +// of these (both directions) are a PLAIN u64, never a packed guid (vmangos `ObjectGuid::operator +// >>/<<`, `ObjectGuid.cpp:174-186` — `PackedGuid` is a distinct type these packets never use). +// Bodies in [`super::taxi`] (decision 0484). +pub const SMSG_SHOWTAXINODES: u16 = 0x01A9; // 425 +pub const CMSG_TAXINODE_STATUS_QUERY: u16 = 0x01AA; // 426 +pub const SMSG_TAXINODE_STATUS: u16 = 0x01AB; // 427 +pub const CMSG_TAXIQUERYAVAILABLENODES: u16 = 0x01AC; // 428 +pub const CMSG_ACTIVATETAXI: u16 = 0x01AD; // 429 +pub const SMSG_ACTIVATETAXIREPLY: u16 = 0x01AE; // 430 +pub const SMSG_NEW_TAXI_PATH: u16 = 0x01AF; // 431 +/// `> CLIENT_BUILD_1_9_4` — active for 5875. Multi-hop route activate (guid, totalcost, node +/// list); a single hop still goes through plain `CMSG_ACTIVATETAXI` above (vmangos accepts both). +pub const CMSG_ACTIVATETAXIEXPRESS: u16 = 0x0312; // 786 + +// The class/profession trainer set (VERIFIED vmangos `Opcodes_1_12_1.h`: 432-436) — one window for +// both, reached through the gossip trainer option (`GOSSIP_OPTION_TRAINER`), not a trainer-specific +// open verb. `CMSG_TRAINER_LIST` requests/refreshes the list; buy is `CMSG_TRAINER_BUY_SPELL`. +// Bodies in [`super::trainer`] (decision 0237). +pub const CMSG_TRAINER_LIST: u16 = 0x01B0; // 432 +pub const SMSG_TRAINER_LIST: u16 = 0x01B1; // 433 +pub const CMSG_TRAINER_BUY_SPELL: u16 = 0x01B2; // 434 +pub const SMSG_TRAINER_BUY_SUCCEEDED: u16 = 0x01B3; // 435 +pub const SMSG_TRAINER_BUY_FAILED: u16 = 0x01B4; // 436 + +// The bank set (VERIFIED vmangos `Opcodes_1_12_1.h`: 439,440-441,642-643 — 0x01B5/0x01B6 +// `CMSG_BINDER_ACTIVATE`/`SMSG_PLAYERBINDERROR` sit between the trainer set and this one but are +// out of scope, decision 0604). `CMSG_BANKER_ACTIVATE` right-clicks a pure banker; +// `SMSG_SHOW_BANK` also arrives unprompted from the `GOSSIP_OPTION_BANKER` gossip option. Slot +// purchase and deposit/withdraw bodies in [`super::bank`]. +pub const CMSG_BANKER_ACTIVATE: u16 = 0x01B7; // 439 +pub const SMSG_SHOW_BANK: u16 = 0x01B8; // 440 +pub const CMSG_BUY_BANK_SLOT: u16 = 0x01B9; // 441 +pub const SMSG_BUY_BANK_SLOT_RESULT: u16 = 0x01BA; // 442 +pub const CMSG_AUTOSTORE_BANK_ITEM: u16 = 0x0282; // 642 +pub const CMSG_AUTOBANK_ITEM: u16 = 0x0283; // 643 + +/// Spend talent points (VERIFIED vmangos `Opcodes_1_12_1.h`: 593) — body in +/// [`super::spells::learn_talent`]; the server answers with the rank spell's learn effects +/// (`SMSG_LEARNED_SPELL` etc.) and the refreshed `PLAYER_CHARACTER_POINTS1`. Decision 0304. +pub const CMSG_LEARN_TALENT: u16 = 0x0251; // 593 + +/// Unlearn (abandon) a whole skill line — the skills pane's red circle-slash (VERIFIED vmangos +/// `Opcodes.cpp` handler registration + `Opcodes_1_9_4.h`: 514). Body in +/// [`super::skills::unlearn_skill`]; no ack — the removal returns as a `PLAYER_SKILL_INFO` +/// field update. +pub const CMSG_UNLEARN_SKILL: u16 = 0x0202; // 514 + +// The solo-loot wire family (VERIFIED vmangos `Opcodes_1_12_1.h`: 264, 349-355, 357-358). +// Bodies in [`super::loot`]. +pub const CMSG_AUTOSTORE_LOOT_ITEM: u16 = 0x0108; // 264 +pub const CMSG_LOOT: u16 = 0x015D; // 349 +pub const CMSG_LOOT_MONEY: u16 = 0x015E; // 350 +pub const CMSG_LOOT_RELEASE: u16 = 0x015F; // 351 +pub const SMSG_LOOT_RESPONSE: u16 = 0x0160; // 352 +pub const SMSG_LOOT_RELEASE_RESPONSE: u16 = 0x0161; // 353 +pub const SMSG_LOOT_REMOVED: u16 = 0x0162; // 354 +pub const SMSG_LOOT_MONEY_NOTIFY: u16 = 0x0163; // 355 +pub const SMSG_LOOT_CLEAR_MONEY: u16 = 0x0165; // 357 +pub const SMSG_ITEM_PUSH_RESULT: u16 = 0x0166; // 358 + +// The group-loot roll family (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`) — the Need/Greed/Pass +// flow the `GroupLootFrame`s drive when the group's loot method is group/need-before-greed and a +// drop is at or above the quality threshold (decision 0591). Bodies in [`super::loot`]. Master +// loot (`CMSG_LOOT_MASTER_GIVE` 675 / `SMSG_LOOT_MASTER_LIST` 676) stays out of scope. +pub const SMSG_LOOT_ALL_PASSED: u16 = 0x029E; // 670 +pub const SMSG_LOOT_ROLL_WON: u16 = 0x029F; // 671 +pub const CMSG_LOOT_ROLL: u16 = 0x02A0; // 672 +pub const SMSG_LOOT_START_ROLL: u16 = 0x02A1; // 673 +pub const SMSG_LOOT_ROLL: u16 = 0x02A2; // 674 + +// The death arc (decision 0308) — release/repop, corpse query, reclaim, spirit healer, resurrect +// requests (all VERIFIED vmangos `Opcodes_1_12_1.h`: 346-348, 466, 534, 540, 546, 617). Bodies in +// [`super::death`]. `CMSG_REPOP_REQUEST` and our `MSG_CORPSE_QUERY` request are EMPTY bodies. +pub const CMSG_REPOP_REQUEST: u16 = 0x015A; // 346 +pub const SMSG_RESURRECT_REQUEST: u16 = 0x015B; // 347 +pub const CMSG_RESURRECT_RESPONSE: u16 = 0x015C; // 348 +pub const CMSG_RECLAIM_CORPSE: u16 = 0x01D2; // 466 +pub const MSG_CORPSE_QUERY: u16 = 0x0216; // 534 +pub const CMSG_SPIRIT_HEALER_ACTIVATE: u16 = 0x021C; // 540 +pub const SMSG_SPIRIT_HEALER_CONFIRM: u16 = 0x0222; // 546 +pub const SMSG_CORPSE_RECLAIM_DELAY: u16 = 0x0269; // 617 +/// Sent with the 10% durability loss a natural (non-PvP) death applies (VERIFIED vmangos +/// `Opcodes_1_12_1.h` 701 + `Unit.cpp:1170-1182` — an EMPTY body; the client's cue for the red +/// "Your equipped items suffer a 10%% durability loss." error line). +pub const SMSG_DURABILITY_DAMAGE_DEATH: u16 = 0x02BD; // 701 + +// The ack'd movement-flag family the death arc exercises (root at death / unroot + water-walk at +// release — VERIFIED vmangos `Opcodes_1_12_1.h`: 222-223, 232-235, 720 and +// `MovementPacketSender.cpp:306-397`). The SMSGs to the mover carry `packed guid + u32 counter`; +// the acks echo `full u64 guid + u32 counter + MovementInfo` (+ a trailing `u32 apply` on the +// water-walk ack only — `Server/Packets/Movement.cpp:38-59`). Un-acked changes never reach +// observers, and wrong/zero counters trip the server's cheat log — the counter must be echoed. +pub const SMSG_MOVE_WATER_WALK: u16 = 0x00DE; // 222 +pub const SMSG_MOVE_LAND_WALK: u16 = 0x00DF; // 223 +pub const CMSG_MOVE_WATER_WALK_ACK: u16 = 0x02D0; // 720 +pub const SMSG_FORCE_MOVE_ROOT: u16 = 0x00E8; // 232 +pub const CMSG_FORCE_MOVE_ROOT_ACK: u16 = 0x00E9; // 233 +pub const SMSG_FORCE_MOVE_UNROOT: u16 = 0x00EA; // 234 +pub const CMSG_FORCE_MOVE_UNROOT_ACK: u16 = 0x00EB; // 235 + +// The group/party family — invite/accept/decline/kick/leader/disband, the loot-method setting, the +// roster push (`SMSG_GROUP_LIST`), party command feedback, live member stats for the party/raid +// frame, minimap pings, raid subgroup management, raid-target icons, and ready checks (VERIFIED +// vmangos `Opcodes_1_12_1.h` + `Server/Packets/Group.{h,cpp}`, `Handlers/GroupHandler.cpp`, +// `Group/Group.{h,cpp}`). Bodies in [`super::group`]. +pub const CMSG_GROUP_INVITE: u16 = 0x006E; // 110 +pub const SMSG_GROUP_INVITE: u16 = 0x006F; // 111 +pub const CMSG_GROUP_ACCEPT: u16 = 0x0072; // 114 +pub const CMSG_GROUP_DECLINE: u16 = 0x0073; // 115 +pub const SMSG_GROUP_DECLINE: u16 = 0x0074; // 116 +pub const CMSG_GROUP_UNINVITE: u16 = 0x0075; // 117 +pub const CMSG_GROUP_UNINVITE_GUID: u16 = 0x0076; // 118 +pub const SMSG_GROUP_UNINVITE: u16 = 0x0077; // 119 +pub const CMSG_GROUP_SET_LEADER: u16 = 0x0078; // 120 +pub const SMSG_GROUP_SET_LEADER: u16 = 0x0079; // 121 +pub const CMSG_LOOT_METHOD: u16 = 0x007A; // 122 +pub const CMSG_GROUP_DISBAND: u16 = 0x007B; // 123 +pub const SMSG_GROUP_DESTROYED: u16 = 0x007C; // 124 +pub const SMSG_GROUP_LIST: u16 = 0x007D; // 125 +pub const SMSG_PARTY_MEMBER_STATS: u16 = 0x007E; // 126 +pub const SMSG_PARTY_COMMAND_RESULT: u16 = 0x007F; // 127 +/// Same opcode both directions (VERIFIED vmangos `Group.cpp:33-37` client read, `Handlers/ +/// GroupHandler.cpp:382-391` server rebroadcast): the client's own ping carries no guid, the +/// server stamps ours on before relaying to the rest of the group (see +/// [`super::group::minimap_ping`] / the parse arm). +pub const MSG_MINIMAP_PING: u16 = 0x01D5; // 469 +pub const CMSG_GROUP_CHANGE_SUB_GROUP: u16 = 0x027E; // 638 +pub const CMSG_REQUEST_PARTY_MEMBER_STATS: u16 = 0x027F; // 639 +pub const CMSG_GROUP_SWAP_SUB_GROUP: u16 = 0x0280; // 640 +pub const CMSG_GROUP_RAID_CONVERT: u16 = 0x028E; // 654 +pub const CMSG_GROUP_ASSISTANT_LEADER: u16 = 0x028F; // 655 +pub const SMSG_PARTY_MEMBER_STATS_FULL: u16 = 0x02F2; // 754 +/// Same opcode both directions, mode-prefixed shapes on the server's side (VERIFIED vmangos +/// `Server/Packets/Group.cpp:77-82` client read, `:132-147` server write; see +/// [`super::group::RaidTargetUpdate`]). +pub const MSG_RAID_TARGET_UPDATE: u16 = 0x0321; // 801 +/// Same opcode both directions — an empty body starts/requests, a non-empty one answers (VERIFIED +/// vmangos `Server/Packets/Group.cpp:84-96`, `:126-130`; see [`super::group::ReadyCheck`]). +pub const MSG_RAID_READY_CHECK: u16 = 0x0322; // 802 + +// The mail arc (decision 0544; VERIFIED vmangos `Opcodes_1_12_1.h` + `Handlers/MailHandler.cpp`). +// There is no `SMSG_SHOW_MAILBOX` on 5875 — the mailbox window opens entirely client-side; every +// CMSG below carries the mailbox guid at its head, and the server independently re-validates the +// 5 yd interact distance (`CheckMailBox`) on each one. Bodies in [`super::mail`]. +pub const CMSG_SEND_MAIL: u16 = 0x0238; // 568 +pub const SMSG_SEND_MAIL_RESULT: u16 = 0x0239; // 569 +pub const CMSG_GET_MAIL_LIST: u16 = 0x023A; // 570 +pub const SMSG_MAIL_LIST_RESULT: u16 = 0x023B; // 571 +/// Letter bodies never ride the list inline — the client fetches them ask-once by `itemTextId`. +pub const CMSG_ITEM_TEXT_QUERY: u16 = 0x0243; // 579 +pub const SMSG_ITEM_TEXT_QUERY_RESPONSE: u16 = 0x0244; // 580 +pub const CMSG_MAIL_TAKE_MONEY: u16 = 0x0245; // 581 +pub const CMSG_MAIL_TAKE_ITEM: u16 = 0x0246; // 582 +/// No response packet — the app flips the local `checked` READ bit and repaints. +pub const CMSG_MAIL_MARK_AS_READ: u16 = 0x0247; // 583 +pub const CMSG_MAIL_RETURN_TO_SENDER: u16 = 0x0248; // 584 +pub const CMSG_MAIL_DELETE: u16 = 0x0249; // 585 +pub const CMSG_MAIL_CREATE_TEXT_ITEM: u16 = 0x024A; // 586 +/// Same opcode both directions (VERIFIED vmangos `Opcodes_1_12_1.h`: 644): our request is an EMPTY +/// body; the reply (same opcode) is one `f32` — `0.0` unread mail waiting, `-86400.0` none. +pub const MSG_QUERY_NEXT_MAIL_TIME: u16 = 0x0284; // 644 +/// One `u32` (always 0) — sent when a mail *arrives* (instant for text-only, on the delivery +/// timer's expiry otherwise). +pub const SMSG_RECEIVED_MAIL: u16 = 0x0285; // 645 diff --git a/crates/benilla-protocol/src/messages/opcode_names.rs b/crates/benilla-protocol/src/messages/opcode_names.rs new file mode 100644 index 00000000..39bef30c --- /dev/null +++ b/crates/benilla-protocol/src/messages/opcode_names.rs @@ -0,0 +1,851 @@ +//! The full 1.12.1 world-opcode name table — **generated** from vmangos +//! `Server/Protocol/Opcodes_1_12_1.h` (825 entries, deduped by number, sorted): +//! +//! ```sh +//! sed -nE 's/^[[:space:]]+([A-Z_0-9]+)[[:space:]]*=[[:space:]]*([0-9]+),.*/\2 \1/p' \ +//! Opcodes_1_12_1.h | grep -v ' NUM_MSG_TYPES$' | sort -n -u \ +//! | awk '!seen[$1]++ {printf " (0x%04X, \"%s\"),\n", $1, $2}' +//! ``` +//! +//! The 1.12.1 wire is frozen, so this table can never go stale. It exists for the +//! *instruments* — naming an opcode in the dropped-packet tally (the debug panel's +//! wire-coverage gap detector) and in skip logs — never for dispatch (that's +//! [`super::opcode`]'s explicitly-verified constants). + +/// Look up the canonical 1.12.1 name of a world opcode (either direction), if the number is +/// assigned at all. O(log n) over the sorted table. +pub fn opcode_name(opcode: u16) -> Option<&'static str> { + NAMES + .binary_search_by_key(&opcode, |&(n, _)| n) + .ok() + .map(|i| NAMES[i].1) +} + +#[rustfmt::skip] +static NAMES: &[(u16, &str)] = &[ + (0x0000, "MSG_NULL_ACTION"), + (0x0001, "CMSG_BOOTME"), + (0x0002, "CMSG_DBLOOKUP"), + (0x0003, "SMSG_DBLOOKUP"), + (0x0004, "CMSG_QUERY_OBJECT_POSITION"), + (0x0005, "SMSG_QUERY_OBJECT_POSITION"), + (0x0006, "CMSG_QUERY_OBJECT_ROTATION"), + (0x0007, "SMSG_QUERY_OBJECT_ROTATION"), + (0x0008, "CMSG_WORLD_TELEPORT"), + (0x0009, "CMSG_TELEPORT_TO_UNIT"), + (0x000A, "CMSG_ZONE_MAP"), + (0x000B, "SMSG_ZONE_MAP"), + (0x000C, "CMSG_DEBUG_CHANGECELLZONE"), + (0x000D, "CMSG_EMBLAZON_TABARD_OBSOLETE"), + (0x000E, "CMSG_UNEMBLAZON_TABARD_OBSOLETE"), + (0x000F, "CMSG_RECHARGE"), + (0x0010, "CMSG_LEARN_SPELL"), + (0x0011, "CMSG_CREATEMONSTER"), + (0x0012, "CMSG_DESTROYMONSTER"), + (0x0013, "CMSG_CREATEITEM"), + (0x0014, "CMSG_CREATEGAMEOBJECT"), + (0x0015, "SMSG_CHECK_FOR_BOTS"), + (0x0016, "CMSG_MAKEMONSTERATTACKGUID"), + (0x0017, "CMSG_BOT_DETECTED2"), + (0x0018, "CMSG_FORCEACTION"), + (0x0019, "CMSG_FORCEACTIONONOTHER"), + (0x001A, "CMSG_FORCEACTIONSHOW"), + (0x001B, "SMSG_FORCEACTIONSHOW"), + (0x001C, "CMSG_PETGODMODE"), + (0x001D, "SMSG_PETGODMODE"), + (0x001E, "SMSG_DEBUGINFOSPELLMISS_OBSOLETE"), + (0x001F, "CMSG_WEATHER_SPEED_CHEAT"), + (0x0020, "CMSG_UNDRESSPLAYER"), + (0x0021, "CMSG_BEASTMASTER"), + (0x0022, "CMSG_GODMODE"), + (0x0023, "SMSG_GODMODE"), + (0x0024, "CMSG_CHEAT_SETMONEY"), + (0x0025, "CMSG_LEVEL_CHEAT"), + (0x0026, "CMSG_PET_LEVEL_CHEAT"), + (0x0027, "CMSG_SET_WORLDSTATE"), + (0x0028, "CMSG_COOLDOWN_CHEAT"), + (0x0029, "CMSG_USE_SKILL_CHEAT"), + (0x002A, "CMSG_FLAG_QUEST"), + (0x002B, "CMSG_FLAG_QUEST_FINISH"), + (0x002C, "CMSG_CLEAR_QUEST"), + (0x002D, "CMSG_SEND_EVENT"), + (0x002E, "CMSG_DEBUG_AISTATE"), + (0x002F, "SMSG_DEBUG_AISTATE"), + (0x0030, "CMSG_DISABLE_PVP_CHEAT"), + (0x0031, "CMSG_ADVANCE_SPAWN_TIME"), + (0x0032, "CMSG_PVP_PORT_OBSOLETE"), + (0x0033, "CMSG_AUTH_SRP6_BEGIN"), + (0x0034, "CMSG_AUTH_SRP6_PROOF"), + (0x0035, "CMSG_AUTH_SRP6_RECODE"), + (0x0036, "CMSG_CHAR_CREATE"), + (0x0037, "CMSG_CHAR_ENUM"), + (0x0038, "CMSG_CHAR_DELETE"), + (0x0039, "SMSG_AUTH_SRP6_RESPONSE"), + (0x003A, "SMSG_CHAR_CREATE"), + (0x003B, "SMSG_CHAR_ENUM"), + (0x003C, "SMSG_CHAR_DELETE"), + (0x003D, "CMSG_PLAYER_LOGIN"), + (0x003E, "SMSG_NEW_WORLD"), + (0x003F, "SMSG_TRANSFER_PENDING"), + (0x0040, "SMSG_TRANSFER_ABORTED"), + (0x0041, "SMSG_CHARACTER_LOGIN_FAILED"), + (0x0042, "SMSG_LOGIN_SETTIMESPEED"), + (0x0043, "SMSG_GAMETIME_UPDATE"), + (0x0044, "CMSG_GAMETIME_SET"), + (0x0045, "SMSG_GAMETIME_SET"), + (0x0046, "CMSG_GAMESPEED_SET"), + (0x0047, "SMSG_GAMESPEED_SET"), + (0x0048, "CMSG_SERVERTIME"), + (0x0049, "SMSG_SERVERTIME"), + (0x004A, "CMSG_PLAYER_LOGOUT"), + (0x004B, "CMSG_LOGOUT_REQUEST"), + (0x004C, "SMSG_LOGOUT_RESPONSE"), + (0x004D, "SMSG_LOGOUT_COMPLETE"), + (0x004E, "CMSG_LOGOUT_CANCEL"), + (0x004F, "SMSG_LOGOUT_CANCEL_ACK"), + (0x0050, "CMSG_NAME_QUERY"), + (0x0051, "SMSG_NAME_QUERY_RESPONSE"), + (0x0052, "CMSG_PET_NAME_QUERY"), + (0x0053, "SMSG_PET_NAME_QUERY_RESPONSE"), + (0x0054, "CMSG_GUILD_QUERY"), + (0x0055, "SMSG_GUILD_QUERY_RESPONSE"), + (0x0056, "CMSG_ITEM_QUERY_SINGLE"), + (0x0057, "CMSG_ITEM_QUERY_MULTIPLE"), + (0x0058, "SMSG_ITEM_QUERY_SINGLE_RESPONSE"), + (0x0059, "SMSG_ITEM_QUERY_MULTIPLE_RESPONSE"), + (0x005A, "CMSG_PAGE_TEXT_QUERY"), + (0x005B, "SMSG_PAGE_TEXT_QUERY_RESPONSE"), + (0x005C, "CMSG_QUEST_QUERY"), + (0x005D, "SMSG_QUEST_QUERY_RESPONSE"), + (0x005E, "CMSG_GAMEOBJECT_QUERY"), + (0x005F, "SMSG_GAMEOBJECT_QUERY_RESPONSE"), + (0x0060, "CMSG_CREATURE_QUERY"), + (0x0061, "SMSG_CREATURE_QUERY_RESPONSE"), + (0x0062, "CMSG_WHO"), + (0x0063, "SMSG_WHO"), + (0x0064, "CMSG_WHOIS"), + (0x0065, "SMSG_WHOIS"), + (0x0066, "CMSG_FRIEND_LIST"), + (0x0067, "SMSG_FRIEND_LIST"), + (0x0068, "SMSG_FRIEND_STATUS"), + (0x0069, "CMSG_ADD_FRIEND"), + (0x006A, "CMSG_DEL_FRIEND"), + (0x006B, "SMSG_IGNORE_LIST"), + (0x006C, "CMSG_ADD_IGNORE"), + (0x006D, "CMSG_DEL_IGNORE"), + (0x006E, "CMSG_GROUP_INVITE"), + (0x006F, "SMSG_GROUP_INVITE"), + (0x0070, "CMSG_GROUP_CANCEL"), + (0x0071, "SMSG_GROUP_CANCEL"), + (0x0072, "CMSG_GROUP_ACCEPT"), + (0x0073, "CMSG_GROUP_DECLINE"), + (0x0074, "SMSG_GROUP_DECLINE"), + (0x0075, "CMSG_GROUP_UNINVITE"), + (0x0076, "CMSG_GROUP_UNINVITE_GUID"), + (0x0077, "SMSG_GROUP_UNINVITE"), + (0x0078, "CMSG_GROUP_SET_LEADER"), + (0x0079, "SMSG_GROUP_SET_LEADER"), + (0x007A, "CMSG_LOOT_METHOD"), + (0x007B, "CMSG_GROUP_DISBAND"), + (0x007C, "SMSG_GROUP_DESTROYED"), + (0x007D, "SMSG_GROUP_LIST"), + (0x007E, "SMSG_PARTY_MEMBER_STATS"), + (0x007F, "SMSG_PARTY_COMMAND_RESULT"), + (0x0080, "MSG_UPDATE_GROUP_MEMBERS"), + (0x0081, "CMSG_GUILD_CREATE"), + (0x0082, "CMSG_GUILD_INVITE"), + (0x0083, "SMSG_GUILD_INVITE"), + (0x0084, "CMSG_GUILD_ACCEPT"), + (0x0085, "CMSG_GUILD_DECLINE"), + (0x0086, "SMSG_GUILD_DECLINE"), + (0x0087, "CMSG_GUILD_INFO"), + (0x0088, "SMSG_GUILD_INFO"), + (0x0089, "CMSG_GUILD_ROSTER"), + (0x008A, "SMSG_GUILD_ROSTER"), + (0x008B, "CMSG_GUILD_PROMOTE"), + (0x008C, "CMSG_GUILD_DEMOTE"), + (0x008D, "CMSG_GUILD_LEAVE"), + (0x008E, "CMSG_GUILD_REMOVE"), + (0x008F, "CMSG_GUILD_DISBAND"), + (0x0090, "CMSG_GUILD_LEADER"), + (0x0091, "CMSG_GUILD_MOTD"), + (0x0092, "SMSG_GUILD_EVENT"), + (0x0093, "SMSG_GUILD_COMMAND_RESULT"), + (0x0094, "MSG_UPDATE_GUILD"), + (0x0095, "CMSG_MESSAGECHAT"), + (0x0096, "SMSG_MESSAGECHAT"), + (0x0097, "CMSG_JOIN_CHANNEL"), + (0x0098, "CMSG_LEAVE_CHANNEL"), + (0x0099, "SMSG_CHANNEL_NOTIFY"), + (0x009A, "CMSG_CHANNEL_LIST"), + (0x009B, "SMSG_CHANNEL_LIST"), + (0x009C, "CMSG_CHANNEL_PASSWORD"), + (0x009D, "CMSG_CHANNEL_SET_OWNER"), + (0x009E, "CMSG_CHANNEL_OWNER"), + (0x009F, "CMSG_CHANNEL_MODERATOR"), + (0x00A0, "CMSG_CHANNEL_UNMODERATOR"), + (0x00A1, "CMSG_CHANNEL_MUTE"), + (0x00A2, "CMSG_CHANNEL_UNMUTE"), + (0x00A3, "CMSG_CHANNEL_INVITE"), + (0x00A4, "CMSG_CHANNEL_KICK"), + (0x00A5, "CMSG_CHANNEL_BAN"), + (0x00A6, "CMSG_CHANNEL_UNBAN"), + (0x00A7, "CMSG_CHANNEL_ANNOUNCEMENTS"), + (0x00A8, "CMSG_CHANNEL_MODERATE"), + (0x00A9, "SMSG_UPDATE_OBJECT"), + (0x00AA, "SMSG_DESTROY_OBJECT"), + (0x00AB, "CMSG_USE_ITEM"), + (0x00AC, "CMSG_OPEN_ITEM"), + (0x00AD, "CMSG_READ_ITEM"), + (0x00AE, "SMSG_READ_ITEM_OK"), + (0x00AF, "SMSG_READ_ITEM_FAILED"), + (0x00B0, "SMSG_ITEM_COOLDOWN"), + (0x00B1, "CMSG_GAMEOBJ_USE"), + (0x00B2, "CMSG_GAMEOBJ_CHAIR_USE_OBSOLETE"), + (0x00B3, "SMSG_GAMEOBJECT_CUSTOM_ANIM"), + (0x00B4, "CMSG_AREATRIGGER"), + (0x00B5, "MSG_MOVE_START_FORWARD"), + (0x00B6, "MSG_MOVE_START_BACKWARD"), + (0x00B7, "MSG_MOVE_STOP"), + (0x00B8, "MSG_MOVE_START_STRAFE_LEFT"), + (0x00B9, "MSG_MOVE_START_STRAFE_RIGHT"), + (0x00BA, "MSG_MOVE_STOP_STRAFE"), + (0x00BB, "MSG_MOVE_JUMP"), + (0x00BC, "MSG_MOVE_START_TURN_LEFT"), + (0x00BD, "MSG_MOVE_START_TURN_RIGHT"), + (0x00BE, "MSG_MOVE_STOP_TURN"), + (0x00BF, "MSG_MOVE_START_PITCH_UP"), + (0x00C0, "MSG_MOVE_START_PITCH_DOWN"), + (0x00C1, "MSG_MOVE_STOP_PITCH"), + (0x00C2, "MSG_MOVE_SET_RUN_MODE"), + (0x00C3, "MSG_MOVE_SET_WALK_MODE"), + (0x00C4, "MSG_MOVE_TOGGLE_LOGGING"), + (0x00C5, "MSG_MOVE_TELEPORT"), + (0x00C6, "MSG_MOVE_TELEPORT_CHEAT"), + (0x00C7, "MSG_MOVE_TELEPORT_ACK"), + (0x00C8, "MSG_MOVE_TOGGLE_FALL_LOGGING"), + (0x00C9, "MSG_MOVE_FALL_LAND"), + (0x00CA, "MSG_MOVE_START_SWIM"), + (0x00CB, "MSG_MOVE_STOP_SWIM"), + (0x00CC, "MSG_MOVE_SET_RUN_SPEED_CHEAT"), + (0x00CD, "MSG_MOVE_SET_RUN_SPEED"), + (0x00CE, "MSG_MOVE_SET_RUN_BACK_SPEED_CHEAT"), + (0x00CF, "MSG_MOVE_SET_RUN_BACK_SPEED"), + (0x00D0, "MSG_MOVE_SET_WALK_SPEED_CHEAT"), + (0x00D1, "MSG_MOVE_SET_WALK_SPEED"), + (0x00D2, "MSG_MOVE_SET_SWIM_SPEED_CHEAT"), + (0x00D3, "MSG_MOVE_SET_SWIM_SPEED"), + (0x00D4, "MSG_MOVE_SET_SWIM_BACK_SPEED_CHEAT"), + (0x00D5, "MSG_MOVE_SET_SWIM_BACK_SPEED"), + (0x00D6, "MSG_MOVE_SET_ALL_SPEED_CHEAT"), + (0x00D7, "MSG_MOVE_SET_TURN_RATE_CHEAT"), + (0x00D8, "MSG_MOVE_SET_TURN_RATE"), + (0x00D9, "MSG_MOVE_TOGGLE_COLLISION_CHEAT"), + (0x00DA, "MSG_MOVE_SET_FACING"), + (0x00DB, "MSG_MOVE_SET_PITCH"), + (0x00DC, "MSG_MOVE_WORLDPORT_ACK"), + (0x00DD, "SMSG_MONSTER_MOVE"), + (0x00DE, "SMSG_MOVE_WATER_WALK"), + (0x00DF, "SMSG_MOVE_LAND_WALK"), + (0x00E0, "MSG_MOVE_SET_RAW_POSITION_ACK"), + (0x00E1, "CMSG_MOVE_SET_RAW_POSITION"), + (0x00E2, "SMSG_FORCE_RUN_SPEED_CHANGE"), + (0x00E3, "CMSG_FORCE_RUN_SPEED_CHANGE_ACK"), + (0x00E4, "SMSG_FORCE_RUN_BACK_SPEED_CHANGE"), + (0x00E5, "CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK"), + (0x00E6, "SMSG_FORCE_SWIM_SPEED_CHANGE"), + (0x00E7, "CMSG_FORCE_SWIM_SPEED_CHANGE_ACK"), + (0x00E8, "SMSG_FORCE_MOVE_ROOT"), + (0x00E9, "CMSG_FORCE_MOVE_ROOT_ACK"), + (0x00EA, "SMSG_FORCE_MOVE_UNROOT"), + (0x00EB, "CMSG_FORCE_MOVE_UNROOT_ACK"), + (0x00EC, "MSG_MOVE_ROOT"), + (0x00ED, "MSG_MOVE_UNROOT"), + (0x00EE, "MSG_MOVE_HEARTBEAT"), + (0x00EF, "SMSG_MOVE_KNOCK_BACK"), + (0x00F0, "CMSG_MOVE_KNOCK_BACK_ACK"), + (0x00F1, "MSG_MOVE_KNOCK_BACK"), + (0x00F2, "SMSG_MOVE_FEATHER_FALL"), + (0x00F3, "SMSG_MOVE_NORMAL_FALL"), + (0x00F4, "SMSG_MOVE_SET_HOVER"), + (0x00F5, "SMSG_MOVE_UNSET_HOVER"), + (0x00F6, "CMSG_MOVE_HOVER_ACK"), + (0x00F7, "MSG_MOVE_HOVER"), + (0x00F8, "CMSG_TRIGGER_CINEMATIC_CHEAT"), + (0x00F9, "CMSG_OPENING_CINEMATIC"), + (0x00FA, "SMSG_TRIGGER_CINEMATIC"), + (0x00FB, "CMSG_NEXT_CINEMATIC_CAMERA"), + (0x00FC, "CMSG_COMPLETE_CINEMATIC"), + (0x00FD, "SMSG_TUTORIAL_FLAGS"), + (0x00FE, "CMSG_TUTORIAL_FLAG"), + (0x00FF, "CMSG_TUTORIAL_CLEAR"), + (0x0100, "CMSG_TUTORIAL_RESET"), + (0x0101, "CMSG_STANDSTATECHANGE"), + (0x0102, "CMSG_EMOTE"), + (0x0103, "SMSG_EMOTE"), + (0x0104, "CMSG_TEXT_EMOTE"), + (0x0105, "SMSG_TEXT_EMOTE"), + (0x0106, "CMSG_AUTOEQUIP_GROUND_ITEM"), + (0x0107, "CMSG_AUTOSTORE_GROUND_ITEM"), + (0x0108, "CMSG_AUTOSTORE_LOOT_ITEM"), + (0x0109, "CMSG_STORE_LOOT_IN_SLOT"), + (0x010A, "CMSG_AUTOEQUIP_ITEM"), + (0x010B, "CMSG_AUTOSTORE_BAG_ITEM"), + (0x010C, "CMSG_SWAP_ITEM"), + (0x010D, "CMSG_SWAP_INV_ITEM"), + (0x010E, "CMSG_SPLIT_ITEM"), + (0x010F, "CMSG_AUTOEQUIP_ITEM_SLOT"), + (0x0110, "CMSG_DROP_ITEM"), + (0x0111, "CMSG_DESTROYITEM"), + (0x0112, "SMSG_INVENTORY_CHANGE_FAILURE"), + (0x0113, "SMSG_OPEN_CONTAINER"), + (0x0114, "CMSG_INSPECT"), + (0x0115, "SMSG_INSPECT"), + (0x0116, "CMSG_INITIATE_TRADE"), + (0x0117, "CMSG_BEGIN_TRADE"), + (0x0118, "CMSG_BUSY_TRADE"), + (0x0119, "CMSG_IGNORE_TRADE"), + (0x011A, "CMSG_ACCEPT_TRADE"), + (0x011B, "CMSG_UNACCEPT_TRADE"), + (0x011C, "CMSG_CANCEL_TRADE"), + (0x011D, "CMSG_SET_TRADE_ITEM"), + (0x011E, "CMSG_CLEAR_TRADE_ITEM"), + (0x011F, "CMSG_SET_TRADE_GOLD"), + (0x0120, "SMSG_TRADE_STATUS"), + (0x0121, "SMSG_TRADE_STATUS_EXTENDED"), + (0x0122, "SMSG_INITIALIZE_FACTIONS"), + (0x0123, "SMSG_SET_FACTION_VISIBLE"), + (0x0124, "SMSG_SET_FACTION_STANDING"), + (0x0125, "CMSG_SET_FACTION_ATWAR"), + (0x0126, "CMSG_SET_FACTION_CHEAT"), + (0x0127, "SMSG_SET_PROFICIENCY"), + (0x0128, "CMSG_SET_ACTION_BUTTON"), + (0x0129, "SMSG_ACTION_BUTTONS"), + (0x012A, "SMSG_INITIAL_SPELLS"), + (0x012B, "SMSG_LEARNED_SPELL"), + (0x012C, "SMSG_SUPERCEDED_SPELL"), + (0x012D, "CMSG_NEW_SPELL_SLOT"), + (0x012E, "CMSG_CAST_SPELL"), + (0x012F, "CMSG_CANCEL_CAST"), + (0x0130, "SMSG_CAST_RESULT"), + (0x0131, "SMSG_SPELL_START"), + (0x0132, "SMSG_SPELL_GO"), + (0x0133, "SMSG_SPELL_FAILURE"), + (0x0134, "SMSG_SPELL_COOLDOWN"), + (0x0135, "SMSG_COOLDOWN_EVENT"), + (0x0136, "CMSG_CANCEL_AURA"), + (0x0137, "SMSG_UPDATE_AURA_DURATION"), + (0x0138, "SMSG_PET_CAST_FAILED"), + (0x0139, "MSG_CHANNEL_START"), + (0x013A, "MSG_CHANNEL_UPDATE"), + (0x013B, "CMSG_CANCEL_CHANNELLING"), + (0x013C, "SMSG_AI_REACTION"), + (0x013D, "CMSG_SET_SELECTION"), + (0x013E, "CMSG_SET_TARGET_OBSOLETE"), + (0x0141, "CMSG_ATTACKSWING"), + (0x0142, "CMSG_ATTACKSTOP"), + (0x0143, "SMSG_ATTACKSTART"), + (0x0144, "SMSG_ATTACKSTOP"), + (0x0145, "SMSG_ATTACKSWING_NOTINRANGE"), + (0x0146, "SMSG_ATTACKSWING_BADFACING"), + (0x0147, "SMSG_ATTACKSWING_NOTSTANDING"), + (0x0148, "SMSG_ATTACKSWING_DEADTARGET"), + (0x0149, "SMSG_ATTACKSWING_CANT_ATTACK"), + (0x014A, "SMSG_ATTACKERSTATEUPDATE"), + (0x014B, "SMSG_VICTIMSTATEUPDATE_OBSOLETE"), + (0x014C, "SMSG_DAMAGE_DONE_OBSOLETE"), + (0x014D, "SMSG_DAMAGE_TAKEN_OBSOLETE"), + (0x014E, "SMSG_CANCEL_COMBAT"), + (0x014F, "SMSG_PLAYER_COMBAT_XP_GAIN_OBSOLETE"), + (0x0150, "SMSG_SPELLHEALLOG"), + (0x0151, "SMSG_SPELLENERGIZELOG"), + (0x0152, "CMSG_SHEATHE_OBSOLETE"), + (0x0153, "CMSG_SAVE_PLAYER"), + (0x0154, "CMSG_SETDEATHBINDPOINT"), + (0x0155, "SMSG_BINDPOINTUPDATE"), + (0x0156, "CMSG_GETDEATHBINDZONE"), + (0x0157, "SMSG_BINDZONEREPLY"), + (0x0158, "SMSG_PLAYERBOUND"), + (0x0159, "SMSG_CLIENT_CONTROL_UPDATE"), + (0x015A, "CMSG_REPOP_REQUEST"), + (0x015B, "SMSG_RESURRECT_REQUEST"), + (0x015C, "CMSG_RESURRECT_RESPONSE"), + (0x015D, "CMSG_LOOT"), + (0x015E, "CMSG_LOOT_MONEY"), + (0x015F, "CMSG_LOOT_RELEASE"), + (0x0160, "SMSG_LOOT_RESPONSE"), + (0x0161, "SMSG_LOOT_RELEASE_RESPONSE"), + (0x0162, "SMSG_LOOT_REMOVED"), + (0x0163, "SMSG_LOOT_MONEY_NOTIFY"), + (0x0164, "SMSG_LOOT_ITEM_NOTIFY"), + (0x0165, "SMSG_LOOT_CLEAR_MONEY"), + (0x0166, "SMSG_ITEM_PUSH_RESULT"), + (0x0167, "SMSG_DUEL_REQUESTED"), + (0x0168, "SMSG_DUEL_OUTOFBOUNDS"), + (0x0169, "SMSG_DUEL_INBOUNDS"), + (0x016A, "SMSG_DUEL_COMPLETE"), + (0x016B, "SMSG_DUEL_WINNER"), + (0x016C, "CMSG_DUEL_ACCEPTED"), + (0x016D, "CMSG_DUEL_CANCELLED"), + (0x016E, "SMSG_MOUNTRESULT"), + (0x016F, "SMSG_DISMOUNTRESULT"), + (0x0170, "SMSG_PUREMOUNT_CANCELLED_OBSOLETE"), + (0x0171, "CMSG_MOUNTSPECIAL_ANIM"), + (0x0172, "SMSG_MOUNTSPECIAL_ANIM"), + (0x0173, "SMSG_PET_TAME_FAILURE"), + (0x0174, "CMSG_PET_SET_ACTION"), + (0x0175, "CMSG_PET_ACTION"), + (0x0176, "CMSG_PET_ABANDON"), + (0x0177, "CMSG_PET_RENAME"), + (0x0178, "SMSG_PET_NAME_INVALID"), + (0x0179, "SMSG_PET_SPELLS"), + (0x017A, "SMSG_PET_MODE"), + (0x017B, "CMSG_GOSSIP_HELLO"), + (0x017C, "CMSG_GOSSIP_SELECT_OPTION"), + (0x017D, "SMSG_GOSSIP_MESSAGE"), + (0x017E, "SMSG_GOSSIP_COMPLETE"), + (0x017F, "CMSG_NPC_TEXT_QUERY"), + (0x0180, "SMSG_NPC_TEXT_UPDATE"), + (0x0181, "SMSG_NPC_WONT_TALK"), + (0x0182, "CMSG_QUESTGIVER_STATUS_QUERY"), + (0x0183, "SMSG_QUESTGIVER_STATUS"), + (0x0184, "CMSG_QUESTGIVER_HELLO"), + (0x0185, "SMSG_QUESTGIVER_QUEST_LIST"), + (0x0186, "CMSG_QUESTGIVER_QUERY_QUEST"), + (0x0187, "CMSG_QUESTGIVER_QUEST_AUTOLAUNCH"), + (0x0188, "SMSG_QUESTGIVER_QUEST_DETAILS"), + (0x0189, "CMSG_QUESTGIVER_ACCEPT_QUEST"), + (0x018A, "CMSG_QUESTGIVER_COMPLETE_QUEST"), + (0x018B, "SMSG_QUESTGIVER_REQUEST_ITEMS"), + (0x018C, "CMSG_QUESTGIVER_REQUEST_REWARD"), + (0x018D, "SMSG_QUESTGIVER_OFFER_REWARD"), + (0x018E, "CMSG_QUESTGIVER_CHOOSE_REWARD"), + (0x018F, "SMSG_QUESTGIVER_QUEST_INVALID"), + (0x0190, "CMSG_QUESTGIVER_CANCEL"), + (0x0191, "SMSG_QUESTGIVER_QUEST_COMPLETE"), + (0x0192, "SMSG_QUESTGIVER_QUEST_FAILED"), + (0x0193, "CMSG_QUESTLOG_SWAP_QUEST"), + (0x0194, "CMSG_QUESTLOG_REMOVE_QUEST"), + (0x0195, "SMSG_QUESTLOG_FULL"), + (0x0196, "SMSG_QUESTUPDATE_FAILED"), + (0x0197, "SMSG_QUESTUPDATE_FAILEDTIMER"), + (0x0198, "SMSG_QUESTUPDATE_COMPLETE"), + (0x0199, "SMSG_QUESTUPDATE_ADD_KILL"), + (0x019A, "SMSG_QUESTUPDATE_ADD_ITEM"), + (0x019B, "CMSG_QUEST_CONFIRM_ACCEPT"), + (0x019C, "SMSG_QUEST_CONFIRM_ACCEPT"), + (0x019D, "CMSG_PUSHQUESTTOPARTY"), + (0x019E, "CMSG_LIST_INVENTORY"), + (0x019F, "SMSG_LIST_INVENTORY"), + (0x01A0, "CMSG_SELL_ITEM"), + (0x01A1, "SMSG_SELL_ITEM"), + (0x01A2, "CMSG_BUY_ITEM"), + (0x01A3, "CMSG_BUY_ITEM_IN_SLOT"), + (0x01A4, "SMSG_BUY_ITEM"), + (0x01A5, "SMSG_BUY_FAILED"), + (0x01A6, "CMSG_TAXICLEARALLNODES"), + (0x01A7, "CMSG_TAXIENABLEALLNODES"), + (0x01A8, "CMSG_TAXISHOWNODES"), + (0x01A9, "SMSG_SHOWTAXINODES"), + (0x01AA, "CMSG_TAXINODE_STATUS_QUERY"), + (0x01AB, "SMSG_TAXINODE_STATUS"), + (0x01AC, "CMSG_TAXIQUERYAVAILABLENODES"), + (0x01AD, "CMSG_ACTIVATETAXI"), + (0x01AE, "SMSG_ACTIVATETAXIREPLY"), + (0x01AF, "SMSG_NEW_TAXI_PATH"), + (0x01B0, "CMSG_TRAINER_LIST"), + (0x01B1, "SMSG_TRAINER_LIST"), + (0x01B2, "CMSG_TRAINER_BUY_SPELL"), + (0x01B3, "SMSG_TRAINER_BUY_SUCCEEDED"), + (0x01B4, "SMSG_TRAINER_BUY_FAILED"), + (0x01B5, "CMSG_BINDER_ACTIVATE"), + (0x01B6, "SMSG_PLAYERBINDERROR"), + (0x01B7, "CMSG_BANKER_ACTIVATE"), + (0x01B8, "SMSG_SHOW_BANK"), + (0x01B9, "CMSG_BUY_BANK_SLOT"), + (0x01BA, "SMSG_BUY_BANK_SLOT_RESULT"), + (0x01BB, "CMSG_PETITION_SHOWLIST"), + (0x01BC, "SMSG_PETITION_SHOWLIST"), + (0x01BD, "CMSG_PETITION_BUY"), + (0x01BE, "CMSG_PETITION_SHOW_SIGNATURES"), + (0x01BF, "SMSG_PETITION_SHOW_SIGNATURES"), + (0x01C0, "CMSG_PETITION_SIGN"), + (0x01C1, "SMSG_PETITION_SIGN_RESULTS"), + (0x01C2, "MSG_PETITION_DECLINE"), + (0x01C3, "CMSG_OFFER_PETITION"), + (0x01C4, "CMSG_TURN_IN_PETITION"), + (0x01C5, "SMSG_TURN_IN_PETITION_RESULTS"), + (0x01C6, "CMSG_PETITION_QUERY"), + (0x01C7, "SMSG_PETITION_QUERY_RESPONSE"), + (0x01C8, "SMSG_FISH_NOT_HOOKED"), + (0x01C9, "SMSG_FISH_ESCAPED"), + (0x01CA, "CMSG_BUG"), + (0x01CB, "SMSG_NOTIFICATION"), + (0x01CC, "CMSG_PLAYED_TIME"), + (0x01CD, "SMSG_PLAYED_TIME"), + (0x01CE, "CMSG_QUERY_TIME"), + (0x01CF, "SMSG_QUERY_TIME_RESPONSE"), + (0x01D0, "SMSG_LOG_XPGAIN"), + (0x01D1, "SMSG_AURACASTLOG"), + (0x01D2, "CMSG_RECLAIM_CORPSE"), + (0x01D3, "CMSG_WRAP_ITEM"), + (0x01D4, "SMSG_LEVELUP_INFO"), + (0x01D5, "MSG_MINIMAP_PING"), + (0x01D6, "SMSG_RESISTLOG"), + (0x01D7, "SMSG_ENCHANTMENTLOG"), + (0x01D8, "CMSG_SET_SKILL_CHEAT"), + (0x01D9, "SMSG_START_MIRROR_TIMER"), + (0x01DA, "SMSG_PAUSE_MIRROR_TIMER"), + (0x01DB, "SMSG_STOP_MIRROR_TIMER"), + (0x01DC, "CMSG_PING"), + (0x01DD, "SMSG_PONG"), + (0x01DE, "SMSG_CLEAR_COOLDOWN"), + (0x01DF, "SMSG_GAMEOBJECT_PAGETEXT"), + (0x01E0, "CMSG_SETSHEATHED"), + (0x01E1, "SMSG_COOLDOWN_CHEAT"), + (0x01E2, "SMSG_SPELL_DELAYED"), + (0x01E3, "CMSG_PLAYER_MACRO_OBSOLETE"), + (0x01E4, "SMSG_PLAYER_MACRO_OBSOLETE"), + (0x01E5, "CMSG_GHOST"), + (0x01E6, "CMSG_GM_INVIS"), + (0x01E7, "SMSG_INVALID_PROMOTION_CODE"), + (0x01E8, "MSG_GM_BIND_OTHER"), + (0x01E9, "MSG_GM_SUMMON"), + (0x01EA, "SMSG_ITEM_TIME_UPDATE"), + (0x01EB, "SMSG_ITEM_ENCHANT_TIME_UPDATE"), + (0x01EC, "SMSG_AUTH_CHALLENGE"), + (0x01ED, "CMSG_AUTH_SESSION"), + (0x01EE, "SMSG_AUTH_RESPONSE"), + (0x01EF, "MSG_GM_SHOWLABEL"), + (0x01F0, "CMSG_PET_CAST_SPELL"), + (0x01F1, "MSG_SAVE_GUILD_EMBLEM"), + (0x01F2, "MSG_TABARDVENDOR_ACTIVATE"), + (0x01F3, "SMSG_PLAY_SPELL_VISUAL"), + (0x01F4, "CMSG_ZONEUPDATE"), + (0x01F5, "SMSG_PARTYKILLLOG"), + (0x01F6, "SMSG_COMPRESSED_UPDATE_OBJECT"), + (0x01F7, "SMSG_PLAY_SPELL_IMPACT"), + (0x01F8, "SMSG_EXPLORATION_EXPERIENCE"), + (0x01F9, "CMSG_GM_SET_SECURITY_GROUP"), + (0x01FA, "CMSG_GM_NUKE"), + (0x01FB, "MSG_RANDOM_ROLL"), + (0x01FC, "SMSG_ENVIRONMENTALDAMAGELOG"), + (0x01FD, "CMSG_RWHOIS"), + (0x01FE, "SMSG_RWHOIS"), + (0x01FF, "MSG_LOOKING_FOR_GROUP"), + (0x0200, "CMSG_SET_LOOKING_FOR_GROUP"), + (0x0201, "CMSG_UNLEARN_SPELL"), + (0x0202, "CMSG_UNLEARN_SKILL"), + (0x0203, "SMSG_REMOVED_SPELL"), + (0x0204, "CMSG_DECHARGE"), + (0x0205, "CMSG_GMTICKET_CREATE"), + (0x0206, "SMSG_GMTICKET_CREATE"), + (0x0207, "CMSG_GMTICKET_UPDATETEXT"), + (0x0208, "SMSG_GMTICKET_UPDATETEXT"), + (0x0209, "SMSG_ACCOUNT_DATA_MD5"), + (0x020A, "CMSG_REQUEST_ACCOUNT_DATA"), + (0x020B, "CMSG_UPDATE_ACCOUNT_DATA"), + (0x020C, "SMSG_UPDATE_ACCOUNT_DATA"), + (0x020D, "SMSG_CLEAR_FAR_SIGHT_IMMEDIATE"), + (0x020E, "SMSG_POWERGAINLOG_OBSOLETE"), + (0x020F, "CMSG_GM_TEACH"), + (0x0210, "CMSG_GM_CREATE_ITEM_TARGET"), + (0x0211, "CMSG_GMTICKET_GETTICKET"), + (0x0212, "SMSG_GMTICKET_GETTICKET"), + (0x0213, "CMSG_UNLEARN_TALENTS"), + (0x0214, "SMSG_GAMEOBJECT_SPAWN_ANIM"), + (0x0215, "SMSG_GAMEOBJECT_DESPAWN_ANIM"), + (0x0216, "MSG_CORPSE_QUERY"), + (0x0217, "CMSG_GMTICKET_DELETETICKET"), + (0x0218, "SMSG_GMTICKET_DELETETICKET"), + (0x0219, "SMSG_CHAT_WRONG_FACTION"), + (0x021A, "CMSG_GMTICKET_SYSTEMSTATUS"), + (0x021B, "SMSG_GMTICKET_SYSTEMSTATUS"), + (0x021C, "CMSG_SPIRIT_HEALER_ACTIVATE"), + (0x021D, "CMSG_SET_STAT_CHEAT"), + (0x021E, "SMSG_SET_REST_START"), + (0x021F, "CMSG_SKILL_BUY_STEP"), + (0x0220, "CMSG_SKILL_BUY_RANK"), + (0x0221, "CMSG_XP_CHEAT"), + (0x0222, "SMSG_SPIRIT_HEALER_CONFIRM"), + (0x0223, "CMSG_CHARACTER_POINT_CHEAT"), + (0x0224, "SMSG_GOSSIP_POI"), + (0x0225, "CMSG_CHAT_IGNORED"), + (0x0226, "CMSG_GM_VISION"), + (0x0227, "CMSG_SERVER_COMMAND"), + (0x0228, "CMSG_GM_SILENCE"), + (0x0229, "CMSG_GM_REVEALTO"), + (0x022A, "CMSG_GM_RESURRECT"), + (0x022B, "CMSG_GM_SUMMONMOB"), + (0x022C, "CMSG_GM_MOVECORPSE"), + (0x022D, "CMSG_GM_FREEZE"), + (0x022E, "CMSG_GM_UBERINVIS"), + (0x022F, "CMSG_GM_REQUEST_PLAYER_INFO"), + (0x0230, "SMSG_GM_PLAYER_INFO"), + (0x0231, "CMSG_GUILD_RANK"), + (0x0232, "CMSG_GUILD_ADD_RANK"), + (0x0233, "CMSG_GUILD_DEL_RANK"), + (0x0234, "CMSG_GUILD_SET_PUBLIC_NOTE"), + (0x0235, "CMSG_GUILD_SET_OFFICER_NOTE"), + (0x0236, "SMSG_LOGIN_VERIFY_WORLD"), + (0x0237, "CMSG_CLEAR_EXPLORATION"), + (0x0238, "CMSG_SEND_MAIL"), + (0x0239, "SMSG_SEND_MAIL_RESULT"), + (0x023A, "CMSG_GET_MAIL_LIST"), + (0x023B, "SMSG_MAIL_LIST_RESULT"), + (0x023C, "CMSG_BATTLEFIELD_LIST"), + (0x023D, "SMSG_BATTLEFIELD_LIST"), + (0x023E, "CMSG_BATTLEFIELD_JOIN"), + (0x023F, "SMSG_BATTLEFIELD_WIN"), + (0x0240, "SMSG_BATTLEFIELD_LOSE"), + (0x0241, "CMSG_TAXICLEARNODE"), + (0x0242, "CMSG_TAXIENABLENODE"), + (0x0243, "CMSG_ITEM_TEXT_QUERY"), + (0x0244, "SMSG_ITEM_TEXT_QUERY_RESPONSE"), + (0x0245, "CMSG_MAIL_TAKE_MONEY"), + (0x0246, "CMSG_MAIL_TAKE_ITEM"), + (0x0247, "CMSG_MAIL_MARK_AS_READ"), + (0x0248, "CMSG_MAIL_RETURN_TO_SENDER"), + (0x0249, "CMSG_MAIL_DELETE"), + (0x024A, "CMSG_MAIL_CREATE_TEXT_ITEM"), + (0x024B, "SMSG_SPELLLOGMISS"), + (0x024C, "SMSG_SPELLLOGEXECUTE"), + (0x024D, "SMSG_DEBUGAURAPROC"), + (0x024E, "SMSG_PERIODICAURALOG"), + (0x024F, "SMSG_SPELLDAMAGESHIELD"), + (0x0250, "SMSG_SPELLNONMELEEDAMAGELOG"), + (0x0251, "CMSG_LEARN_TALENT"), + (0x0252, "SMSG_RESURRECT_FAILED"), + (0x0253, "CMSG_TOGGLE_PVP"), + (0x0254, "SMSG_ZONE_UNDER_ATTACK"), + (0x0255, "MSG_AUCTION_HELLO"), + (0x0256, "CMSG_AUCTION_SELL_ITEM"), + (0x0257, "CMSG_AUCTION_REMOVE_ITEM"), + (0x0258, "CMSG_AUCTION_LIST_ITEMS"), + (0x0259, "CMSG_AUCTION_LIST_OWNER_ITEMS"), + (0x025A, "CMSG_AUCTION_PLACE_BID"), + (0x025B, "SMSG_AUCTION_COMMAND_RESULT"), + (0x025C, "SMSG_AUCTION_LIST_RESULT"), + (0x025D, "SMSG_AUCTION_OWNER_LIST_RESULT"), + (0x025E, "SMSG_AUCTION_BIDDER_NOTIFICATION"), + (0x025F, "SMSG_AUCTION_OWNER_NOTIFICATION"), + (0x0260, "SMSG_PROCRESIST"), + (0x0261, "SMSG_STANDSTATE_CHANGE_FAILURE"), + (0x0262, "SMSG_DISPEL_FAILED"), + (0x0263, "SMSG_SPELLORDAMAGE_IMMUNE"), + (0x0264, "CMSG_AUCTION_LIST_BIDDER_ITEMS"), + (0x0265, "SMSG_AUCTION_BIDDER_LIST_RESULT"), + (0x0266, "SMSG_SET_FLAT_SPELL_MODIFIER"), + (0x0267, "SMSG_SET_PCT_SPELL_MODIFIER"), + (0x0268, "CMSG_SET_AMMO"), + (0x0269, "SMSG_CORPSE_RECLAIM_DELAY"), + (0x026A, "CMSG_SET_ACTIVE_MOVER"), + (0x026B, "CMSG_PET_CANCEL_AURA"), + (0x026C, "CMSG_PLAYER_AI_CHEAT"), + (0x026D, "CMSG_CANCEL_AUTO_REPEAT_SPELL"), + (0x026E, "MSG_GM_ACCOUNT_ONLINE"), + (0x026F, "MSG_LIST_STABLED_PETS"), + (0x0270, "CMSG_STABLE_PET"), + (0x0271, "CMSG_UNSTABLE_PET"), + (0x0272, "CMSG_BUY_STABLE_SLOT"), + (0x0273, "SMSG_STABLE_RESULT"), + (0x0274, "CMSG_STABLE_REVIVE_PET"), + (0x0275, "CMSG_STABLE_SWAP_PET"), + (0x0276, "MSG_QUEST_PUSH_RESULT"), + (0x0277, "SMSG_PLAY_MUSIC"), + (0x0278, "SMSG_PLAY_OBJECT_SOUND"), + (0x0279, "CMSG_REQUEST_PET_INFO"), + (0x027A, "CMSG_FAR_SIGHT"), + (0x027B, "SMSG_SPELLDISPELLOG"), + (0x027C, "SMSG_DAMAGE_CALC_LOG"), + (0x027D, "CMSG_ENABLE_DAMAGE_LOG"), + (0x027E, "CMSG_GROUP_CHANGE_SUB_GROUP"), + (0x027F, "CMSG_REQUEST_PARTY_MEMBER_STATS"), + (0x0280, "CMSG_GROUP_SWAP_SUB_GROUP"), + (0x0281, "CMSG_RESET_FACTION_CHEAT"), + (0x0282, "CMSG_AUTOSTORE_BANK_ITEM"), + (0x0283, "CMSG_AUTOBANK_ITEM"), + (0x0284, "MSG_QUERY_NEXT_MAIL_TIME"), + (0x0285, "SMSG_RECEIVED_MAIL"), + (0x0286, "SMSG_RAID_GROUP_ONLY"), + (0x0287, "CMSG_SET_DURABILITY_CHEAT"), + (0x0288, "CMSG_SET_PVP_RANK_CHEAT"), + (0x0289, "CMSG_ADD_PVP_MEDAL_CHEAT"), + (0x028A, "CMSG_DEL_PVP_MEDAL_CHEAT"), + (0x028B, "CMSG_SET_PVP_TITLE"), + (0x028C, "SMSG_PVP_CREDIT"), + (0x028D, "SMSG_AUCTION_REMOVED_NOTIFICATION"), + (0x028E, "CMSG_GROUP_RAID_CONVERT"), + (0x028F, "CMSG_GROUP_ASSISTANT_LEADER"), + (0x0290, "CMSG_BUYBACK_ITEM"), + (0x0291, "SMSG_SERVER_MESSAGE"), + (0x0292, "CMSG_MEETINGSTONE_JOIN"), + (0x0293, "CMSG_MEETINGSTONE_LEAVE"), + (0x0294, "CMSG_MEETINGSTONE_CHEAT"), + (0x0295, "SMSG_MEETINGSTONE_SETQUEUE"), + (0x0296, "CMSG_MEETINGSTONE_INFO"), + (0x0297, "SMSG_MEETINGSTONE_COMPLETE"), + (0x0298, "SMSG_MEETINGSTONE_IN_PROGRESS"), + (0x0299, "SMSG_MEETINGSTONE_MEMBER_ADDED"), + (0x029A, "CMSG_GMTICKETSYSTEM_TOGGLE"), + (0x029B, "CMSG_CANCEL_GROWTH_AURA"), + (0x029C, "SMSG_CANCEL_AUTO_REPEAT"), + (0x029D, "SMSG_STANDSTATE_UPDATE"), + (0x029E, "SMSG_LOOT_ALL_PASSED"), + (0x029F, "SMSG_LOOT_ROLL_WON"), + (0x02A0, "CMSG_LOOT_ROLL"), + (0x02A1, "SMSG_LOOT_START_ROLL"), + (0x02A2, "SMSG_LOOT_ROLL"), + (0x02A3, "CMSG_LOOT_MASTER_GIVE"), + (0x02A4, "SMSG_LOOT_MASTER_LIST"), + (0x02A5, "SMSG_SET_FORCED_REACTIONS"), + (0x02A6, "SMSG_SPELL_FAILED_OTHER"), + (0x02A7, "SMSG_GAMEOBJECT_RESET_STATE"), + (0x02A8, "CMSG_REPAIR_ITEM"), + (0x02A9, "SMSG_CHAT_PLAYER_NOT_FOUND"), + (0x02AA, "MSG_TALENT_WIPE_CONFIRM"), + (0x02AB, "SMSG_SUMMON_REQUEST"), + (0x02AC, "CMSG_SUMMON_RESPONSE"), + (0x02AD, "MSG_MOVE_TOGGLE_GRAVITY_CHEAT"), + (0x02AE, "SMSG_MONSTER_MOVE_TRANSPORT"), + (0x02AF, "SMSG_PET_BROKEN"), + (0x02B0, "MSG_MOVE_FEATHER_FALL"), + (0x02B1, "MSG_MOVE_WATER_WALK"), + (0x02B2, "CMSG_SERVER_BROADCAST"), + (0x02B3, "CMSG_SELF_RES"), + (0x02B4, "SMSG_FEIGN_DEATH_RESISTED"), + (0x02B5, "CMSG_RUN_SCRIPT"), + (0x02B6, "SMSG_SCRIPT_MESSAGE"), + (0x02B7, "SMSG_DUEL_COUNTDOWN"), + (0x02B8, "SMSG_AREA_TRIGGER_MESSAGE"), + (0x02B9, "CMSG_TOGGLE_HELM"), + (0x02BA, "CMSG_TOGGLE_CLOAK"), + (0x02BB, "SMSG_MEETINGSTONE_JOINFAILED"), + (0x02BC, "SMSG_PLAYER_SKINNED"), + (0x02BD, "SMSG_DURABILITY_DAMAGE_DEATH"), + (0x02BE, "CMSG_SET_EXPLORATION"), + (0x02BF, "CMSG_SET_ACTIONBAR_TOGGLES"), + (0x02C0, "MSG_DELETE_GUILD_CHARTER"), + (0x02C1, "MSG_PETITION_RENAME"), + (0x02C2, "SMSG_INIT_WORLD_STATES"), + (0x02C3, "SMSG_UPDATE_WORLD_STATE"), + (0x02C4, "CMSG_ITEM_NAME_QUERY"), + (0x02C5, "SMSG_ITEM_NAME_QUERY_RESPONSE"), + (0x02C6, "SMSG_PET_ACTION_FEEDBACK"), + (0x02C7, "CMSG_CHAR_RENAME"), + (0x02C8, "SMSG_CHAR_RENAME"), + (0x02C9, "CMSG_MOVE_SPLINE_DONE"), + (0x02CA, "CMSG_MOVE_FALL_RESET"), + (0x02CB, "SMSG_INSTANCE_SAVE_CREATED"), + (0x02CC, "SMSG_RAID_INSTANCE_INFO"), + (0x02CD, "CMSG_REQUEST_RAID_INFO"), + (0x02CE, "CMSG_MOVE_TIME_SKIPPED"), + (0x02CF, "CMSG_MOVE_FEATHER_FALL_ACK"), + (0x02D0, "CMSG_MOVE_WATER_WALK_ACK"), + (0x02D1, "CMSG_MOVE_NOT_ACTIVE_MOVER"), + (0x02D2, "SMSG_PLAY_SOUND"), + (0x02D3, "CMSG_BATTLEFIELD_STATUS"), + (0x02D4, "SMSG_BATTLEFIELD_STATUS"), + (0x02D5, "CMSG_BATTLEFIELD_PORT"), + (0x02D6, "MSG_INSPECT_HONOR_STATS"), + (0x02D7, "CMSG_BATTLEMASTER_HELLO"), + (0x02D8, "CMSG_MOVE_START_SWIM_CHEAT"), + (0x02D9, "CMSG_MOVE_STOP_SWIM_CHEAT"), + (0x02DA, "SMSG_FORCE_WALK_SPEED_CHANGE"), + (0x02DB, "CMSG_FORCE_WALK_SPEED_CHANGE_ACK"), + (0x02DC, "SMSG_FORCE_SWIM_BACK_SPEED_CHANGE"), + (0x02DD, "CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK"), + (0x02DE, "SMSG_FORCE_TURN_RATE_CHANGE"), + (0x02DF, "CMSG_FORCE_TURN_RATE_CHANGE_ACK"), + (0x02E0, "MSG_PVP_LOG_DATA"), + (0x02E1, "CMSG_LEAVE_BATTLEFIELD"), + (0x02E2, "CMSG_AREA_SPIRIT_HEALER_QUERY"), + (0x02E3, "CMSG_AREA_SPIRIT_HEALER_QUEUE"), + (0x02E4, "SMSG_AREA_SPIRIT_HEALER_TIME"), + (0x02E5, "CMSG_GM_UNTEACH"), + (0x02E6, "SMSG_WARDEN_DATA"), + (0x02E7, "CMSG_WARDEN_DATA"), + (0x02E8, "SMSG_GROUP_JOINED_BATTLEGROUND"), + (0x02E9, "MSG_BATTLEGROUND_PLAYER_POSITIONS"), + (0x02EA, "CMSG_PET_STOP_ATTACK"), + (0x02EB, "SMSG_BINDER_CONFIRM"), + (0x02EC, "SMSG_BATTLEGROUND_PLAYER_JOINED"), + (0x02ED, "SMSG_BATTLEGROUND_PLAYER_LEFT"), + (0x02EE, "CMSG_BATTLEMASTER_JOIN"), + (0x02EF, "SMSG_ADDON_INFO"), + (0x02F0, "CMSG_PET_UNLEARN"), + (0x02F1, "SMSG_PET_UNLEARN_CONFIRM"), + (0x02F2, "SMSG_PARTY_MEMBER_STATS_FULL"), + (0x02F3, "CMSG_PET_SPELL_AUTOCAST"), + (0x02F4, "SMSG_WEATHER"), + (0x02F5, "SMSG_PLAY_TIME_WARNING"), + (0x02F6, "SMSG_MINIGAME_SETUP"), + (0x02F7, "SMSG_MINIGAME_STATE"), + (0x02F8, "CMSG_MINIGAME_MOVE"), + (0x02F9, "SMSG_MINIGAME_MOVE_FAILED"), + (0x02FA, "SMSG_RAID_INSTANCE_MESSAGE"), + (0x02FB, "SMSG_COMPRESSED_MOVES"), + (0x02FC, "CMSG_GUILD_INFO_TEXT"), + (0x02FD, "SMSG_CHAT_RESTRICTED"), + (0x02FE, "SMSG_SPLINE_SET_RUN_SPEED"), + (0x02FF, "SMSG_SPLINE_SET_RUN_BACK_SPEED"), + (0x0300, "SMSG_SPLINE_SET_SWIM_SPEED"), + (0x0301, "SMSG_SPLINE_SET_WALK_SPEED"), + (0x0302, "SMSG_SPLINE_SET_SWIM_BACK_SPEED"), + (0x0303, "SMSG_SPLINE_SET_TURN_RATE"), + (0x0304, "SMSG_SPLINE_MOVE_UNROOT"), + (0x0305, "SMSG_SPLINE_MOVE_FEATHER_FALL"), + (0x0306, "SMSG_SPLINE_MOVE_NORMAL_FALL"), + (0x0307, "SMSG_SPLINE_MOVE_SET_HOVER"), + (0x0308, "SMSG_SPLINE_MOVE_UNSET_HOVER"), + (0x0309, "SMSG_SPLINE_MOVE_WATER_WALK"), + (0x030A, "SMSG_SPLINE_MOVE_LAND_WALK"), + (0x030B, "SMSG_SPLINE_MOVE_START_SWIM"), + (0x030C, "SMSG_SPLINE_MOVE_STOP_SWIM"), + (0x030D, "SMSG_SPLINE_MOVE_SET_RUN_MODE"), + (0x030E, "SMSG_SPLINE_MOVE_SET_WALK_MODE"), + (0x030F, "CMSG_GM_NUKE_ACCOUNT"), + (0x0310, "MSG_GM_DESTROY_CORPSE"), + (0x0311, "CMSG_GM_DESTROY_ONLINE_CORPSE"), + (0x0312, "CMSG_ACTIVATETAXIEXPRESS"), + (0x0313, "SMSG_SET_FACTION_ATWAR"), + (0x0314, "SMSG_GAMETIMEBIAS_SET"), + (0x0315, "CMSG_DEBUG_ACTIONS_START"), + (0x0316, "CMSG_DEBUG_ACTIONS_STOP"), + (0x0317, "CMSG_SET_FACTION_INACTIVE"), + (0x0318, "CMSG_SET_WATCHED_FACTION"), + (0x0319, "MSG_MOVE_TIME_SKIPPED"), + (0x031A, "SMSG_SPLINE_MOVE_ROOT"), + (0x031B, "CMSG_SET_EXPLORATION_ALL"), + (0x031C, "SMSG_INVALIDATE_PLAYER"), + (0x031D, "CMSG_RESET_INSTANCES"), + (0x031E, "SMSG_INSTANCE_RESET"), + (0x031F, "SMSG_INSTANCE_RESET_FAILED"), + (0x0320, "SMSG_UPDATE_LAST_INSTANCE"), + (0x0321, "MSG_RAID_TARGET_UPDATE"), + (0x0322, "MSG_RAID_READY_CHECK"), + (0x0323, "CMSG_LUA_USAGE"), + (0x0324, "SMSG_PET_ACTION_SOUND"), + (0x0325, "SMSG_PET_DISMISS_SOUND"), + (0x0326, "SMSG_GHOSTEE_GONE"), + (0x0327, "CMSG_GM_UPDATE_TICKET_STATUS"), + (0x0328, "SMSG_GM_TICKET_STATUS_UPDATE"), + (0x0329, "MSG_SET_DUNGEON_DIFFICULTY"), + (0x032A, "CMSG_GMSURVEY_SUBMIT"), + (0x032B, "SMSG_UPDATE_INSTANCE_OWNERSHIP"), + (0x032C, "CMSG_IGNORE_KNOCKBACK_CHEAT"), + (0x032D, "SMSG_CHAT_PLAYER_AMBIGUOUS"), + (0x032E, "MSG_DELAY_GHOST_TELEPORT"), + (0x032F, "SMSG_SPELLINSTAKILLLOG"), + (0x0330, "SMSG_SPELL_UPDATE_CHAIN_TARGETS"), + (0x0331, "CMSG_CHAT_FILTERED"), + (0x0332, "SMSG_EXPECTED_SPAM_RECORDS"), + (0x0333, "SMSG_SPELLSTEALLOG"), + (0x0334, "CMSG_LOTTERY_QUERY_OBSOLETE"), + (0x0335, "SMSG_LOTTERY_QUERY_RESULT_OBSOLETE"), + (0x0336, "CMSG_BUY_LOTTERY_TICKET_OBSOLETE"), + (0x0337, "SMSG_LOTTERY_RESULT_OBSOLETE"), + (0x0338, "SMSG_CHARACTER_PROFILE"), + (0x0339, "SMSG_CHARACTER_PROFILE_REALM_CONNECTED"), + (0x033B, "SMSG_DEFENSE_MESSAGE"), +]; diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs new file mode 100644 index 00000000..439573d7 --- /dev/null +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -0,0 +1,1094 @@ +//! The decoded [`ServerPacket`] — one variant per server opcode benilla models, with the wire +//! provenance on each, plus the logging [`ServerPacket::name`]. The opcode→variant dispatch lives +//! in the sibling `parse` module; the per-domain payload readers in the domain children. + +use crate::wire::Vector3d; + +use super::{ + ActionButton, AttackerState, CastOutcome, ChannelNotify, Character, ChatMessage, + CorpseLocation, DamageShield, EnvironmentalDamageLog, GameObjectQueryInfo, GossipOption, + GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, + LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, Object, + PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, + QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody, + SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, + SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, + TransportPose, VendorItem, XpGain, +}; + +/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this +/// when the move is applied — the real client stores it straight into the unit's movement facing, a +/// hard snap, not a smooth turn (wow-re object-layer: `0x6018f0` → `0x7c6f30 mov [esi+0x1c],eax`). +/// This is how a creature re-orients without walking (a scripted/emote/aggro face), and benilla used +/// to **discard** it (the "mob won't turn to face me" gap). `Angle` is a raw WoW orientation; `Spot` +/// / `Target` resolve to an angle in the app (atan2 from the unit to the point, or to the target +/// unit's live position — the client snapshots the target's position at receipt, it doesn't track). +#[derive(Debug, Clone, Copy)] +pub enum MonsterMoveFacing { + /// A plain move (`moveType` 0) or a stop — no dictated facing; the unit faces its travel direction. + None, + /// `moveType 2` — face a world point (raw WoW coords). + Spot([f32; 3]), + /// `moveType 3` — face a unit by guid (resolved to its position when applied). + Target(u64), + /// `moveType 4` — face a raw orientation (radians, WoW convention). + Angle(f32), +} + +/// One creature template's UI-visible head (see [`ServerPacket::CreatureQueryResponse`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatureQueryInfo { + pub name: String, + /// The NPC subtitle ("Stable Master") — the unit tooltip's second line. + pub subname: String, + /// `CreatureType.dbc` id (Beast/Humanoid/… — the level line's type word + the TAB filter). + pub creature_type: u32, + /// Elite rank: 0 normal, 1 elite, 2 rare-elite, 3 world boss, 4 rare — the unit tooltip's + /// rank word `{"", Elite, Elite, Boss, ""}` (decision 0276's byte-verified table). + pub rank: u32, + /// The template type flags. Bit `0x10` (HIDE_FACTION_TOOLTIP) suppresses the unit tooltip's + /// faction-name line (the client's `0x612610` gate on the cached record's `+0x14`). + pub type_flags: u32, + /// The civilian flag (the tooltip's green CIVILIAN line; dishonorable-kill marking). + pub civilian: bool, + /// The racial-leader flag (the tooltip's white LEADER line; `0x6125c0` on the record's `+0x31`). + pub racial_leader: bool, +} + +/// A decoded server packet (only the opcodes benilla handles; everything else is [`Self::Other`]). +pub enum ServerPacket { + AuthChallenge { + server_seed: u32, + }, + AuthResponse { + result: u8, + }, + CharEnum { + characters: Vec, + }, + CharCreate { + result: u8, + }, + CharDelete { + result: u8, + }, + UpdateObject { + objects: Vec, + }, + /// `SMSG_DESTROY_OBJECT` — one object ceased to exist server-side (a corpse decaying ahead of + /// respawn, a despawn), body a plain `u64` guid (vmangos `DestroyObject::AppendBodyTo`). Distinct + /// from the update-object `OutOfRange` block (*we* left its range; the real client keeps those + /// staged for cheap re-create): destroy frees the object outright (handler `0xaa → 0x4674a0`). + DestroyObject { + guid: u64, + }, + /// `SMSG_TRIGGER_CINEMATIC` — play the cinematic sequence (body: the `CinematicSequences.dbc` + /// id, `u32`). Sent on a character's first-ever login (the race intro, + /// `HandleCharEnum`'s played-time-0 branch) and by GameObject type-13 cameras. While one runs + /// unacked, vmangos anchors visibility to the flying camera (see [`super::opcode`]'s note) — the + /// client must answer with `CMSG_COMPLETE_CINEMATIC` (empty body) when done (or to skip). + TriggerCinematic { + cinematic_id: u32, + }, + MonsterMove { + guid: u64, + start: Vector3d, + /// The server's per-move spline counter — echoed back in `CMSG_MOVE_SPLINE_DONE` when the + /// spline drives our own player (Charge/knockback/taxi); ignored for a creature's walk. + spline_id: u32, + /// The **full** ordered path the unit traverses, in travel order `[start, …waypoints…, endpoint]` + /// (absolute WoW coords) — every intermediate waypoint decoded, not just the endpoint. The client + /// walks all of them at constant (arc-length) speed; a straight hop is the two-point degenerate. + /// Empty for a `Stop`/degenerate move. (Ground paths pack the waypoints as `¼`-yd offsets from the + /// endpoint; a flying `Mask_CatmullRom` path sends them absolute — decoded in `monster_move`.) + path: Vec, + /// The dictated final facing (`moveType` 2/3/4), applied as a snap. [`MonsterMoveFacing::None`] + /// for a plain move — the unit then faces its travel direction along the path. + facing: MonsterMoveFacing, + stop: bool, + duration_ms: u32, + /// The path is a 3-D **flight** (`SPLINE_FLAG_FLYING` = `Mask_CatmullRom`): keep the spline's own Z. + /// Clear ⇒ a ground walk whose Z the client re-derives from the terrain (the app terrain-clamps it). + flying: bool, + }, + /// A relayed *player* movement packet (`MSG_MOVE_*`, see [`super::parse::is_movement_relay`]): the mover's + /// authoritative pose plus its live CMovement `moveFlags`. The app snaps the entity to `position` + /// and extrapolates from `flags` between packets. `opcode` is kept for logging/diagnostics. + /// `fall_time` + `jump` carry the airborne clock + ballistic launch params when `JUMPING` is set, so + /// a jump replays as one arc (decision 0053). `transport` is `Some` while `MOVEFLAG_ON_TRANSPORT` is + /// set: the mover's local pose on the named transport (decision 0438 — an observed rider's boarding + /// tail, composed through the transport's live matrix rather than read as world coordinates). + PlayerMove { + guid: u64, + opcode: u16, + flags: u32, + position: Vector3d, + orientation: f32, + /// The swim pitch (radians, +up) from the `MovementInfo` tail — the mover's 3D travel + /// angle while `MOVEFLAG_SWIMMING` is set (`0.0` otherwise), so observers integrate the + /// vertical between packets the way the client's swim velocity basis does (`0x7c5880`). + pitch: f32, + /// The `MovementInfo` time word. vmangos stamps it with **its own ms clock at receipt** + /// (`MovementInfo::stime`, `Object.cpp` `Write`), so every relayed move shares one server + /// clock — the scheduling basis for the reference's deferred replay (decision 0601). + time: u32, + fall_time: u32, + jump: Option, + transport: Option, + }, + Teleport { + guid: u64, + counter: u32, + position: Vector3d, + orientation: f32, + }, + NewWorld { + map: u32, + position: Vector3d, + orientation: f32, + }, + /// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): the destination map and, when the + /// player is riding a transport through the transfer, `(transportEntry, oldMapId)`. The + /// block's presence decides how the follow-up [`Self::NewWorld`] coordinates read — + /// boat-local vs world (decision 0455). + TransferPending { + map: u32, + transport: Option<(u32, u32)>, + }, + /// `SMSG_TRANSFER_ABORTED`: the announced transfer will not happen (map full, no instance). + TransferAborted { + reason: u8, + }, + LoginVerifyWorld { + map: u32, + position: Vector3d, + orientation: f32, + }, + TimeSpeed { + hours: u8, + minutes: u8, + /// Monotonic day count flattened from the packed date (`year·372 + month·31 + day`) — + /// the celestial layer's moon-phase precession input. + day_serial: u32, + timescale: f32, + }, + /// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`, at login + on re-bind): + /// position + map + the AreaTable id the `$z` token names. + BindPoint { + position: Vector3d, + map: u32, + area: u32, + }, + /// `SMSG_SET_PROFICIENCY` (at login + on train): the subclass bitmask the player can equip + /// for one item class (2 weapons / 4 armor) — the client's `0xc4d4a0[class]` store, the item + /// tooltip's slot-line proficiency red (vmangos `Skill.h`: u8 itemClass + u32 mask). + SetProficiency { + item_class: u8, + subclass_mask: u32, + }, + /// `SMSG_INITIALIZE_FACTIONS` — the player's reputation store, sent once at login: `(flags, + /// standing)` per reputation-list slot (vanilla sends all 64), indexed by `Faction.dbc`'s + /// `reputationIndex`. The wire standing **excludes** the DBC base value — the client adds the + /// race/class-matched `BaseRepValue` before ranking (hated → exalted). + InitializeFactions { + standings: Vec<(u8, i32)>, + }, + /// `SMSG_SET_FACTION_STANDING` — mid-session standing deltas: `(reputationListId, standing)` + /// per changed slot (vmangos `ReputationMgr::SendState` batches every `needSend` faction). + /// Same standing convention as [`Self::InitializeFactions`] (DBC base excluded). + SetFactionStanding { + standings: Vec<(u32, i32)>, + }, + /// `SMSG_NAME_QUERY_RESPONSE` — a player character's identity, answering `CMSG_NAME_QUERY`: + /// guid, name, realm (1.12.1 carries it, empty on a single realm), race/gender/class as `u32`s + /// (VERIFIED vmangos `NameQueryResponse::AppendBodyTo`). An unknown guid answers with an empty + /// name (`SendNameQueryOpcodeFromDBCallBack` with no DB row). + NameQueryResponse { + guid: u64, + name: String, + race: u32, + gender: u32, + class: u32, + }, + /// `SMSG_CREATURE_QUERY_RESPONSE` — a creature template's display data, answering + /// `CMSG_CREATURE_QUERY`. We surface what the UI consumes ([`CreatureQueryInfo`]: name, + /// subname, `CreatureType.dbc` id, the elite **rank**, the civilian flag); the rest of the + /// tail (type flags/family/pet spells/display id/racial leader) is parsed for alignment and + /// dropped until a consumer exists. A **miss** (unknown entry) is a lone `u32` of + /// `entry | 0x8000_0000` → `None` (VERIFIED vmangos `HandleCreatureQueryOpcode`, both + /// branches). + CreatureQueryResponse { + entry: u32, + /// `Some` on a hit; `None` when the server flagged the entry unknown. + info: Option, + }, + /// `SMSG_GAMEOBJECT_QUERY_RESPONSE` — a GameObject template's type/display/name/`data[24]` head, + /// answering `CMSG_GAMEOBJECT_QUERY` (decision 0236's ask-once template lookup, the GO twin of + /// [`Self::CreatureQueryResponse`]). `data[24]` is the raw type-specific tail (e.g. a chest's + /// lockId) — parsed verbatim, resolved by whichever later consumer knows the GO's type. A miss + /// (unknown entry) is a lone `u32` of `entry | 0x8000_0000` → `None`, same shape as the + /// creature/item miss. + GameObjectQueryResponse { + entry: u32, + info: Option, + }, + /// `SMSG_PLAY_SOUND` — a 2D sound-kit id, map/zone-wide (BG events, scripts). Payload + /// VERIFIED vmangos `Map::PlayDirectSoundToMap`: one `u32 soundId` (SoundEntries). + PlaySound { + sound_id: u32, + }, + /// `SMSG_PLAY_MUSIC` — a music kit for the music channel (scripts, e.g. Karazhan opera). + /// Payload VERIFIED vmangos: one `u32 musicId` (SoundEntries). + PlayMusic { + music_id: u32, + }, + /// `SMSG_PLAY_OBJECT_SOUND` — a 3D kit at a source object (fishing-bobber splash 3355, + /// distance-dependent scripts). Payload VERIFIED vmangos `WorldObject::PlayDistanceSound`: + /// `u32 soundId, u64 sourceGuid`. + PlayObjectSound { + sound_id: u32, + guid: u64, + }, + /// `SMSG_WEATHER` — the zone's weather state. Payload VERIFIED vmangos + /// `Weather::SendWeatherForPlayersInZone` (1.12 shape): `u32 type, f32 grade, + /// u32 soundId (a SoundEntries kit — 8533..8558, the rain/snow/sandstorm loops; 0 = clear), + /// u8 instant`. `type`/`grade`/`instant` also feed the weather *visuals* when they exist. + Weather { + weather_type: u32, + grade: f32, + sound_id: u32, + instant: bool, + }, + /// `SMSG_TEXT_EMOTE` — a nearby unit performed a chat emote (`/wave`). Payload VERIFIED + /// vmangos `EmoteChatBuilder`: `u64 guid, u32 textEmote (EmotesText.dbc), u32 emoteNum, + /// u32 namelen, char name[namelen]` (the target's name; namelen ≥ 1). The name is parsed + /// past and dropped (the chat line renders from it later; audio keys on ids). + TextEmote { + guid: u64, + text_emote: u32, + }, + /// `SMSG_EMOTE` — a unit plays an anim emote. Payload VERIFIED vmangos + /// `Unit::HandleEmote`: `u32 emoteId (Emotes.dbc), u64 guid`. + Emote { + guid: u64, + emote_id: u32, + }, + /// `SMSG_ITEM_QUERY_SINGLE_RESPONSE` — an item template's display head, answering + /// `CMSG_ITEM_QUERY_SINGLE` (layout in [`super::items`]); `None` info = the miss shape. `ItemInfo` now + /// carries the full 1.12.1 item template (decision 0274 P1) — boxed so this one wide-but-rare + /// variant doesn't inflate every other (tiny, hot) `ServerPacket` (`clippy::large_enum_variant`). + ItemQueryResponse { + entry: u32, + info: Option>, + }, + /// `SMSG_MESSAGECHAT` — one inbound chat line (say/yell/system/NPC/channel; layout in + /// [`super::chat`]). System lines (type `0x0A`) are how GM dot-commands answer. + MessageChat(ChatMessage), + /// `SMSG_CHANNEL_NOTIFY` — a channel join/leave/error/moderation notice (layout in + /// [`super::channel::read_channel_notify`], decision 0288). + ChannelNotify(ChannelNotify), + /// `SMSG_CHANNEL_LIST` — a channel's member roster, answering our `CMSG_CHANNEL_LIST` (layout in + /// [`super::channel::read_channel_list`]): `(guid, memberFlags)` per row. + ChannelList { + channel: String, + flags: u8, + members: Vec<(u64, u8)>, + }, + /// `SMSG_CHAT_PLAYER_NOT_FOUND` — a whisper target wasn't found online (vmangos + /// `Server/Packets/Chat.cpp:26-29`). + ChatPlayerNotFound { + name: String, + }, + /// `SMSG_CHAT_WRONG_FACTION` — a cross-faction whisper was refused; empty body (vmangos + /// `Server/Packets/Chat.cpp:16-18`). + ChatWrongFaction, + /// `SMSG_NOTIFICATION` — a server notice ("You do not know that language", …); one cstring + /// (vmangos `WorldSession::SendNotification`, `Server/WorldSession.cpp:900-915`). + Notification { + text: String, + }, + /// `SMSG_PLAYED_TIME` — answers our `CMSG_PLAYED_TIME` (`/played`): total played time + time + /// since the last level-up, both in seconds (layout in [`super::chat::read_played_time`]). + PlayedTime { + total: u32, + level: u32, + }, + /// `MSG_RANDOM_ROLL` — the server's `/random` broadcast (layout in + /// [`super::chat::read_random_roll`]): the rolled range, the result, and the roller's guid. + RandomRoll { + min: u32, + max: u32, + roll: u32, + guid: u64, + }, + /// `SMSG_INVENTORY_CHANGE_FAILURE` — the server refused an inventory operation + /// (equip/store/split; layout in [`super::items::read_inventory_change_failure`]). `reason` is the + /// `InventoryResult` code; `required_level` rides only on the level refusal (reason 1). + InventoryChangeFailure { + reason: u8, + required_level: Option, + item_guid: u64, + }, + /// `SMSG_INITIAL_SPELLS` — the player's spell book + active cooldowns, once at login + /// (layout in [`super::spells::read_initial_spells`]). + InitialSpells { + spell_ids: Vec, + cooldowns: Vec, + }, + /// `SMSG_ACTION_BUTTONS` — the player's saved action bar, once at login: the occupied slots + /// of the 120-slot wire array (layout in [`super::spells::read_action_buttons`]). + ActionButtons { + buttons: Vec, + }, + /// `SMSG_LEARNED_SPELL` — a spell was added to the book after login (trainer/quest/level-up; + /// layout in [`super::spells::read_learned_spell`]). The first post-login spell-book mutation (0237). + LearnedSpell { + spell_id: u16, + }, + /// `SMSG_SUPERCEDED_SPELL` — a rank-up replaced its predecessor in the book + action bar + /// (layout in [`super::spells::read_superceded_spell`]). + SupercededSpell { + old_spell_id: u16, + new_spell_id: u16, + }, + /// `SMSG_CAST_RESULT` — the server's verdict on our `CMSG_CAST_SPELL`. + CastResult { + spell_id: u32, + outcome: CastOutcome, + }, + /// `SMSG_ATTACKSTART` — a unit began melee auto-attack (including our own echo). + AttackStart { + attacker: u64, + victim: u64, + }, + /// `SMSG_ATTACKSTOP` — a unit stopped melee auto-attack. + AttackStop { + attacker: u64, + victim: u64, + }, + /// `SMSG_ATTACKERSTATEUPDATE` — one completed melee swing (decision 0073: the attacker's swing + /// animation trigger). + AttackerState(AttackerState), + /// `SMSG_AI_REACTION` — a creature flared aggro (2 HOSTILE) or a stealth pre-aggro alert + /// (0 ALERT) at someone (layout in [`super::spells::read_ai_reaction`]; decision 0277). + AiReaction { + unit: u64, + reaction: u32, + }, + /// `SMSG_SPELL_START` — a non-triggered cast began, instants included (decision 0099 phase 1: + /// the precast trigger; layout in [`super::spells::read_spell_start`]). + SpellStart(SpellStart), + /// `SMSG_SPELL_GO` — the cast launched: hit/miss lists + (for a ranged spell) the ammo block. + /// The server schedules impact itself off `Spell.dbc` Speed — nothing about missile travel + /// rides this packet (layout in [`super::spells::read_spell_go`]). + SpellGo(SpellGo), + /// `SMSG_SPELL_FAILED_OTHER` — an observed cast was interrupted/cancelled (vmangos + /// `Spell::SendInterrupted`); our own cast's failure is [`Self::CastResult`] instead. + SpellFailedOther { + caster: u64, + spell_id: u32, + }, + /// `SMSG_SPELL_DELAYED` — pushback: our own cast took damage and the server extended its timer + /// by `delay_ms` (vmangos `Spell::Delayed`). The cast bar slides its window out by the same, so + /// a hit pushes the bar back instead of letting it finish early (decision 0256). + SpellDelayed { + caster: u64, + delay_ms: u32, + }, + /// `SMSG_CANCEL_AUTO_REPEAT` — stop our own ranged auto-repeat visual; self-only, empty body + /// (vmangos `WorldPackets::Misc::CancelAutoRepeat`). Consumed by the local cancel funnel + /// (`net/apply/spells.rs::cancel_auto_repeat`, decision 0406) — dormant against vmangos, + /// which never sends it (zero send sites; the cast-result fail is the live stop path). + CancelAutoRepeat, + /// `SMSG_SPELL_COOLDOWN` — server-pushed cooldowns for the player or pet, by caster guid + /// (layout + the `cooldown_ms == 0` "use Spell.dbc" fork in [`super::spells::read_spell_cooldown`]; + /// decision 0137 phase 4). + SpellCooldownList { + caster: u64, + /// `(spell_id, cooldown_ms)` — `0` ms = the spell's own DBC recovery/category times. + cooldowns: Vec<(u32, u32)>, + }, + /// `SMSG_ITEM_COOLDOWN` — put an item (by instance guid) on the client's fixed 30 s use + /// cooldown for its on-use spell (layout in [`super::spells::read_item_cooldown`]). + ItemCooldown { + item_guid: u64, + spell_id: u32, + }, + /// `SMSG_COOLDOWN_EVENT` — start an on-hold (`SPELL_ATTR_COOLDOWN_ON_EVENT`) cooldown's + /// parked timers now (layout in [`super::spells::read_cooldown_event`]). + CooldownEvent { + spell_id: u32, + caster: u64, + }, + /// `SMSG_CLEAR_COOLDOWN` — remove one spell's cooldown record outright (same body shape as + /// [`Self::CooldownEvent`]). + ClearCooldown { + spell_id: u32, + caster: u64, + }, + /// `SMSG_COOLDOWN_CHEAT` — wipe every cooldown for the named unit (the GM reset). + CooldownCheat { + caster: u64, + }, + /// `MSG_CHANNEL_START` — our own channeled cast opened (self-only; no guid on the wire). The + /// cast bar's channel-open edge (decision 0137). + ChannelStart { + spell_id: u32, + duration_ms: u32, + }, + /// `MSG_CHANNEL_UPDATE` — our own channel's time left; `0` = over (natural end and interrupt + /// alike). Self-only (decision 0137). + ChannelUpdate { + remaining_ms: u32, + }, + /// `SMSG_UPDATE_AURA_DURATION` — how long one of **our own** auras has left, keyed by its + /// `UNIT_FIELD_AURA` slot. Self-only, never sent for a permanent aura, and it arrives *before* + /// the descriptor delta that says which spell occupies the slot — so a consumer buffers it by + /// slot and joins on `(slot, spell_id)` (decision 0255). + UpdateAuraDuration { + slot: u8, + remaining_ms: u32, + }, + /// `SMSG_PLAY_SPELL_VISUAL` — play a spell-visual kit on a unit outside the normal cast + /// sequence, at the client's hardcoded stage 0 (the eat/drink kit cadence, mid-channel kit + /// swaps — decision 0280). + PlaySpellVisual { + unit: u64, + kit_id: u32, + }, + /// `SMSG_SPELLNONMELEEDAMAGELOG` — non-melee (spell) damage dealt (decision 0137 phase 2's + /// floating-combat-text data feed; layout in [`super::spells::read_spell_damage_log`]). + SpellDamageLog(SpellDamageLog), + /// `SMSG_PERIODICAURALOG` — periodic (DoT/HoT/regen) aura ticks (decision 0137 phase 2; layout + /// in [`super::spells::read_periodic_aura_log`]). + PeriodicAuraLog(PeriodicAuraLog), + /// `SMSG_SPELLHEALLOG` — a direct heal landing (decision 0578's center-combat-text feed; + /// layout in [`super::spells::read_spell_heal_log`]). + SpellHealLog(SpellHealLog), + /// `SMSG_SPELLENERGIZELOG` — an instant power gain (decision 0578; layout in + /// [`super::spells::read_spell_energize_log`]). + SpellEnergizeLog(SpellEnergizeLog), + /// `SMSG_SPELLDAMAGESHIELD` — a damage-shield (Thorns-style) return hit (decision 0137 phase 2; + /// layout in [`super::spells::read_damage_shield`]). + DamageShield(DamageShield), + /// `SMSG_ENVIRONMENTALDAMAGELOG` — environmental damage taken (fall/drowning/…; layout in + /// [`super::spells::read_environmental_damage_log`]). + EnvironmentalDamageLog(EnvironmentalDamageLog), + /// `SMSG_SPELLLOGMISS` — a spell cast's per-target miss list (decision 0137 phase 2; layout in + /// [`super::spells::read_spell_log_miss`]). + SpellLogMiss(SpellLogMiss), + /// `SMSG_LOG_XPGAIN` — an XP award, kill or non-kill (decision 0137 phase 2; layout in + /// [`super::spells::read_xp_gain`]). + XpGain(XpGain), + /// `SMSG_LEVELUP_INFO` — our own ding, self-addressed only (decision 0304; layout in + /// [`super::spells::read_level_up_info`]). + LevelUp(LevelUpInfo), + /// `SMSG_QUESTGIVER_STATUS` — the questgiver dialog status for one NPC (`!`/`?` marker), a + /// [`super::quest::dialog_status`] value. A world-marker concern (out of the panel slice, decision + /// 0088): parsed + surfaced per guid now, rendered later. + QuestGiverStatus { + npc: u64, + status: u32, + }, + /// `SMSG_QUESTGIVER_QUEST_LIST` — the greeting panel: an NPC's offered/active quest rows + /// (layout in [`super::quest::read_questgiver_quest_list`]). + QuestGiverQuestList(QuestGiverList), + /// `SMSG_QUESTGIVER_QUEST_DETAILS` — the accept panel: full quest text + rewards on offer + /// (layout in [`super::quest::read_questgiver_quest_details`]). + QuestGiverDetails(QuestDetails), + /// `SMSG_QUESTGIVER_REQUEST_ITEMS` — the progress panel: the "bring me these" text + required + /// items and the completability flag (layout in [`super::quest::read_questgiver_request_items`]). + QuestGiverRequestItems(QuestRequestItems), + /// `SMSG_QUESTGIVER_OFFER_REWARD` — the reward panel: turn-in text + rewards to grant (layout in + /// [`super::quest::read_questgiver_offer_reward`]). + QuestGiverOfferReward(QuestOfferReward), + /// `SMSG_QUESTGIVER_QUEST_COMPLETE` — the turn-in result: XP/money granted + fixed items + /// (layout in [`super::quest::read_questgiver_quest_complete`]). + QuestGiverComplete(QuestComplete), + /// `SMSG_QUESTGIVER_QUEST_INVALID` (vmangos `Quest.cpp:126`) — the accept/query attempt was + /// rejected before a details/failed panel could be built; `msg` is a client message code. + QuestGiverInvalid { + msg: u32, + }, + /// `SMSG_QUESTGIVER_QUEST_FAILED` (vmangos `Quest.cpp:110`) — a `CMSG_QUESTGIVER_ACCEPT_QUEST` + /// (or an in-progress requirement) failed; `reason` is a client `QuestFailedReason` code. + QuestGiverFailed { + quest_id: u32, + reason: u32, + }, + /// `SMSG_QUEST_QUERY_RESPONSE` — the full quest template, answering `CMSG_QUEST_QUERY`; feeds + /// the quest-log detail view (layout + wire-trap notes on [`QuestTemplate`]). Boxed: at 400+ + /// bytes (four fixed-count arrays plus five strings) it would otherwise dwarf every other + /// `ServerPacket` variant and bloat the whole enum. + QuestQueryResponse(Box), + /// `SMSG_QUESTLOG_FULL` — the log has no free slot for a new quest; empty body (vmangos + /// `Quest.cpp:87`). + QuestLogFull, + /// `SMSG_QUESTUPDATE_COMPLETE` — every objective on this quest is now complete (vmangos + /// `Quest.cpp:91`); the log slot's state byte gets `QUEST_STATE_COMPLETE`. + QuestUpdateComplete { + quest_id: u32, + }, + /// `SMSG_QUESTUPDATE_FAILED` — the quest failed outright (vmangos `Quest.cpp:116`). + QuestUpdateFailed { + quest_id: u32, + }, + /// `SMSG_QUESTUPDATE_FAILEDTIMER` — a timed quest's clock ran out (vmangos `Quest.cpp:121`). + QuestUpdateFailedTimer { + quest_id: u32, + }, + /// `SMSG_QUESTUPDATE_ADD_KILL` — a kill/use toast for a creature-or-gameobject objective + /// (vmangos `Quest.cpp:144`); `entry` carries the same raw creature/GO encoding as + /// [`super::quest::QuestObjective::creature_or_go`], and mirrors (doesn't replace) the durable + /// `PLAYER_QUEST_LOG` counter field. + QuestUpdateAddKill { + quest_id: u32, + entry: u32, + count: u32, + required: u32, + guid: u64, + }, + /// `SMSG_QUESTUPDATE_ADD_ITEM` — an item-collection toast (vmangos `Quest.cpp:138`). + QuestUpdateAddItem { + item_id: u32, + count: u32, + }, + /// `SMSG_GOSSIP_MESSAGE` — a gossip menu opened on an NPC, answering our `CMSG_GOSSIP_HELLO` (or + /// riding a `CMSG_GOSSIP_SELECT_OPTION` reply that re-opens the menu). Payload VERIFIED vmangos + /// `GossipDef.cpp:180-225` (the 1.12 shape — no box-money field, that's TBC+); `text_id` drives a + /// follow-up `CMSG_NPC_TEXT_QUERY` for the greeting body. `quests` carries the quest-option rows + /// riding the same packet (wired to the questgiver panels — decision 0088). + GossipMessage { + npc: u64, + text_id: u32, + options: Vec, + quests: Vec, + }, + /// `SMSG_GOSSIP_COMPLETE` — the gossip window closes (vmangos `Npc.cpp:90`); an empty body. + GossipComplete, + /// `SMSG_NPC_TEXT_UPDATE` — answers `CMSG_NPC_TEXT_QUERY`: always 8 probability-weighted text + /// blocks (vmangos `GossipDef.cpp:298-369`); `greeting` is the highest-probability block's male + /// text with a female-text fallback (see [`super::gossip::read_npc_text_update`]). + NpcText { + text_id: u32, + greeting: String, + }, + /// `SMSG_LIST_INVENTORY` — a vendor's stock, answering `CMSG_LIST_INVENTORY` (vmangos + /// `ItemHandler.cpp:741-810`). Empty stock sends `count = 0` plus a trailing error byte the + /// parser leaves unconsumed. + VendorList { + vendor: u64, + items: Vec, + }, + /// `SMSG_BUY_ITEM` — the vendor stock update after a purchase (vmangos `Item.cpp:190-196`); the + /// purchased item itself arrives via `UPDATE_OBJECT` + `SMSG_ITEM_PUSH_RESULT`, already handled. + BuyItem { + vendor: u64, + slot: u32, + new_count: u32, + purchase_count: u32, + }, + /// `SMSG_SELL_ITEM` — the **error** path only (vmangos `Item.cpp:183-188`, `SendSellError` + /// `Player.cpp:11723`); a successful sell sends nothing, visible only as the item vanishing + + /// coinage rising via `UPDATE_OBJECT`. `reason` is a [`super::vendor::sell_result`] code. + SellItemResult { + vendor: u64, + item_guid: u64, + reason: u8, + }, + /// `SMSG_BUY_FAILED` — the server refused a purchase (vmangos `Item.h:277`). `reason` is a + /// [`super::vendor::buy_result`] code. + BuyFailed { + vendor: u64, + item_entry: u32, + reason: u8, + }, + /// `SMSG_SHOW_BANK` — the bank window opens, answering our `CMSG_BANKER_ACTIVATE` (vmangos + /// `Npc.cpp:94`) — or arriving unprompted for the `GOSSIP_OPTION_BANKER` gossip option + /// (`SendShowBank`, `Player.cpp:12426`); the handler must not assume it always follows our own + /// activate. The vault itself is already streamed via the ordinary player descriptor + /// (decision 0604) — this only opens the window. + ShowBank { + banker: u64, + }, + /// `SMSG_BUY_BANK_SLOT_RESULT` — a bank-slot purchase was refused (vmangos `Item.cpp:137-140`). + /// `result` is a [`super::bank::bank_slot_result`] code; a *successful* buy sends no packet at + /// all, visible only as the `PLAYER_BYTES_2` bank-bag-count byte advancing + the coinage drop + /// (decision 0604). + BuyBankSlotResult { + result: u32, + }, + /// `SMSG_TRAINER_LIST` — a class/profession trainer's service list, reached through the gossip + /// trainer option (layout in [`super::trainer::read_trainer_list`], decision 0237). `trainer_type` is + /// the window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet); `title` is the greeting. + TrainerList { + trainer: u64, + trainer_type: u32, + services: Vec, + title: String, + }, + /// `SMSG_TRAINER_BUY_SUCCEEDED` — the trainer taught the service (vmangos `SendTrainingSuccess`); + /// confirmation + sound only — the learned spell arrives via `SMSG_LEARNED_SPELL`, and the + /// green→gray repaint needs a `CMSG_TRAINER_LIST` re-request. + TrainerBuySucceeded { + trainer: u64, + spell_id: u32, + }, + /// `SMSG_TRAINER_BUY_FAILED` — the trainer refused (vmangos `SendTrainingFailure`). `error` is a + /// [`super::trainer::train_fail`] code (0 unavailable · 1 not-enough-money · 2 not-enough-skill). + TrainerBuyFailed { + trainer: u64, + spell_id: u32, + error: u32, + }, + /// `SMSG_LOOT_RESPONSE`, normal shape — a loot window opened, answering `CMSG_LOOT` (layout in + /// [`super::loot::read_loot_response`]). `loot_type` is a [`super::loot::loot_type`] code; `items` includes + /// any quest-item rows riding the same list. + LootResponse { + guid: u64, + loot_type: u8, + gold: u32, + items: Vec, + }, + /// `SMSG_LOOT_RESPONSE`, error shape — the server refused to open the loot window (didn't + /// kill it, too far, not standing, …). `error` is a [`super::loot::loot_error`] code. + LootError { + guid: u64, + error: u8, + }, + /// `SMSG_LOOT_RELEASE_RESPONSE` — the loot window closes, answering `CMSG_LOOT_RELEASE`. + /// `result` is always `1` (vmangos never sends another value). + LootReleaseResponse { + guid: u64, + result: u8, + }, + /// `SMSG_LOOT_REMOVED` — one loot-window row was taken, by anyone. + LootRemoved { + slot: u8, + }, + /// `SMSG_LOOT_MONEY_NOTIFY` — our share of the loot's coin pile, answering `CMSG_LOOT_MONEY`. + LootMoneyNotify { + amount: u32, + }, + /// `SMSG_LOOT_CLEAR_MONEY` — the coin line disappears for every current looter; empty body. + LootClearMoney, + /// `SMSG_LOOT_START_ROLL` — a group roll opened on one drop (layout in [`LootStartRoll`]); + /// drives a `GroupLootFrame`. + LootStartRoll(LootStartRoll), + /// `SMSG_LOOT_ROLL` — one roller's vote or dice result (layout, and the overloaded + /// `(roll_number, roll_type)` pair, in [`LootRoll`]). + LootRoll(LootRoll), + /// `SMSG_LOOT_ROLL_WON` — a group roll resolved (layout in [`LootRollWon`]). + LootRollWon(LootRollWon), + /// `SMSG_LOOT_ALL_PASSED` — everyone passed; the roll closes and the item returns to the + /// corpse for ordinary looting (layout in [`LootAllPassed`]). + LootAllPassed(LootAllPassed), + /// `SMSG_ITEM_PUSH_RESULT` — an item landed in our bags (looted or received from an NPC); + /// drives the "You receive loot: …" chat line (layout in [`ItemPushResult`]). + ItemPushResult(ItemPushResult), + /// `MSG_CORPSE_QUERY`'s answer — where our corpse is (layout + the two-map split in + /// [`CorpseLocation`]); also pushed unprompted as not-found at bones-conversion. + CorpseQuery(CorpseLocation), + /// `SMSG_CORPSE_RECLAIM_DELAY` — ms until the corpse can be reclaimed (sent at release + + /// at login-while-dead). + CorpseReclaimDelay { + delay_ms: u32, + }, + /// `SMSG_DURABILITY_DAMAGE_DEATH` — the 10% natural-death durability loss happened (empty + /// body); the red error-line cue. + DurabilityDamageDeath, + /// `SMSG_RESURRECT_REQUEST` — a resurrection offer (layout in [`ResurrectRequestBody`]). + ResurrectRequest(ResurrectRequestBody), + /// `SMSG_SPIRIT_HEALER_CONFIRM` — the spirit healer asks for the XP_LOSS-style confirm; + /// carries the NPC's guid (the eventual `CMSG_SPIRIT_HEALER_ACTIVATE` target). + SpiritHealerConfirm { + npc: u64, + }, + /// `SMSG_FORCE_MOVE_ROOT` / `SMSG_FORCE_MOVE_UNROOT` — the server (un)roots our mover; must be + /// acked with the echoed `counter` (`CMSG_FORCE_MOVE_[UN]ROOT_ACK`) or observers never see it. + MoveRoot { + guid: u64, + counter: u32, + rooted: bool, + }, + /// `SMSG_MOVE_WATER_WALK` / `SMSG_MOVE_LAND_WALK` — water-walking granted/removed on our mover + /// (the ghost form's walk-on-water); ack with `CMSG_MOVE_WATER_WALK_ACK` + the echoed counter. + WaterWalk { + guid: u64, + counter: u32, + on: bool, + }, + LogoutComplete, + LogoutResponse { + result: u32, + }, + /// `SMSG_PONG` — the echo of our `CMSG_PING`'s sequence number (the keepalive's return leg; + /// the io layer matches it against the ping clock to measure the round-trip). + Pong { + sequence: u32, + }, + /// A `SMSG_FORCE_*_SPEED_CHANGE` — the server changed one of our mover's six speeds (aura, + /// mount, GM `.modify speed`) and awaits the matching `CMSG_FORCE_*_SPEED_CHANGE_ACK` carrying + /// this `counter` + the exact `speed` back (see the opcode block's protocol note). `guid` is + /// the mover (packed on the wire); `speed` is flat yd/s (rad/s for the turn rate). + ForceSpeedChange { + guid: u64, + kind: SpeedKind, + counter: u32, + speed: f32, + }, + /// `SMSG_GROUP_INVITE` — someone invited us to their group (layout in + /// [`super::group::read_group_invite`]). + GroupInvite { + inviter: String, + }, + /// `SMSG_GROUP_DECLINE` — an invite we sent was declined. + GroupDecline { + name: String, + }, + /// `SMSG_GROUP_UNINVITE` — we were removed from our group (kicked or left); empty body. + GroupUninvited, + /// `SMSG_GROUP_SET_LEADER` — the group's leader changed. + GroupLeaderChanged { + name: String, + }, + /// `SMSG_GROUP_DESTROYED` — the group disbanded outright; empty body. + GroupDestroyed, + /// `SMSG_GROUP_LIST` — the full roster, sent on every membership change (layout in + /// [`super::group::read_group_list`]). `members` excludes the recipient's own row (`own_flags` + /// carries theirs); `loot` is `None` for the empty "you left" shape and whenever the list has + /// no other members. + GroupList { + group_type: u8, + own_flags: u8, + members: Vec, + leader: u64, + loot: Option, + }, + /// `SMSG_PARTY_COMMAND_RESULT` — the server's verdict on a group command we issued + /// (invite/leave): `operation` a [`super::group::party_operation`] code, `result` a + /// [`super::group::party_result`] code. + PartyCommandResult { + operation: u32, + member: String, + result: u32, + }, + /// `SMSG_PARTY_MEMBER_STATS` / `_FULL` — a party/raid member's live stats for the frame + /// (layout in [`super::group::read_party_member_stats`]); `full` distinguishes the delta form + /// from the ask-once full form (our own [`super::group::request_party_member_stats`], or the + /// offline-miss reply). Boxed: [`PartyMemberStatsInfo`]'s ~20 optional fields would otherwise + /// dwarf every other variant here. + PartyMemberStats { + guid: u64, + full: bool, + info: Box, + }, + /// `MSG_MINIMAP_PING` — someone pinged the minimap for the group. + MinimapPing { + guid: u64, + x: f32, + y: f32, + }, + /// `MSG_RAID_TARGET_UPDATE`, delta shape — one raid-target icon changed; `guid == 0` clears it. + RaidTargetSet { + icon: u8, + guid: u64, + }, + /// `MSG_RAID_TARGET_UPDATE`, full-list shape — the whole current icon set (only currently-set + /// icons are present). + RaidTargetList { + entries: Vec<(u8, u64)>, + }, + /// `MSG_RAID_READY_CHECK`, empty body — the raid leader started a ready check. + ReadyCheckRequest, + /// `MSG_RAID_READY_CHECK`, non-empty body — one member's answer, forwarded to the leader only. + ReadyCheckAnswer { + guid: u64, + ready: u8, + }, + /// A `SMSG_SPLINE_SET_*_SPEED` — a speed change on a unit we don't control (a creature, or a + /// player mid-spline): `[packed guid][f32 speed]`, no counter, no ack (decision 0441 — how an + /// observed unit's mounted speed reaches us). + SplineSpeedChange { + guid: u64, + kind: SpeedKind, + speed: f32, + }, + /// A `MSG_MOVE_SET_*_SPEED` — a freely-moving *player's* speed change (the common observer + /// case: someone mounts up nearby): `[packed guid][MovementInfo][f32 speed]` — a speed change + /// AND a fresh authoritative pose in one packet (decision 0441). + MoveSetSpeed { + guid: u64, + kind: SpeedKind, + flags: u32, + position: Vector3d, + orientation: f32, + /// The swim pitch from the `MovementInfo` tail (see [`Self::PlayerMove::pitch`]). + pitch: f32, + /// The `MovementInfo` time word (see [`Self::PlayerMove::time`]). + time: u32, + fall_time: u32, + jump: Option, + /// The rider's platform frame when ON_TRANSPORT is set (a speed change aboard a boat + /// carries the same MovementInfo as any relay — decision 0438's frame law applies). + transport: Option, + speed: f32, + }, + /// `SMSG_MOUNTRESULT` / `SMSG_DISMOUNTRESULT` — the server's answer to a (dis)mount attempt + /// (`mount` distinguishes them): one raw result code (vmangos `UnitMountResult` / + /// `UnitDismountResult`; OK = 10 / 3). Decision 0441; error lines are a P2 trimming. + MountResult { + mount: bool, + code: u32, + }, + /// `SMSG_MOUNTSPECIAL_ANIM` — a nearby mounted player hit the flourish (one raw u64 guid; + /// VERIFIED vmangos `HandleMountSpecialAnimOpcode`, `MovementHandler.cpp:969-970`). The + /// sender is excluded from the broadcast, so this only ever names someone else's mount. + MountSpecialAnim { + guid: u64, + }, + /// `SMSG_SHOWTAXINODES` — the taxi map (vmangos `SendTaxiMenu`, `TaxiHandler.cpp:82-96`; + /// layout in [`super::taxi::read_show_taxi_nodes`], decision 0484). `window` is the + /// window-framing constant vmangos always writes `1`; `flightmaster` names the NPC the menu + /// opened on (its taxi mount resolves through the `benilla-formats` `TaxiNodes` catalog); + /// `nearest_node` is the node the flight master itself sits at; `known` is the full + /// known-node bitmask. + ShowTaxiNodes { + window: u32, + flightmaster: u64, + nearest_node: u32, + known: TaxiMask, + }, + /// `SMSG_TAXINODE_STATUS` — answers `CMSG_TAXINODE_STATUS_QUERY`, and also rides a + /// first-visit "learn" (vmangos `SendLearnNewTaxiNode`, `TaxiHandler.cpp:117-138`): `guid` + /// names the flight master asked about (plain, not packed); `known` is whether the nearest + /// node to it is in our taxi mask. + TaxiNodeStatus { + guid: u64, + known: bool, + }, + /// `SMSG_ACTIVATETAXIREPLY` — answers `CMSG_ACTIVATETAXI`/`CMSG_ACTIVATETAXIEXPRESS`: `code` + /// is a [`super::taxi_reply`] value (`0` OK, everything else a refusal — no flight starts). + ActivateTaxiReply { + code: u32, + }, + /// `SMSG_NEW_TAXI_PATH` — empty body. Rides a first-visit "learn" alongside + /// [`Self::TaxiNodeStatus`] (vmangos `SendLearnNewTaxiNode`); the client has nothing to read + /// from it, but the wire is modelled rather than silently dropped. + NewTaxiPath, + /// `SMSG_MAIL_LIST_RESULT` — the inbox page, answering `CMSG_GET_MAIL_LIST` (layout in + /// [`super::mail::read_mail_list_result`], decision 0544 P0). + MailList { + mails: Vec, + }, + /// `SMSG_SEND_MAIL_RESULT` — the verdict on a mail action (send/take-money/take-item/return/ + /// delete), keyed by `action` ([`super::mail::mail_action`]) and `error` + /// ([`super::mail::mail_error`]); `equip_error`/`item` are the two mutually-exclusive + /// conditional tails (layout in [`super::mail::read_send_mail_result`]). + SendMailResult { + mail_id: u32, + action: u32, + error: u32, + equip_error: Option, + item: Option<(u32, u32)>, + }, + /// `SMSG_ITEM_TEXT_QUERY_RESPONSE` — a letter's body text, answering `CMSG_ITEM_TEXT_QUERY` + /// (the ask-once fetch a mail's nonzero `item_text_id` triggers). + ItemTextQueryResponse { + text_id: u32, + text: String, + }, + /// `SMSG_RECEIVED_MAIL` — a mail arrived (instant for text-only, on the delivery timer's + /// expiry otherwise); the wire's lone `u32` is always 0 on vmangos, so nothing rides this. + ReceivedMail, + /// `MSG_QUERY_NEXT_MAIL_TIME`'s reply (same opcode as our empty-body request): `0.0` = unread + /// mail waiting, `-86400.0` = none. + NextMailTime { + seconds: f32, + }, + /// `SMSG_TRADE_STATUS` — one pulse of the trade state machine (open/accept/cancel/complete/ + /// the refusal reasons); the tail-carrying statuses hold their payload in [`TradeStatus`] + /// (layout in [`super::trade::read_trade_status`], decision 0592 P0). + TradeStatus { + status: TradeStatus, + }, + /// `SMSG_TRADE_STATUS_EXTENDED` — the item/gold snapshot for one window side, pushed whenever + /// that side's offer changes (layout in [`super::trade::read_trade_status_extended`]). Boxed: + /// the seven-slot item array is ~460 bytes, and it would otherwise bloat every `ServerPacket` + /// (the same reason `QuestQueryResponse`/`PartyMemberStats` box their payloads). + TradeStatusExtended { + state: Box, + }, + Other { + opcode: u16, + }, +} + +impl ServerPacket { + /// A short human name for logging/tallying (the opcode in hex for unmodelled packets). + pub fn name(&self) -> String { + match self { + ServerPacket::AuthChallenge { .. } => "SMSG_AUTH_CHALLENGE".into(), + ServerPacket::AuthResponse { .. } => "SMSG_AUTH_RESPONSE".into(), + ServerPacket::CharEnum { .. } => "SMSG_CHAR_ENUM".into(), + ServerPacket::CharCreate { .. } => "SMSG_CHAR_CREATE".into(), + ServerPacket::CharDelete { .. } => "SMSG_CHAR_DELETE".into(), + ServerPacket::UpdateObject { .. } => "SMSG_UPDATE_OBJECT".into(), + ServerPacket::DestroyObject { .. } => "SMSG_DESTROY_OBJECT".into(), + ServerPacket::TriggerCinematic { .. } => "SMSG_TRIGGER_CINEMATIC".into(), + ServerPacket::MonsterMove { .. } => "SMSG_MONSTER_MOVE".into(), + ServerPacket::PlayerMove { opcode, .. } => format!("MSG_MOVE relay ({opcode:#06x})"), + ServerPacket::Teleport { .. } => "MSG_MOVE_TELEPORT_ACK".into(), + ServerPacket::NewWorld { .. } => "SMSG_NEW_WORLD".into(), + ServerPacket::TransferPending { .. } => "SMSG_TRANSFER_PENDING".into(), + ServerPacket::TransferAborted { .. } => "SMSG_TRANSFER_ABORTED".into(), + ServerPacket::LoginVerifyWorld { .. } => "SMSG_LOGIN_VERIFY_WORLD".into(), + ServerPacket::TimeSpeed { .. } => "SMSG_LOGIN_SETTIMESPEED".into(), + ServerPacket::BindPoint { .. } => "SMSG_BINDPOINTUPDATE".into(), + ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(), + ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(), + ServerPacket::SetFactionStanding { .. } => "SMSG_SET_FACTION_STANDING".into(), + ServerPacket::NameQueryResponse { .. } => "SMSG_NAME_QUERY_RESPONSE".into(), + ServerPacket::CreatureQueryResponse { .. } => "SMSG_CREATURE_QUERY_RESPONSE".into(), + ServerPacket::GameObjectQueryResponse { .. } => "SMSG_GAMEOBJECT_QUERY_RESPONSE".into(), + ServerPacket::PlaySound { .. } => "SMSG_PLAY_SOUND".into(), + ServerPacket::PlayMusic { .. } => "SMSG_PLAY_MUSIC".into(), + ServerPacket::PlayObjectSound { .. } => "SMSG_PLAY_OBJECT_SOUND".into(), + ServerPacket::Weather { .. } => "SMSG_WEATHER".into(), + ServerPacket::TextEmote { .. } => "SMSG_TEXT_EMOTE".into(), + ServerPacket::Emote { .. } => "SMSG_EMOTE".into(), + ServerPacket::ItemQueryResponse { .. } => "SMSG_ITEM_QUERY_SINGLE_RESPONSE".into(), + ServerPacket::InventoryChangeFailure { .. } => "SMSG_INVENTORY_CHANGE_FAILURE".into(), + ServerPacket::MessageChat(_) => "SMSG_MESSAGECHAT".into(), + ServerPacket::ChannelNotify(_) => "SMSG_CHANNEL_NOTIFY".into(), + ServerPacket::ChannelList { .. } => "SMSG_CHANNEL_LIST".into(), + ServerPacket::ChatPlayerNotFound { .. } => "SMSG_CHAT_PLAYER_NOT_FOUND".into(), + ServerPacket::ChatWrongFaction => "SMSG_CHAT_WRONG_FACTION".into(), + ServerPacket::Notification { .. } => "SMSG_NOTIFICATION".into(), + ServerPacket::PlayedTime { .. } => "SMSG_PLAYED_TIME".into(), + ServerPacket::RandomRoll { .. } => "MSG_RANDOM_ROLL".into(), + ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(), + ServerPacket::ActionButtons { .. } => "SMSG_ACTION_BUTTONS".into(), + ServerPacket::LearnedSpell { .. } => "SMSG_LEARNED_SPELL".into(), + ServerPacket::SupercededSpell { .. } => "SMSG_SUPERCEDED_SPELL".into(), + ServerPacket::CastResult { .. } => "SMSG_CAST_RESULT".into(), + ServerPacket::AttackStart { .. } => "SMSG_ATTACKSTART".into(), + ServerPacket::AttackStop { .. } => "SMSG_ATTACKSTOP".into(), + ServerPacket::AttackerState(_) => "SMSG_ATTACKERSTATEUPDATE".into(), + ServerPacket::AiReaction { .. } => "SMSG_AI_REACTION".into(), + ServerPacket::SpellStart(_) => "SMSG_SPELL_START".into(), + ServerPacket::SpellGo(_) => "SMSG_SPELL_GO".into(), + ServerPacket::SpellFailedOther { .. } => "SMSG_SPELL_FAILED_OTHER".into(), + ServerPacket::SpellDelayed { .. } => "SMSG_SPELL_DELAYED".into(), + ServerPacket::CancelAutoRepeat => "SMSG_CANCEL_AUTO_REPEAT".into(), + ServerPacket::SpellCooldownList { .. } => "SMSG_SPELL_COOLDOWN".into(), + ServerPacket::ItemCooldown { .. } => "SMSG_ITEM_COOLDOWN".into(), + ServerPacket::CooldownEvent { .. } => "SMSG_COOLDOWN_EVENT".into(), + ServerPacket::ClearCooldown { .. } => "SMSG_CLEAR_COOLDOWN".into(), + ServerPacket::CooldownCheat { .. } => "SMSG_COOLDOWN_CHEAT".into(), + ServerPacket::ChannelStart { .. } => "MSG_CHANNEL_START".into(), + ServerPacket::ChannelUpdate { .. } => "MSG_CHANNEL_UPDATE".into(), + ServerPacket::UpdateAuraDuration { .. } => "SMSG_UPDATE_AURA_DURATION".into(), + ServerPacket::PlaySpellVisual { .. } => "SMSG_PLAY_SPELL_VISUAL".into(), + ServerPacket::SpellDamageLog(_) => "SMSG_SPELLNONMELEEDAMAGELOG".into(), + ServerPacket::PeriodicAuraLog(_) => "SMSG_PERIODICAURALOG".into(), + ServerPacket::SpellHealLog(_) => "SMSG_SPELLHEALLOG".into(), + ServerPacket::SpellEnergizeLog(_) => "SMSG_SPELLENERGIZELOG".into(), + ServerPacket::DamageShield(_) => "SMSG_SPELLDAMAGESHIELD".into(), + ServerPacket::EnvironmentalDamageLog(_) => "SMSG_ENVIRONMENTALDAMAGELOG".into(), + ServerPacket::SpellLogMiss(_) => "SMSG_SPELLLOGMISS".into(), + ServerPacket::XpGain(_) => "SMSG_LOG_XPGAIN".into(), + ServerPacket::LevelUp(_) => "SMSG_LEVELUP_INFO".into(), + ServerPacket::QuestGiverStatus { .. } => "SMSG_QUESTGIVER_STATUS".into(), + ServerPacket::QuestGiverQuestList(_) => "SMSG_QUESTGIVER_QUEST_LIST".into(), + ServerPacket::QuestGiverDetails(_) => "SMSG_QUESTGIVER_QUEST_DETAILS".into(), + ServerPacket::QuestGiverRequestItems(_) => "SMSG_QUESTGIVER_REQUEST_ITEMS".into(), + ServerPacket::QuestGiverOfferReward(_) => "SMSG_QUESTGIVER_OFFER_REWARD".into(), + ServerPacket::QuestGiverComplete(_) => "SMSG_QUESTGIVER_QUEST_COMPLETE".into(), + ServerPacket::QuestGiverInvalid { .. } => "SMSG_QUESTGIVER_QUEST_INVALID".into(), + ServerPacket::QuestGiverFailed { .. } => "SMSG_QUESTGIVER_QUEST_FAILED".into(), + ServerPacket::QuestQueryResponse(_) => "SMSG_QUEST_QUERY_RESPONSE".into(), + ServerPacket::QuestLogFull => "SMSG_QUESTLOG_FULL".into(), + ServerPacket::QuestUpdateComplete { .. } => "SMSG_QUESTUPDATE_COMPLETE".into(), + ServerPacket::QuestUpdateFailed { .. } => "SMSG_QUESTUPDATE_FAILED".into(), + ServerPacket::QuestUpdateFailedTimer { .. } => "SMSG_QUESTUPDATE_FAILEDTIMER".into(), + ServerPacket::QuestUpdateAddKill { .. } => "SMSG_QUESTUPDATE_ADD_KILL".into(), + ServerPacket::QuestUpdateAddItem { .. } => "SMSG_QUESTUPDATE_ADD_ITEM".into(), + ServerPacket::GossipMessage { .. } => "SMSG_GOSSIP_MESSAGE".into(), + ServerPacket::GossipComplete => "SMSG_GOSSIP_COMPLETE".into(), + ServerPacket::NpcText { .. } => "SMSG_NPC_TEXT_UPDATE".into(), + ServerPacket::VendorList { .. } => "SMSG_LIST_INVENTORY".into(), + ServerPacket::BuyItem { .. } => "SMSG_BUY_ITEM".into(), + ServerPacket::SellItemResult { .. } => "SMSG_SELL_ITEM".into(), + ServerPacket::BuyFailed { .. } => "SMSG_BUY_FAILED".into(), + ServerPacket::ShowBank { .. } => "SMSG_SHOW_BANK".into(), + ServerPacket::BuyBankSlotResult { .. } => "SMSG_BUY_BANK_SLOT_RESULT".into(), + ServerPacket::TrainerList { .. } => "SMSG_TRAINER_LIST".into(), + ServerPacket::TrainerBuySucceeded { .. } => "SMSG_TRAINER_BUY_SUCCEEDED".into(), + ServerPacket::TrainerBuyFailed { .. } => "SMSG_TRAINER_BUY_FAILED".into(), + ServerPacket::LootResponse { .. } => "SMSG_LOOT_RESPONSE".into(), + ServerPacket::LootError { .. } => "SMSG_LOOT_RESPONSE (error)".into(), + ServerPacket::LootReleaseResponse { .. } => "SMSG_LOOT_RELEASE_RESPONSE".into(), + ServerPacket::LootRemoved { .. } => "SMSG_LOOT_REMOVED".into(), + ServerPacket::LootMoneyNotify { .. } => "SMSG_LOOT_MONEY_NOTIFY".into(), + ServerPacket::LootClearMoney => "SMSG_LOOT_CLEAR_MONEY".into(), + ServerPacket::LootStartRoll(_) => "SMSG_LOOT_START_ROLL".into(), + ServerPacket::LootRoll(_) => "SMSG_LOOT_ROLL".into(), + ServerPacket::LootRollWon(_) => "SMSG_LOOT_ROLL_WON".into(), + ServerPacket::LootAllPassed(_) => "SMSG_LOOT_ALL_PASSED".into(), + ServerPacket::ItemPushResult(_) => "SMSG_ITEM_PUSH_RESULT".into(), + ServerPacket::CorpseQuery(_) => "MSG_CORPSE_QUERY".into(), + ServerPacket::CorpseReclaimDelay { .. } => "SMSG_CORPSE_RECLAIM_DELAY".into(), + ServerPacket::DurabilityDamageDeath => "SMSG_DURABILITY_DAMAGE_DEATH".into(), + ServerPacket::ResurrectRequest(_) => "SMSG_RESURRECT_REQUEST".into(), + ServerPacket::SpiritHealerConfirm { .. } => "SMSG_SPIRIT_HEALER_CONFIRM".into(), + ServerPacket::MoveRoot { rooted: true, .. } => "SMSG_FORCE_MOVE_ROOT".into(), + ServerPacket::MoveRoot { rooted: false, .. } => "SMSG_FORCE_MOVE_UNROOT".into(), + ServerPacket::WaterWalk { on: true, .. } => "SMSG_MOVE_WATER_WALK".into(), + ServerPacket::WaterWalk { on: false, .. } => "SMSG_MOVE_LAND_WALK".into(), + ServerPacket::LogoutComplete => "SMSG_LOGOUT_COMPLETE".into(), + ServerPacket::LogoutResponse { .. } => "SMSG_LOGOUT_RESPONSE".into(), + ServerPacket::Pong { .. } => "SMSG_PONG".into(), + ServerPacket::ForceSpeedChange { kind, .. } => { + format!("SMSG_FORCE_{kind:?}_SPEED_CHANGE") + } + ServerPacket::GroupInvite { .. } => "SMSG_GROUP_INVITE".into(), + ServerPacket::GroupDecline { .. } => "SMSG_GROUP_DECLINE".into(), + ServerPacket::GroupUninvited => "SMSG_GROUP_UNINVITE".into(), + ServerPacket::GroupLeaderChanged { .. } => "SMSG_GROUP_SET_LEADER".into(), + ServerPacket::GroupDestroyed => "SMSG_GROUP_DESTROYED".into(), + ServerPacket::GroupList { .. } => "SMSG_GROUP_LIST".into(), + ServerPacket::PartyCommandResult { .. } => "SMSG_PARTY_COMMAND_RESULT".into(), + ServerPacket::PartyMemberStats { full: true, .. } => { + "SMSG_PARTY_MEMBER_STATS_FULL".into() + } + ServerPacket::PartyMemberStats { full: false, .. } => "SMSG_PARTY_MEMBER_STATS".into(), + ServerPacket::MinimapPing { .. } => "MSG_MINIMAP_PING".into(), + ServerPacket::RaidTargetSet { .. } | ServerPacket::RaidTargetList { .. } => { + "MSG_RAID_TARGET_UPDATE".into() + } + ServerPacket::ReadyCheckRequest | ServerPacket::ReadyCheckAnswer { .. } => { + "MSG_RAID_READY_CHECK".into() + } + ServerPacket::SplineSpeedChange { kind, .. } => { + format!("SMSG_SPLINE_SET_{kind:?}_SPEED") + } + ServerPacket::MoveSetSpeed { kind, .. } => format!("MSG_MOVE_SET_{kind:?}_SPEED"), + ServerPacket::MountResult { mount: true, .. } => "SMSG_MOUNTRESULT".into(), + ServerPacket::MountResult { mount: false, .. } => "SMSG_DISMOUNTRESULT".into(), + ServerPacket::MountSpecialAnim { .. } => "SMSG_MOUNTSPECIAL_ANIM".into(), + ServerPacket::ShowTaxiNodes { .. } => "SMSG_SHOWTAXINODES".into(), + ServerPacket::TaxiNodeStatus { .. } => "SMSG_TAXINODE_STATUS".into(), + ServerPacket::ActivateTaxiReply { .. } => "SMSG_ACTIVATETAXIREPLY".into(), + ServerPacket::NewTaxiPath => "SMSG_NEW_TAXI_PATH".into(), + ServerPacket::MailList { .. } => "SMSG_MAIL_LIST_RESULT".into(), + ServerPacket::SendMailResult { .. } => "SMSG_SEND_MAIL_RESULT".into(), + ServerPacket::ItemTextQueryResponse { .. } => "SMSG_ITEM_TEXT_QUERY_RESPONSE".into(), + ServerPacket::ReceivedMail => "SMSG_RECEIVED_MAIL".into(), + ServerPacket::NextMailTime { .. } => "MSG_QUERY_NEXT_MAIL_TIME".into(), + ServerPacket::TradeStatus { .. } => "SMSG_TRADE_STATUS".into(), + ServerPacket::TradeStatusExtended { .. } => "SMSG_TRADE_STATUS_EXTENDED".into(), + ServerPacket::Other { opcode } => format!("opcode {opcode:#06x}"), + } + } +} diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs new file mode 100644 index 00000000..e9eabb39 --- /dev/null +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -0,0 +1,875 @@ +//! The opcode→[`ServerPacket`] dispatch: [`parse_server`] decodes one server packet body (already +//! decrypted + sized) into its variant, delegating per-domain payloads to the domain children +//! (`spells`, `quest`, `loot`, …). One match arm per opcode — which opcode carries what stays +//! readable in one place. + +use std::io::{self, Read}; + +use crate::wire::{ + read_cstring, read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8, Vector3d, +}; + +use super::{ + bank, channel, chat, death, gameobject, gossip, group, items, loot, mail, monster_move, + movement, opcode, quest, spells, taxi, trade, trainer, update_object, vendor, Character, + CreatureQueryInfo, ServerPacket, SpeedKind, +}; + +/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`, +/// identical across all six kinds (VERIFIED vmangos `SendSpeedChangeToController`, the 5875 +/// `> 1_9_4` branch). The kind is the opcode's; the caller's arm names it. +fn read_force_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result { + Ok(ServerPacket::ForceSpeedChange { + guid: read_packed_guid(r)?, + kind, + counter: read_u32_le(r)?, + speed: read_f32_le(r)?, + }) +} + +/// Read one `SMSG_SPLINE_SET_*_SPEED` body — `[packed guid][f32 speed]` (VERIFIED vmangos +/// `MovementPacketSender::SendSpeedChangeToAll` + the mid-spline observer branch, the 5875 +/// `> 1_8_4` layout). Observer-only: no counter, no ack (decision 0441). +fn read_spline_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result { + Ok(ServerPacket::SplineSpeedChange { + guid: read_packed_guid(r)?, + kind, + speed: read_f32_le(r)?, + }) +} + +/// Read one `MSG_MOVE_SET_*_SPEED` body — `[packed guid][MovementInfo][f32 speed]` (VERIFIED +/// vmangos `SendSpeedChangeToObservers`, the finalized-spline branch): a freely-moving player's +/// speed change, carrying a fresh pose alongside (decision 0441). No ack. +fn read_move_set_speed(kind: SpeedKind, r: &mut impl Read) -> io::Result { + let guid = read_packed_guid(r)?; + let info = movement::read_movement_info(r)?; + Ok(ServerPacket::MoveSetSpeed { + guid, + kind, + flags: info.flags, + position: info.position, + orientation: info.orientation, + pitch: info.pitch, + time: info.timestamp, + fall_time: info.fall_time, + jump: info.jump, + transport: info.transport, + speed: read_f32_le(r)?, + }) +} + +/// True for a relayed player-movement opcode — one the server rebroadcasts as +/// `[packed guid][MovementInfo]` (every opcode bound to vmangos `HandleMovementOpcodes`, VERIFIED +/// `Opcodes.cpp`). Excludes `MSG_MOVE_TELEPORT_ACK` / `MSG_MOVE_WORLDPORT_ACK`, which share the family +/// but carry different bodies and are decoded by their own arms. +const fn is_movement_relay(o: u16) -> bool { + matches!( + o, + opcode::MSG_MOVE_START_FORWARD + | opcode::MSG_MOVE_START_BACKWARD + | opcode::MSG_MOVE_STOP + | opcode::MSG_MOVE_START_STRAFE_LEFT + | opcode::MSG_MOVE_START_STRAFE_RIGHT + | opcode::MSG_MOVE_STOP_STRAFE + | opcode::MSG_MOVE_JUMP + | opcode::MSG_MOVE_START_TURN_LEFT + | opcode::MSG_MOVE_START_TURN_RIGHT + | opcode::MSG_MOVE_STOP_TURN + | opcode::MSG_MOVE_START_PITCH_UP + | opcode::MSG_MOVE_START_PITCH_DOWN + | opcode::MSG_MOVE_STOP_PITCH + | opcode::MSG_MOVE_SET_RUN_MODE + | opcode::MSG_MOVE_SET_WALK_MODE + | opcode::MSG_MOVE_FALL_LAND + | opcode::MSG_MOVE_START_SWIM + | opcode::MSG_MOVE_STOP_SWIM + | opcode::MSG_MOVE_SET_FACING + | opcode::MSG_MOVE_SET_PITCH + | opcode::MSG_MOVE_HEARTBEAT + ) +} + +/// Parse a server packet body (already decrypted + sized) by opcode. +pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { + let mut r = body; + Ok(match opcode { + opcode::SMSG_AUTH_CHALLENGE => ServerPacket::AuthChallenge { + server_seed: read_u32_le(&mut r)?, + }, + opcode::SMSG_AUTH_RESPONSE => ServerPacket::AuthResponse { + result: read_u8(&mut r)?, + }, + opcode::SMSG_CHAR_ENUM => { + let count = read_u8(&mut r)?; + let mut characters = Vec::with_capacity(count as usize); + for _ in 0..count { + characters.push(Character::read(&mut r)?); + } + ServerPacket::CharEnum { characters } + } + opcode::SMSG_CHAR_DELETE => ServerPacket::CharDelete { + result: read_u8(&mut r)?, + }, + opcode::SMSG_CHAR_CREATE => ServerPacket::CharCreate { + result: read_u8(&mut r)?, + }, + opcode::SMSG_UPDATE_OBJECT => ServerPacket::UpdateObject { + objects: update_object::read_update_object(&mut r)?, + }, + opcode::SMSG_DESTROY_OBJECT => ServerPacket::DestroyObject { + guid: read_u64_le(&mut r)?, + }, + opcode::SMSG_TRIGGER_CINEMATIC => ServerPacket::TriggerCinematic { + cinematic_id: read_u32_le(&mut r)?, + }, + opcode::SMSG_COMPRESSED_UPDATE_OBJECT => { + let _decompressed_size = read_u32_le(&mut r)?; + let mut decoder = flate2::read::ZlibDecoder::new(r); + let mut decompressed = Vec::new(); + decoder.read_to_end(&mut decompressed)?; + let mut dr = decompressed.as_slice(); + ServerPacket::UpdateObject { + objects: update_object::read_update_object(&mut dr)?, + } + } + opcode::SMSG_MONSTER_MOVE => monster_move::read_monster_move(&mut r)?, + opcode::MSG_MOVE_TELEPORT_ACK => { + let guid = read_packed_guid(&mut r)?; + let counter = read_u32_le(&mut r)?; + let info = movement::read_movement_info(&mut r)?; + ServerPacket::Teleport { + guid, + counter, + position: info.position, + orientation: info.orientation, + } + } + opcode::SMSG_NEW_WORLD => { + let map = read_u32_le(&mut r)?; + let position = Vector3d::read(&mut r)?; + let orientation = read_f32_le(&mut r)?; + ServerPacket::NewWorld { + map, + position, + orientation, + } + } + opcode::SMSG_LOGIN_VERIFY_WORLD => { + let map = read_u32_le(&mut r)?; + let position = Vector3d::read(&mut r)?; + let orientation = read_f32_le(&mut r)?; + ServerPacket::LoginVerifyWorld { + map, + position, + orientation, + } + } + opcode::SMSG_TRANSFER_PENDING => { + // `u32 newMapId` +, iff the player rides a transport through the transfer, + // `{u32 transportEntry, u32 oldMapId}` (VERIFIED vmangos `Misc.cpp:493-501`). The + // block's PRESENCE is load-bearing: it decides whether the follow-up NEW_WORLD's + // coordinates are boat-local or world (decision 0455). + let map = read_u32_le(&mut r)?; + let transport = if r.is_empty() { + None + } else { + let entry = read_u32_le(&mut r)?; + let old_map = read_u32_le(&mut r)?; + Some((entry, old_map)) + }; + ServerPacket::TransferPending { map, transport } + } + opcode::SMSG_TRANSFER_ABORTED => ServerPacket::TransferAborted { + reason: read_u8(&mut r)?, + }, + opcode::SMSG_LOGIN_SETTIMESPEED => { + // The vanilla packed DateTime bit layout (LSB up): minute:6, hour:5, weekday:3, + // day-of-month:6, month:4, year:5. The day serial flattens it with the packed + // convention's fixed 31-day months / 372-day years — monotonic across the dates a + // server actually serves, which is all the celestial moon-phase precession needs. + let datetime = read_u32_le(&mut r)?; + let timescale = read_f32_le(&mut r)?; + let (day, month, year) = ( + (datetime >> 14) & 0x3F, + (datetime >> 20) & 0x0F, + (datetime >> 24) & 0x1F, + ); + ServerPacket::TimeSpeed { + hours: ((datetime >> 6) & 0x1F) as u8, + minutes: (datetime & 0x3F) as u8, + day_serial: year * 372 + month * 31 + day, + timescale, + } + } + opcode::SMSG_BINDPOINTUPDATE => { + // vmangos BindpointUpdate::AppendBodyTo (Packets/Misc.cpp): x, y, z, mapId, areaId. + let position = Vector3d::read(&mut r)?; + let map = read_u32_le(&mut r)?; + let area = read_u32_le(&mut r)?; + ServerPacket::BindPoint { + position, + map, + area, + } + } + opcode::SMSG_SET_PROFICIENCY => { + // vmangos SetProficiency::AppendBodyTo (Packets/Skill.cpp): u8 itemClass + u32 mask. + let item_class = read_u8(&mut r)?; + let subclass_mask = read_u32_le(&mut r)?; + ServerPacket::SetProficiency { + item_class, + subclass_mask, + } + } + opcode::SMSG_PLAY_SOUND => ServerPacket::PlaySound { + sound_id: read_u32_le(&mut r)?, + }, + opcode::SMSG_PLAY_MUSIC => ServerPacket::PlayMusic { + music_id: read_u32_le(&mut r)?, + }, + opcode::SMSG_PLAY_OBJECT_SOUND => ServerPacket::PlayObjectSound { + sound_id: read_u32_le(&mut r)?, + guid: read_u64_le(&mut r)?, + }, + opcode::SMSG_WEATHER => ServerPacket::Weather { + weather_type: read_u32_le(&mut r)?, + grade: read_f32_le(&mut r)?, + sound_id: read_u32_le(&mut r)?, + instant: read_u8(&mut r)? != 0, + }, + opcode::SMSG_TEXT_EMOTE => { + let guid = read_u64_le(&mut r)?; + let text_emote = read_u32_le(&mut r)?; + let _emote_num = read_u32_le(&mut r)?; + let namelen = read_u32_le(&mut r)?; + for _ in 0..namelen { + read_u8(&mut r)?; // the target's name — chat consumes it later; audio keys on ids + } + ServerPacket::TextEmote { guid, text_emote } + } + opcode::SMSG_EMOTE => { + let emote_id = read_u32_le(&mut r)?; + let guid = read_u64_le(&mut r)?; + ServerPacket::Emote { guid, emote_id } + } + opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE => { + let (entry, info) = items::read_item_query_response(&mut r)?; + ServerPacket::ItemQueryResponse { + entry, + info: info.map(Box::new), + } + } + opcode::SMSG_MESSAGECHAT => ServerPacket::MessageChat(chat::read_message_chat(&mut r)?), + opcode::SMSG_CHANNEL_NOTIFY => { + ServerPacket::ChannelNotify(channel::read_channel_notify(&mut r)?) + } + opcode::SMSG_CHANNEL_LIST => { + let (name, flags, members) = channel::read_channel_list(&mut r)?; + ServerPacket::ChannelList { + channel: name, + flags, + members, + } + } + opcode::SMSG_CHAT_PLAYER_NOT_FOUND => ServerPacket::ChatPlayerNotFound { + name: chat::read_chat_player_not_found(&mut r)?, + }, + opcode::SMSG_CHAT_WRONG_FACTION => ServerPacket::ChatWrongFaction, + opcode::SMSG_NOTIFICATION => ServerPacket::Notification { + text: chat::read_notification(&mut r)?, + }, + opcode::SMSG_PLAYED_TIME => { + let (total, level) = chat::read_played_time(&mut r)?; + ServerPacket::PlayedTime { total, level } + } + opcode::MSG_RANDOM_ROLL => { + let (min, max, roll, guid) = chat::read_random_roll(&mut r)?; + ServerPacket::RandomRoll { + min, + max, + roll, + guid, + } + } + opcode::SMSG_INVENTORY_CHANGE_FAILURE => { + let (reason, required_level, item_guid) = items::read_inventory_change_failure(&mut r)?; + ServerPacket::InventoryChangeFailure { + reason, + required_level, + item_guid, + } + } + opcode::SMSG_INITIAL_SPELLS => { + let (spell_ids, cooldowns) = spells::read_initial_spells(&mut r)?; + ServerPacket::InitialSpells { + spell_ids, + cooldowns, + } + } + opcode::SMSG_ACTION_BUTTONS => ServerPacket::ActionButtons { + buttons: spells::read_action_buttons(&mut r)?, + }, + opcode::SMSG_LEARNED_SPELL => ServerPacket::LearnedSpell { + spell_id: spells::read_learned_spell(&mut r)?, + }, + opcode::SMSG_SUPERCEDED_SPELL => { + let (old_spell_id, new_spell_id) = spells::read_superceded_spell(&mut r)?; + ServerPacket::SupercededSpell { + old_spell_id, + new_spell_id, + } + } + opcode::SMSG_CAST_RESULT => { + let (spell_id, outcome) = spells::read_cast_result(&mut r)?; + ServerPacket::CastResult { spell_id, outcome } + } + opcode::SMSG_ATTACKSTART => { + let (attacker, victim) = spells::read_attack_start(&mut r)?; + ServerPacket::AttackStart { attacker, victim } + } + opcode::SMSG_ATTACKSTOP => { + let (attacker, victim) = spells::read_attack_stop(&mut r)?; + ServerPacket::AttackStop { attacker, victim } + } + opcode::SMSG_ATTACKERSTATEUPDATE => { + ServerPacket::AttackerState(spells::read_attacker_state(&mut r)?) + } + opcode::SMSG_AI_REACTION => { + let (unit, reaction) = spells::read_ai_reaction(&mut r)?; + ServerPacket::AiReaction { unit, reaction } + } + opcode::SMSG_SPELL_START => ServerPacket::SpellStart(spells::read_spell_start(&mut r)?), + opcode::SMSG_SPELL_GO => ServerPacket::SpellGo(spells::read_spell_go(&mut r)?), + opcode::SMSG_SPELL_FAILED_OTHER => { + let (caster, spell_id) = spells::read_spell_failed_other(&mut r)?; + ServerPacket::SpellFailedOther { caster, spell_id } + } + opcode::SMSG_SPELL_DELAYED => { + let (caster, delay_ms) = spells::read_spell_delayed(&mut r)?; + ServerPacket::SpellDelayed { caster, delay_ms } + } + opcode::SMSG_CANCEL_AUTO_REPEAT => ServerPacket::CancelAutoRepeat, + opcode::SMSG_SPELL_COOLDOWN => { + let (caster, cooldowns) = spells::read_spell_cooldown(&mut r)?; + ServerPacket::SpellCooldownList { caster, cooldowns } + } + opcode::SMSG_ITEM_COOLDOWN => { + let (item_guid, spell_id) = spells::read_item_cooldown(&mut r)?; + ServerPacket::ItemCooldown { + item_guid, + spell_id, + } + } + opcode::SMSG_COOLDOWN_EVENT => { + let (spell_id, caster) = spells::read_cooldown_event(&mut r)?; + ServerPacket::CooldownEvent { spell_id, caster } + } + opcode::SMSG_CLEAR_COOLDOWN => { + let (spell_id, caster) = spells::read_cooldown_event(&mut r)?; + ServerPacket::ClearCooldown { spell_id, caster } + } + opcode::SMSG_COOLDOWN_CHEAT => ServerPacket::CooldownCheat { + caster: spells::read_cooldown_cheat(&mut r)?, + }, + opcode::MSG_CHANNEL_START => { + let (spell_id, duration_ms) = spells::read_channel_start(&mut r)?; + ServerPacket::ChannelStart { + spell_id, + duration_ms, + } + } + opcode::MSG_CHANNEL_UPDATE => ServerPacket::ChannelUpdate { + remaining_ms: spells::read_channel_update(&mut r)?, + }, + opcode::SMSG_UPDATE_AURA_DURATION => { + let (slot, remaining_ms) = spells::read_update_aura_duration(&mut r)?; + ServerPacket::UpdateAuraDuration { slot, remaining_ms } + } + opcode::SMSG_PLAY_SPELL_VISUAL => { + let (unit, kit_id) = spells::read_play_spell_visual(&mut r)?; + ServerPacket::PlaySpellVisual { unit, kit_id } + } + opcode::SMSG_SPELLNONMELEEDAMAGELOG => { + ServerPacket::SpellDamageLog(spells::read_spell_damage_log(&mut r)?) + } + opcode::SMSG_PERIODICAURALOG => { + ServerPacket::PeriodicAuraLog(spells::read_periodic_aura_log(&mut r)?) + } + opcode::SMSG_SPELLDAMAGESHIELD => { + ServerPacket::DamageShield(spells::read_damage_shield(&mut r)?) + } + opcode::SMSG_SPELLHEALLOG => { + ServerPacket::SpellHealLog(spells::read_spell_heal_log(&mut r)?) + } + opcode::SMSG_SPELLENERGIZELOG => { + ServerPacket::SpellEnergizeLog(spells::read_spell_energize_log(&mut r)?) + } + opcode::SMSG_ENVIRONMENTALDAMAGELOG => { + ServerPacket::EnvironmentalDamageLog(spells::read_environmental_damage_log(&mut r)?) + } + opcode::SMSG_SPELLLOGMISS => { + ServerPacket::SpellLogMiss(spells::read_spell_log_miss(&mut r)?) + } + opcode::SMSG_LOG_XPGAIN => ServerPacket::XpGain(spells::read_xp_gain(&mut r)?), + opcode::SMSG_LEVELUP_INFO => ServerPacket::LevelUp(spells::read_level_up_info(&mut r)?), + opcode::SMSG_QUESTGIVER_STATUS => { + let (npc, status) = quest::read_questgiver_status(&mut r)?; + ServerPacket::QuestGiverStatus { npc, status } + } + opcode::SMSG_QUESTGIVER_QUEST_LIST => { + ServerPacket::QuestGiverQuestList(quest::read_questgiver_quest_list(&mut r)?) + } + opcode::SMSG_QUESTGIVER_QUEST_DETAILS => { + ServerPacket::QuestGiverDetails(quest::read_questgiver_quest_details(&mut r)?) + } + opcode::SMSG_QUESTGIVER_REQUEST_ITEMS => { + ServerPacket::QuestGiverRequestItems(quest::read_questgiver_request_items(&mut r)?) + } + opcode::SMSG_QUESTGIVER_OFFER_REWARD => { + ServerPacket::QuestGiverOfferReward(quest::read_questgiver_offer_reward(&mut r)?) + } + opcode::SMSG_QUESTGIVER_QUEST_COMPLETE => { + ServerPacket::QuestGiverComplete(quest::read_questgiver_quest_complete(&mut r)?) + } + opcode::SMSG_QUESTGIVER_QUEST_INVALID => ServerPacket::QuestGiverInvalid { + msg: quest::read_questgiver_quest_invalid(&mut r)?, + }, + opcode::SMSG_QUESTGIVER_QUEST_FAILED => { + let (quest_id, reason) = quest::read_questgiver_quest_failed(&mut r)?; + ServerPacket::QuestGiverFailed { quest_id, reason } + } + opcode::SMSG_QUEST_QUERY_RESPONSE => { + ServerPacket::QuestQueryResponse(Box::new(quest::read_quest_query_response(&mut r)?)) + } + opcode::SMSG_QUESTLOG_FULL => ServerPacket::QuestLogFull, + opcode::SMSG_QUESTUPDATE_COMPLETE => ServerPacket::QuestUpdateComplete { + quest_id: quest::read_quest_update_complete(&mut r)?, + }, + opcode::SMSG_QUESTUPDATE_FAILED => ServerPacket::QuestUpdateFailed { + quest_id: quest::read_quest_update_failed(&mut r)?, + }, + opcode::SMSG_QUESTUPDATE_FAILEDTIMER => ServerPacket::QuestUpdateFailedTimer { + quest_id: quest::read_quest_update_failedtimer(&mut r)?, + }, + opcode::SMSG_QUESTUPDATE_ADD_KILL => { + let (quest_id, entry, count, required, guid) = + quest::read_quest_update_add_kill(&mut r)?; + ServerPacket::QuestUpdateAddKill { + quest_id, + entry, + count, + required, + guid, + } + } + opcode::SMSG_QUESTUPDATE_ADD_ITEM => { + let (item_id, count) = quest::read_quest_update_add_item(&mut r)?; + ServerPacket::QuestUpdateAddItem { item_id, count } + } + opcode::SMSG_GOSSIP_MESSAGE => { + let (npc, text_id, options, quests) = gossip::read_gossip_message(&mut r)?; + ServerPacket::GossipMessage { + npc, + text_id, + options, + quests, + } + } + opcode::SMSG_GOSSIP_COMPLETE => ServerPacket::GossipComplete, + opcode::SMSG_NPC_TEXT_UPDATE => { + let (text_id, greeting) = gossip::read_npc_text_update(&mut r)?; + ServerPacket::NpcText { text_id, greeting } + } + opcode::SMSG_LIST_INVENTORY => { + let (vendor, items) = vendor::read_list_inventory(&mut r)?; + ServerPacket::VendorList { vendor, items } + } + opcode::SMSG_BUY_ITEM => { + let (vendor, slot, new_count, purchase_count) = vendor::read_buy_item(&mut r)?; + ServerPacket::BuyItem { + vendor, + slot, + new_count, + purchase_count, + } + } + opcode::SMSG_SELL_ITEM => { + let (vendor, item_guid, reason) = vendor::read_sell_item(&mut r)?; + ServerPacket::SellItemResult { + vendor, + item_guid, + reason, + } + } + opcode::SMSG_BUY_FAILED => { + let (vendor, item_entry, reason) = vendor::read_buy_failed(&mut r)?; + ServerPacket::BuyFailed { + vendor, + item_entry, + reason, + } + } + opcode::SMSG_SHOW_BANK => { + let banker = bank::read_show_bank(&mut r)?; + ServerPacket::ShowBank { banker } + } + opcode::SMSG_BUY_BANK_SLOT_RESULT => { + let result = bank::read_buy_bank_slot_result(&mut r)?; + ServerPacket::BuyBankSlotResult { result } + } + opcode::SMSG_TRAINER_LIST => { + let (trainer, trainer_type, services, title) = trainer::read_trainer_list(&mut r)?; + ServerPacket::TrainerList { + trainer, + trainer_type, + services, + title, + } + } + opcode::SMSG_TRAINER_BUY_SUCCEEDED => { + let (trainer, spell_id) = trainer::read_trainer_buy_succeeded(&mut r)?; + ServerPacket::TrainerBuySucceeded { trainer, spell_id } + } + opcode::SMSG_TRAINER_BUY_FAILED => { + let (trainer, spell_id, error) = trainer::read_trainer_buy_failed(&mut r)?; + ServerPacket::TrainerBuyFailed { + trainer, + spell_id, + error, + } + } + opcode::SMSG_LOOT_RESPONSE => { + let (guid, body) = loot::read_loot_response(&mut r)?; + match body { + loot::LootResponseBody::Items { + loot_type, + gold, + items, + } => ServerPacket::LootResponse { + guid, + loot_type, + gold, + items, + }, + loot::LootResponseBody::Error { error } => ServerPacket::LootError { guid, error }, + } + } + opcode::SMSG_LOOT_RELEASE_RESPONSE => { + let (guid, result) = loot::read_loot_release_response(&mut r)?; + ServerPacket::LootReleaseResponse { guid, result } + } + opcode::SMSG_LOOT_REMOVED => ServerPacket::LootRemoved { + slot: loot::read_loot_removed(&mut r)?, + }, + opcode::SMSG_LOOT_MONEY_NOTIFY => ServerPacket::LootMoneyNotify { + amount: loot::read_loot_money_notify(&mut r)?, + }, + opcode::SMSG_LOOT_CLEAR_MONEY => ServerPacket::LootClearMoney, + opcode::SMSG_LOOT_START_ROLL => { + ServerPacket::LootStartRoll(loot::read_loot_start_roll(&mut r)?) + } + opcode::SMSG_LOOT_ROLL => ServerPacket::LootRoll(loot::read_loot_roll(&mut r)?), + opcode::SMSG_LOOT_ROLL_WON => ServerPacket::LootRollWon(loot::read_loot_roll_won(&mut r)?), + opcode::SMSG_LOOT_ALL_PASSED => { + ServerPacket::LootAllPassed(loot::read_loot_all_passed(&mut r)?) + } + opcode::SMSG_ITEM_PUSH_RESULT => { + ServerPacket::ItemPushResult(loot::read_item_push_result(&mut r)?) + } + opcode::MSG_CORPSE_QUERY => { + ServerPacket::CorpseQuery(death::read_corpse_query_response(&mut r)?) + } + opcode::SMSG_DURABILITY_DAMAGE_DEATH => ServerPacket::DurabilityDamageDeath, + opcode::SMSG_CORPSE_RECLAIM_DELAY => ServerPacket::CorpseReclaimDelay { + delay_ms: death::read_corpse_reclaim_delay(&mut r)?, + }, + opcode::SMSG_RESURRECT_REQUEST => { + ServerPacket::ResurrectRequest(death::read_resurrect_request(&mut r)?) + } + opcode::SMSG_SPIRIT_HEALER_CONFIRM => ServerPacket::SpiritHealerConfirm { + npc: death::read_spirit_healer_confirm(&mut r)?, + }, + // The ack'd movement-flag family (root at death / water-walk in ghost form): the mover- + // addressed shape is `packed guid + u32 counter` (VERIFIED vmangos + // `MovementPacketSender.cpp:342-366`, the `> CLIENT_BUILD_1_9_4` branch). The app must ack + // with the echoed counter or observers never see the change. + opcode::SMSG_FORCE_MOVE_ROOT | opcode::SMSG_FORCE_MOVE_UNROOT => { + let guid = read_packed_guid(&mut r)?; + let counter = read_u32_le(&mut r)?; + ServerPacket::MoveRoot { + guid, + counter, + rooted: opcode == opcode::SMSG_FORCE_MOVE_ROOT, + } + } + opcode::SMSG_MOVE_WATER_WALK | opcode::SMSG_MOVE_LAND_WALK => { + let guid = read_packed_guid(&mut r)?; + let counter = read_u32_le(&mut r)?; + ServerPacket::WaterWalk { + guid, + counter, + on: opcode == opcode::SMSG_MOVE_WATER_WALK, + } + } + opcode::SMSG_INITIALIZE_FACTIONS => { + let count = read_u32_le(&mut r)?; + let mut standings = Vec::with_capacity(count as usize); + for _ in 0..count { + let flags = read_u8(&mut r)?; + let standing = read_u32_le(&mut r)? as i32; + standings.push((flags, standing)); + } + ServerPacket::InitializeFactions { standings } + } + opcode::SMSG_SET_FACTION_STANDING => { + let count = read_u32_le(&mut r)?; + let mut standings = Vec::with_capacity(count as usize); + for _ in 0..count { + let list_id = read_u32_le(&mut r)?; + let standing = read_u32_le(&mut r)? as i32; + standings.push((list_id, standing)); + } + ServerPacket::SetFactionStanding { standings } + } + opcode::SMSG_NAME_QUERY_RESPONSE => { + let guid = read_u64_le(&mut r)?; + let name = read_cstring(&mut r)?; + let _realm = read_cstring(&mut r)?; // cross-realm BG name; empty on a single realm + ServerPacket::NameQueryResponse { + guid, + name, + race: read_u32_le(&mut r)?, + gender: read_u32_le(&mut r)?, + class: read_u32_le(&mut r)?, + } + } + opcode::SMSG_CREATURE_QUERY_RESPONSE => { + let entry = read_u32_le(&mut r)?; + // A miss is the lone entry echoed with its top bit set — nothing follows. + if entry & 0x8000_0000 != 0 { + ServerPacket::CreatureQueryResponse { + entry: entry & 0x7FFF_FFFF, + info: None, + } + } else { + let name = read_cstring(&mut r)?; + for _ in 0..3 { + let _ = read_cstring(&mut r)?; // name2..name4, always empty in 5875 + } + let subname = read_cstring(&mut r)?; + // The tail (VERIFIED vmangos `HandleCreatureQueryOpcode`, 5875 = every build + // guard included): type_flags, type, pet_family, RANK, unk, pet_spell_list_id, + // display_id (u32 ×7), then civilian + racial_leader (u8 ×2). We keep `type` + // (the `CreatureType.dbc` id — the TAB-target filter's input), `rank` (the unit + // tooltip's Elite/Boss word, decision 0276's level-line law), `type_flags` (bit + // 0x10 hides its faction line), and the `civilian`/`racial_leader` pair (its + // green CIVILIAN / white LEADER lines); the rest stays alignment-only. + let type_flags = read_u32_le(&mut r)?; + let creature_type = read_u32_le(&mut r)?; + let _pet_family = read_u32_le(&mut r)?; + let rank = read_u32_le(&mut r)?; + let _unk = read_u32_le(&mut r)?; + let _pet_spell_list = read_u32_le(&mut r)?; + let _display_id = read_u32_le(&mut r)?; + let civilian = read_u8(&mut r)? != 0; + let racial_leader = read_u8(&mut r)? != 0; + ServerPacket::CreatureQueryResponse { + entry, + info: Some(CreatureQueryInfo { + name, + subname, + creature_type, + rank, + type_flags, + civilian, + racial_leader, + }), + } + } + } + opcode::SMSG_GAMEOBJECT_QUERY_RESPONSE => { + let (entry, info) = gameobject::read_gameobject_query_response(&mut r)?; + ServerPacket::GameObjectQueryResponse { entry, info } + } + opcode::SMSG_LOGOUT_COMPLETE => ServerPacket::LogoutComplete, + opcode::SMSG_LOGOUT_RESPONSE => ServerPacket::LogoutResponse { + result: read_u32_le(&mut r)?, + }, + // The keepalive echo: our CMSG_PING's sequence number back (vmangos `_HandlePing` — the + // body is the one u32 it read from us). + opcode::SMSG_PONG => ServerPacket::Pong { + sequence: read_u32_le(&mut r)?, + }, + // The force-speed-change family — one arm per kind, one shared body reader (the wire shape + // and the mandatory ack protocol are documented on the opcode block). + opcode::SMSG_FORCE_WALK_SPEED_CHANGE => read_force_speed(SpeedKind::Walk, &mut r)?, + opcode::SMSG_FORCE_RUN_SPEED_CHANGE => read_force_speed(SpeedKind::Run, &mut r)?, + opcode::SMSG_FORCE_RUN_BACK_SPEED_CHANGE => read_force_speed(SpeedKind::RunBack, &mut r)?, + opcode::SMSG_FORCE_SWIM_SPEED_CHANGE => read_force_speed(SpeedKind::Swim, &mut r)?, + opcode::SMSG_FORCE_SWIM_BACK_SPEED_CHANGE => read_force_speed(SpeedKind::SwimBack, &mut r)?, + opcode::SMSG_FORCE_TURN_RATE_CHANGE => read_force_speed(SpeedKind::TurnRate, &mut r)?, + // The group/party family (see the opcode block's note; bodies in `group`). + opcode::SMSG_GROUP_INVITE => ServerPacket::GroupInvite { + inviter: group::read_group_invite(&mut r)?, + }, + opcode::SMSG_GROUP_DECLINE => ServerPacket::GroupDecline { + name: group::read_group_decline(&mut r)?, + }, + opcode::SMSG_GROUP_UNINVITE => ServerPacket::GroupUninvited, + opcode::SMSG_GROUP_SET_LEADER => ServerPacket::GroupLeaderChanged { + name: group::read_group_set_leader(&mut r)?, + }, + opcode::SMSG_GROUP_DESTROYED => ServerPacket::GroupDestroyed, + opcode::SMSG_GROUP_LIST => { + let (group_type, own_flags, members, leader, loot) = group::read_group_list(&mut r)?; + ServerPacket::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } + } + opcode::SMSG_PARTY_COMMAND_RESULT => { + let (operation, member, result) = group::read_party_command_result(&mut r)?; + ServerPacket::PartyCommandResult { + operation, + member, + result, + } + } + opcode::SMSG_PARTY_MEMBER_STATS | opcode::SMSG_PARTY_MEMBER_STATS_FULL => { + let (guid, info) = group::read_party_member_stats(&mut r)?; + ServerPacket::PartyMemberStats { + guid, + full: opcode == opcode::SMSG_PARTY_MEMBER_STATS_FULL, + info: Box::new(info), + } + } + opcode::MSG_MINIMAP_PING => { + let (guid, x, y) = group::read_minimap_ping(&mut r)?; + ServerPacket::MinimapPing { guid, x, y } + } + opcode::MSG_RAID_TARGET_UPDATE => match group::read_raid_target_update(&mut r)? { + group::RaidTargetUpdate::Delta { icon, guid } => { + ServerPacket::RaidTargetSet { icon, guid } + } + group::RaidTargetUpdate::List(entries) => ServerPacket::RaidTargetList { entries }, + }, + opcode::MSG_RAID_READY_CHECK => match group::read_ready_check(&mut r)? { + group::ReadyCheck::Started => ServerPacket::ReadyCheckRequest, + group::ReadyCheck::Answer { guid, ready } => { + ServerPacket::ReadyCheckAnswer { guid, ready } + } + }, + // The observer speed legs (decision 0441): a unit we don't control changed speed — a + // creature or mid-spline player (SPLINE family), or a freely-moving player (MOVE_SET + // family, which carries a fresh pose too). No ack on either. + opcode::SMSG_SPLINE_SET_WALK_SPEED => read_spline_speed(SpeedKind::Walk, &mut r)?, + opcode::SMSG_SPLINE_SET_RUN_SPEED => read_spline_speed(SpeedKind::Run, &mut r)?, + opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED => read_spline_speed(SpeedKind::RunBack, &mut r)?, + opcode::SMSG_SPLINE_SET_SWIM_SPEED => read_spline_speed(SpeedKind::Swim, &mut r)?, + opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED => read_spline_speed(SpeedKind::SwimBack, &mut r)?, + opcode::SMSG_SPLINE_SET_TURN_RATE => read_spline_speed(SpeedKind::TurnRate, &mut r)?, + opcode::MSG_MOVE_SET_WALK_SPEED => read_move_set_speed(SpeedKind::Walk, &mut r)?, + opcode::MSG_MOVE_SET_RUN_SPEED => read_move_set_speed(SpeedKind::Run, &mut r)?, + opcode::MSG_MOVE_SET_RUN_BACK_SPEED => read_move_set_speed(SpeedKind::RunBack, &mut r)?, + opcode::MSG_MOVE_SET_SWIM_SPEED => read_move_set_speed(SpeedKind::Swim, &mut r)?, + opcode::MSG_MOVE_SET_SWIM_BACK_SPEED => read_move_set_speed(SpeedKind::SwimBack, &mut r)?, + opcode::MSG_MOVE_SET_TURN_RATE => read_move_set_speed(SpeedKind::TurnRate, &mut r)?, + // Mount feedback (decision 0441): one u32 result code per attempt; error lines are a P2 + // trimming — decoded so the wire is modelled, surfaced for the debug log. + opcode::SMSG_MOUNTRESULT => ServerPacket::MountResult { + mount: true, + code: read_u32_le(&mut r)?, + }, + opcode::SMSG_DISMOUNTRESULT => ServerPacket::MountResult { + mount: false, + code: read_u32_le(&mut r)?, + }, + // A nearby rider's flourish: one raw u64 guid (VERIFIED vmangos + // `HandleMountSpecialAnimOpcode` — `data << GetObjectGuid()`, full 8 bytes, not packed). + opcode::SMSG_MOUNTSPECIAL_ANIM => ServerPacket::MountSpecialAnim { + guid: read_u64_le(&mut r)?, + }, + // The taxi/flight-master family (decision 0484): the map (SHOWTAXINODES), a status + // answer/first-visit learn (TAXINODE_STATUS), the activate verdict, and the multi-hop + // path ack (NEW_TAXI_PATH, empty body). + opcode::SMSG_SHOWTAXINODES => { + let (window, flightmaster, nearest_node, known) = taxi::read_show_taxi_nodes(&mut r)?; + ServerPacket::ShowTaxiNodes { + window, + flightmaster, + nearest_node, + known, + } + } + opcode::SMSG_TAXINODE_STATUS => { + let (guid, known) = taxi::read_taxi_node_status(&mut r)?; + ServerPacket::TaxiNodeStatus { + guid, + known: known != 0, + } + } + opcode::SMSG_ACTIVATETAXIREPLY => ServerPacket::ActivateTaxiReply { + code: taxi::read_activate_taxi_reply(&mut r)?, + }, + opcode::SMSG_NEW_TAXI_PATH => ServerPacket::NewTaxiPath, + // The mail arc (decision 0544 P0): the inbox list, the send/take/return/delete verdict, + // the letter-body ask-once fetch, and the arrival pair (RECEIVED_MAIL + the + // QUERY_NEXT_MAIL_TIME reply — same opcode as our empty-body request). + opcode::SMSG_MAIL_LIST_RESULT => ServerPacket::MailList { + mails: mail::read_mail_list_result(&mut r)?, + }, + opcode::SMSG_SEND_MAIL_RESULT => { + let (mail_id, action, error, equip_error, item) = mail::read_send_mail_result(&mut r)?; + ServerPacket::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } + } + opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE => { + let (text_id, text) = mail::read_item_text_query_response(&mut r)?; + ServerPacket::ItemTextQueryResponse { text_id, text } + } + opcode::SMSG_RECEIVED_MAIL => { + let _ = mail::read_received_mail(&mut r)?; + ServerPacket::ReceivedMail + } + opcode::MSG_QUERY_NEXT_MAIL_TIME => ServerPacket::NextMailTime { + seconds: mail::read_query_next_mail_time(&mut r)?, + }, + // The player-trade arc (decision 0592 P0): the state-machine status pulse and the + // item/gold snapshot for one window side. + opcode::SMSG_TRADE_STATUS => ServerPacket::TradeStatus { + status: trade::read_trade_status(&mut r)?, + }, + opcode::SMSG_TRADE_STATUS_EXTENDED => ServerPacket::TradeStatusExtended { + state: Box::new(trade::read_trade_status_extended(&mut r)?), + }, + // A relayed player movement packet: `[packed mover guid][MovementInfo]`, same opcode the mover + // sent. This is how other players' walking/turning/strafing reaches us (creatures use + // SMSG_MONSTER_MOVE instead). Surface the pose + live move-flags; the app extrapolates between. + o if is_movement_relay(o) => { + let guid = read_packed_guid(&mut r)?; + let info = movement::read_movement_info(&mut r)?; + ServerPacket::PlayerMove { + guid, + opcode: o, + flags: info.flags, + position: info.position, + orientation: info.orientation, + pitch: info.pitch, + time: info.timestamp, + fall_time: info.fall_time, + jump: info.jump, + transport: info.transport, + } + } + other => ServerPacket::Other { opcode: other }, + }) +} diff --git a/crates/benilla-protocol/src/messages/quest/giver.rs b/crates/benilla-protocol/src/messages/quest/giver.rs new file mode 100644 index 00000000..1c6aad2d --- /dev/null +++ b/crates/benilla-protocol/src/messages/quest/giver.rs @@ -0,0 +1,683 @@ +//! `SMSG_QUESTGIVER_*` / `CMSG_QUESTGIVER_*` — the "accept/turn-in a quest at an NPC" family +//! (opcodes 386-402, vmangos `Opcodes_1_12_1.h`, VERIFIED). This is the *giver panel* slice +//! (decision 0088): the four questgiver windows carry their own text, so it needs none of the full +//! `SMSG_QUEST_QUERY_RESPONSE` template — that lives in the sibling [`super::log`] slice. CMSG +//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's +//! `AppendBodyTo` writers (line citations inline). +//! +//! Wire traps this parser is written against the *byte stream* to survive (never the C++ control +//! flow): +//! - **QUEST_LIST icon is a `u32`** — `QuestListEntry`'s `icon` field is `uint32` (`Quest.h:270`), +//! not the `u8` the gossip-menu option icon uses. +//! - **OFFER_REWARD emotes are `{delay, emote}`** — reversed from DETAILS' `{emote, delay}` +//! (`Quest.cpp:271` vs `:308-309`). +//! - **DETAILS rewards parse is uniform across the HIDDEN_REWARDS fork** — the hidden branch writes +//! three `u32` zeros (`Quest.cpp:235-237`), which a count-prefixed reader consumes as +//! `choiceCount=0, rewCount=0, money=0`, identical in shape to an empty rewards block. The parser +//! can't see the quest flag, and it doesn't need to. + +use std::io; + +use crate::wire::{read_cstring, read_i32_le, read_u32_le, read_u64_le, read_u8}; + +/// `QUEST_EMOTE_COUNT` (vmangos `QuestDef.h:43`) — DETAILS always writes exactly this many emote +/// pairs; OFFER_REWARD writes a variable count (0..=4), so only DETAILS relies on the constant. +pub const QUEST_EMOTE_COUNT: u32 = 4; + +/// `DIALOG_STATUS_*` (vmangos `QuestDef.h:120-129`) — the questgiver dialog status a +/// `SMSG_QUESTGIVER_STATUS` carries per NPC guid, and the per-quest `icon` in a `QUEST_LIST` / +/// gossip quest entry. Drives the `!`/`?` over the NPC head (a world-marker concern, out of the +/// panel slice — parsed + stored per guid now, rendered later) and the greeting panel's +/// active-vs-available split. +pub mod dialog_status { + pub const NONE: u32 = 0; + pub const UNAVAILABLE: u32 = 1; + pub const CHAT: u32 = 2; + pub const INCOMPLETE: u32 = 3; + pub const REWARD_REP: u32 = 4; + pub const AVAILABLE: u32 = 5; + pub const REWARD_OLD: u32 = 6; + pub const REWARD2: u32 = 7; +} + +/// One reward/choice item on a questgiver panel (`{itemId u32, count u32, displayId u32}` triple, +/// as written in DETAILS/OFFER_REWARD — vmangos `Quest.cpp:244-249` etc.). `display_id` → +/// `ItemDisplayInfo.dbc` icon, exactly like a vendor row; a `0` id means the server had no template +/// (writes a zero triple, `Quest.cpp:248-249`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuestRewardItem { + pub item_id: u32, + pub count: u32, + pub display_id: u32, +} + +/// One required item on the progress panel (`SMSG_QUESTGIVER_REQUEST_ITEMS`), same triple shape as +/// [`QuestRewardItem`] (vmangos `Quest.cpp:376-381`). +pub type QuestRequiredItem = QuestRewardItem; + +/// One entry in a `SMSG_QUESTGIVER_QUEST_LIST` (the greeting panel's quest rows). `icon` is a +/// [`dialog_status`] value (**`u32`** — `Quest.h:270`, the list-entry trap), `level` the quest's +/// display level, `title` the row label. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestListEntry { + pub quest_id: u32, + pub icon: u32, + pub level: u32, + pub title: String, +} + +/// `SMSG_QUESTGIVER_QUEST_LIST` (vmangos `Quest.cpp:159-203`): the multi-quest greeting panel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestGiverList { + pub npc: u64, + pub greeting: String, + pub emote_delay: u32, + pub emote: u32, + pub quests: Vec, +} + +/// `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): the accept panel — full quest +/// text + the rewards on offer. `money` is the reward money (`GetRewOrReqMoney`, `i32`; negative on +/// a money-*sink* quest, which DETAILS shows as a required cost — rare, kept signed). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestDetails { + pub npc: u64, + pub quest_id: u32, + pub title: String, + pub details: String, + pub objectives: String, + pub auto_finish: u32, + pub choices: Vec, + pub rewards: Vec, + pub money: i32, + pub reward_spell: u32, +} + +/// `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): the reward panel — the turn-in +/// text + the rewards to grant. Note the emote pairs are `{delay, emote}` (reversed vs DETAILS) and +/// are consumed for alignment only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestOfferReward { + pub npc: u64, + pub quest_id: u32, + pub title: String, + pub offer_text: String, + pub auto_finish: u32, + pub choices: Vec, + pub rewards: Vec, + pub money: i32, + pub quest_flags: u32, + pub reward_spell: u32, +} + +/// `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): the progress panel — the "bring +/// me these" text + required items/money, and the four completion flags the client ANDs to enable +/// the Continue button. `is_complete` is derived from the second flag word (`0x03` complete / +/// `0x00` not — `Quest.cpp:388`), the only place the completability reaches the client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestRequestItems { + pub npc: u64, + pub quest_id: u32, + pub title: String, + pub request_text: String, + pub emote: u32, + pub close_on_cancel: u32, + pub required_money: u32, + pub required_items: Vec, + pub is_complete: bool, +} + +/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): the turn-in result — the XP + +/// money granted and the fixed items handed over. The reward *item* rows here carry only +/// `{itemId, count}` (no display id — the client already knows them from the offer panel). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestComplete { + pub quest_id: u32, + pub xp: u32, + pub money: u32, + pub items: Vec<(u32, u32)>, +} + +// ── CMSG encoders (all trivial, readers at vmangos `Quest.cpp:6-50`) ────────────────────────────── + +/// Body of `CMSG_QUESTGIVER_STATUS_QUERY` (`Quest.cpp:11-14`): one `u64` NPC guid. Answered by +/// `SMSG_QUESTGIVER_STATUS`. +pub fn questgiver_status_query(npc: u64) -> Vec { + npc.to_le_bytes().to_vec() +} + +/// Body of `CMSG_QUESTGIVER_HELLO` (`Quest.cpp:16-19`): one `u64` NPC guid. The same +/// `SendPreparedGossip` path as `CMSG_GOSSIP_HELLO` (vmangos `QuestHandler.cpp:103`), so benilla's +/// interact route can keep sending gossip-hello; this opener exists for completeness/parity. +pub fn questgiver_hello(npc: u64) -> Vec { + npc.to_le_bytes().to_vec() +} + +/// Body of `CMSG_QUESTGIVER_QUERY_QUEST` (`Quest.cpp:27-31`): `u64 guid, u32 quest`. Answered by +/// `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `QuestHandler.cpp:224`, always — this is "look at an +/// available quest"). The click a greeting/gossip quest row makes. +pub fn questgiver_query_quest(npc: u64, quest: u32) -> Vec { + guid_quest(npc, quest) +} + +/// Body of `CMSG_QUESTGIVER_ACCEPT_QUEST` (`Quest.cpp:21-25`): `u64 guid, u32 quest`. Adds the quest +/// to the log, then the server closes the gossip window (`SMSG_GOSSIP_COMPLETE`). +pub fn questgiver_accept_quest(npc: u64, quest: u32) -> Vec { + guid_quest(npc, quest) +} + +/// Body of `CMSG_QUESTGIVER_COMPLETE_QUEST` (`Quest.cpp:46-50`): `u64 guid, u32 quest`. Asks the +/// server to show the turn-in **progress** panel (`SMSG_QUESTGIVER_REQUEST_ITEMS`, vmangos +/// `QuestHandler.cpp:383-397`) — the alternate entry from a quest-log completable quest. +pub fn questgiver_complete_quest(npc: u64, quest: u32) -> Vec { + guid_quest(npc, quest) +} + +/// Body of `CMSG_QUESTGIVER_REQUEST_REWARD` (`Quest.cpp:40-44`): `u64 guid, u32 quest`. The progress +/// panel's Continue button — advances to the **reward** panel (`SMSG_QUESTGIVER_OFFER_REWARD`, +/// vmangos `QuestHandler.cpp:286-312`, which first `CompleteQuest`s if it can). +pub fn questgiver_request_reward(npc: u64, quest: u32) -> Vec { + guid_quest(npc, quest) +} + +/// Body of `CMSG_QUESTGIVER_CHOOSE_REWARD` (`Quest.cpp:33-38`): `u64 guid, u32 quest, u32 reward`. +/// `reward` is the **index** into the quest's choice items (0-based; ignored when the quest has no +/// choice rewards — pass 0). The reward panel's Complete button — grants the reward and answers +/// `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `QuestHandler.cpp:239-284`). +pub fn questgiver_choose_reward(npc: u64, quest: u32, reward: u32) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&npc.to_le_bytes()); + body.extend_from_slice(&quest.to_le_bytes()); + body.extend_from_slice(&reward.to_le_bytes()); + body +} + +/// The shared `{u64 guid, u32 quest}` body of the five simple questgiver CMSGs. +fn guid_quest(npc: u64, quest: u32) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&npc.to_le_bytes()); + body.extend_from_slice(&quest.to_le_bytes()); + body +} + +// ── SMSG parsers ────────────────────────────────────────────────────────────────────────────────── + +/// Read a `{itemId u32, count u32, displayId u32}` reward/choice/required triple (the shape shared +/// by DETAILS/OFFER_REWARD/REQUEST_ITEMS). +fn read_item_triple(r: &mut &[u8]) -> io::Result { + Ok(QuestRewardItem { + item_id: read_u32_le(r)?, + count: read_u32_le(r)?, + display_id: read_u32_le(r)?, + }) +} + +/// Read a count-prefixed run of item triples (`u32 count` then that many triples). +fn read_item_block(r: &mut &[u8]) -> io::Result> { + let count = read_u32_le(r)?; + let mut items = Vec::with_capacity(count as usize); + for _ in 0..count { + items.push(read_item_triple(r)?); + } + Ok(items) +} + +/// Read `SMSG_QUESTGIVER_STATUS` (vmangos `Quest.cpp:153-157`): `u64 npcGuid, u32 status`. +pub(in crate::messages) fn read_questgiver_status(r: &mut &[u8]) -> io::Result<(u64, u32)> { + Ok((read_u64_le(r)?, read_u32_le(r)?)) +} + +/// Read `SMSG_QUESTGIVER_QUEST_LIST` (vmangos `Quest.cpp:159-203`): `u64 npcGuid, cstr greeting, +/// u32 emoteDelay, u32 emote, u8 count`, then per quest `{u32 questId, u32 icon, u32 level, cstr +/// title}`. Note the entry `icon` is a **`u32`** (the list-entry trap), unlike the gossip option's +/// `u8` icon. +pub(in crate::messages) fn read_questgiver_quest_list(r: &mut &[u8]) -> io::Result { + let npc = read_u64_le(r)?; + let greeting = read_cstring(r)?; + let emote_delay = read_u32_le(r)?; + let emote = read_u32_le(r)?; + let count = read_u8(r)?; + let mut quests = Vec::with_capacity(count as usize); + for _ in 0..count { + quests.push(QuestListEntry { + quest_id: read_u32_le(r)?, + icon: read_u32_le(r)?, + level: read_u32_le(r)?, + title: read_cstring(r)?, + }); + } + Ok(QuestGiverList { + npc, + greeting, + emote_delay, + emote, + quests, + }) +} + +/// Read `SMSG_QUESTGIVER_QUEST_DETAILS` (vmangos `Quest.cpp:205-273`): `u64 npcGuid, u32 questId, +/// cstr title, cstr details, cstr objectives, u32 autoFinish`, then the count-prefixed choice block, +/// the count-prefixed reward block, `i32 money`, `u32 rewSpell`, `u32 emoteCount (=4)` and that many +/// `{u32 emote, u32 delay}` pairs (consumed for alignment). The HIDDEN_REWARDS fork writes three +/// `u32` zeros in place of the two blocks + money, which this count-prefixed reader consumes +/// identically to an empty rewards block (the uniform-parse trap). +pub(in crate::messages) fn read_questgiver_quest_details( + r: &mut &[u8], +) -> io::Result { + let npc = read_u64_le(r)?; + let quest_id = read_u32_le(r)?; + let title = read_cstring(r)?; + let details = read_cstring(r)?; + let objectives = read_cstring(r)?; + let auto_finish = read_u32_le(r)?; + let choices = read_item_block(r)?; + let rewards = read_item_block(r)?; + let money = read_i32_le(r)?; + let reward_spell = read_u32_le(r)?; + let emote_count = read_u32_le(r)?; + for _ in 0..emote_count { + let _emote = read_u32_le(r)?; + let _delay = read_u32_le(r)?; + } + Ok(QuestDetails { + npc, + quest_id, + title, + details, + objectives, + auto_finish, + choices, + rewards, + money, + reward_spell, + }) +} + +/// Read `SMSG_QUESTGIVER_OFFER_REWARD` (vmangos `Quest.cpp:275-339`): `u64 npcGuid, u32 questId, +/// cstr title, cstr offerText, u32 autoFinish, u32 emoteCount` then that many `{u32 delay, u32 +/// emote}` pairs (**delay first** — reversed vs DETAILS), the choice block, the reward block, `i32 +/// money, u32 questFlags, u32 rewSpell` (the spell is guarded by build > 1.4.2 server-side, which +/// 5875 satisfies — always present). +pub(in crate::messages) fn read_questgiver_offer_reward( + r: &mut &[u8], +) -> io::Result { + let npc = read_u64_le(r)?; + let quest_id = read_u32_le(r)?; + let title = read_cstring(r)?; + let offer_text = read_cstring(r)?; + let auto_finish = read_u32_le(r)?; + let emote_count = read_u32_le(r)?; + for _ in 0..emote_count { + let _delay = read_u32_le(r)?; // delay FIRST here (reversed vs DETAILS) + let _emote = read_u32_le(r)?; + } + let choices = read_item_block(r)?; + let rewards = read_item_block(r)?; + let money = read_i32_le(r)?; + let quest_flags = read_u32_le(r)?; + let reward_spell = read_u32_le(r)?; + Ok(QuestOfferReward { + npc, + quest_id, + title, + offer_text, + auto_finish, + choices, + rewards, + money, + quest_flags, + reward_spell, + }) +} + +/// Read `SMSG_QUESTGIVER_REQUEST_ITEMS` (vmangos `Quest.cpp:341-391`): `u64 npcGuid, u32 questId, +/// cstr title, cstr requestText, u32 emoteDelay(0), u32 emote, u32 closeOnCancel, u32 requiredMoney, +/// u32 reqCount` then that many `{u32 itemId, u32 count, u32 displayId}` triples, then four `u32` +/// completion flags (`0x02, isComplete?0x03:0x00, 0x04, 0x08`). `is_complete` comes from the second +/// flag word — the only completability signal on this wire. +pub(in crate::messages) fn read_questgiver_request_items( + r: &mut &[u8], +) -> io::Result { + let npc = read_u64_le(r)?; + let quest_id = read_u32_le(r)?; + let title = read_cstring(r)?; + let request_text = read_cstring(r)?; + let _emote_delay = read_u32_le(r)?; // always 0 + let emote = read_u32_le(r)?; + let close_on_cancel = read_u32_le(r)?; + let required_money = read_u32_le(r)?; + let required_items = read_item_block(r)?; + let _flag0 = read_u32_le(r)?; // 0x02 + let flag_complete = read_u32_le(r)?; // 0x03 complete / 0x00 not + let _flag2 = read_u32_le(r)?; // 0x04 + let _flag3 = read_u32_le(r)?; // 0x08 + Ok(QuestRequestItems { + npc, + quest_id, + title, + request_text, + emote, + close_on_cancel, + required_money, + required_items, + is_complete: flag_complete != 0, + }) +} + +/// Read `SMSG_QUESTGIVER_QUEST_COMPLETE` (vmangos `Quest.cpp:96-108`): `u32 questId, u32 unknown, +/// u32 xp, u32 money, u32 itemCount` then that many `{u32 itemId, u32 count}` pairs (no display id +/// on this wire). +pub(in crate::messages) fn read_questgiver_quest_complete( + r: &mut &[u8], +) -> io::Result { + let quest_id = read_u32_le(r)?; + let _unknown = read_u32_le(r)?; + let xp = read_u32_le(r)?; + let money = read_u32_le(r)?; + let count = read_u32_le(r)?; + let mut items = Vec::with_capacity(count as usize); + for _ in 0..count { + items.push((read_u32_le(r)?, read_u32_le(r)?)); + } + Ok(QuestComplete { + quest_id, + xp, + money, + items, + }) +} + +/// Read `SMSG_QUESTGIVER_QUEST_INVALID` (vmangos `Quest.cpp:126`): one `u32` message code. +pub(in crate::messages) fn read_questgiver_quest_invalid(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// Read `SMSG_QUESTGIVER_QUEST_FAILED` (vmangos `Quest.cpp:110`): `u32 questId, u32 reason`. +pub(in crate::messages) fn read_questgiver_quest_failed(r: &mut &[u8]) -> io::Result<(u32, u32)> { + Ok((read_u32_le(r)?, read_u32_le(r)?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Small byte-buffer builders for the hand-built parse fixtures ────────────────────────────── + fn push_u32(b: &mut Vec, v: u32) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_i32(b: &mut Vec, v: i32) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_u64(b: &mut Vec, v: u64) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_cstr(b: &mut Vec, s: &str) { + b.extend_from_slice(s.as_bytes()); + b.push(0); + } + fn push_triple(b: &mut Vec, id: u32, count: u32, disp: u32) { + push_u32(b, id); + push_u32(b, count); + push_u32(b, disp); + } + + // ── CMSG encode goldens ────────────────────────────────────────────────────────────────────── + + #[test] + fn cmsg_guid_only_bodies() { + // STATUS_QUERY and HELLO are both a lone u64 guid, little-endian. + assert_eq!( + questgiver_status_query(0x0102_0304_0506_0708), + vec![0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01] + ); + assert_eq!(questgiver_hello(0xAABB), vec![0xBB, 0xAA, 0, 0, 0, 0, 0, 0]); + } + + #[test] + fn cmsg_guid_quest_bodies() { + // QUERY/ACCEPT/COMPLETE/REQUEST_REWARD share the {u64 guid, u32 quest} body. + let mut want = Vec::new(); + push_u64(&mut want, 0x42); + push_u32(&mut want, 3); + assert_eq!(questgiver_query_quest(0x42, 3), want); + assert_eq!(questgiver_accept_quest(0x42, 3), want); + assert_eq!(questgiver_complete_quest(0x42, 3), want); + assert_eq!(questgiver_request_reward(0x42, 3), want); + } + + #[test] + fn cmsg_choose_reward_body() { + // {u64 guid, u32 quest, u32 reward-index}. + let mut want = Vec::new(); + push_u64(&mut want, 0x99); + push_u32(&mut want, 40); + push_u32(&mut want, 2); + assert_eq!(questgiver_choose_reward(0x99, 40, 2), want); + } + + // ── SMSG parse fixtures ────────────────────────────────────────────────────────────────────── + + #[test] + fn status_parses() { + let mut b = Vec::new(); + push_u64(&mut b, 0xF130_0000_0000_0042); + push_u32(&mut b, dialog_status::AVAILABLE); + let (npc, status) = read_questgiver_status(&mut b.as_slice()).unwrap(); + assert_eq!(npc, 0xF130_0000_0000_0042); + assert_eq!(status, 5); + } + + #[test] + fn quest_list_reads_u32_icon() { + // The list-entry `icon` is a u32 — exercise it by putting a value that would misalign the + // next field if read as a u8 (0x0000_0005). Two quests. + let mut b = Vec::new(); + push_u64(&mut b, 0xABCD); + push_cstr(&mut b, "Greetings, hero."); + push_u32(&mut b, 0); // emoteDelay + push_u32(&mut b, 1); // emote + b.push(2); // count (u8) + push_u32(&mut b, 100); // questId + push_u32(&mut b, dialog_status::AVAILABLE); // icon (u32!) + push_u32(&mut b, 3); // level + push_cstr(&mut b, "A Threat Within"); + push_u32(&mut b, 200); + push_u32(&mut b, dialog_status::REWARD_REP); + push_u32(&mut b, 5); + push_cstr(&mut b, "Report to Goldshire"); + let list = read_questgiver_quest_list(&mut b.as_slice()).unwrap(); + assert_eq!(list.npc, 0xABCD); + assert_eq!(list.greeting, "Greetings, hero."); + assert_eq!(list.quests.len(), 2); + assert_eq!(list.quests[0].icon, 5); + assert_eq!(list.quests[0].level, 3); + assert_eq!(list.quests[0].title, "A Threat Within"); + assert_eq!(list.quests[1].quest_id, 200); + assert_eq!(list.quests[1].icon, 4); + assert_eq!(list.quests[1].title, "Report to Goldshire"); + } + + #[test] + fn details_rewards_block_is_uniform() { + // A quest with one choice + one fixed reward + money. The count-prefixed reader is the same + // shape the HIDDEN_REWARDS fork's three zeros parse as (choice 0, reward 0, money 0). + let mut b = Vec::new(); + push_u64(&mut b, 0x42); + push_u32(&mut b, 100); // questId + push_cstr(&mut b, "A Threat Within"); + push_cstr(&mut b, "Kill the kobolds."); + push_cstr(&mut b, "Slay 10 kobolds."); + push_u32(&mut b, 1); // autoFinish + push_u32(&mut b, 1); // choiceCount + push_triple(&mut b, 2000, 1, 555); // choice item + push_u32(&mut b, 1); // rewCount + push_triple(&mut b, 3000, 2, 777); // fixed reward + push_i32(&mut b, 1234); // money + push_u32(&mut b, 0); // rewSpell + push_u32(&mut b, QUEST_EMOTE_COUNT); // emoteCount (always 4) + for i in 0..QUEST_EMOTE_COUNT { + push_u32(&mut b, 10 + i); // emote + push_u32(&mut b, 100 + i); // delay + } + let d = read_questgiver_quest_details(&mut b.as_slice()).unwrap(); + assert_eq!(d.quest_id, 100); + assert_eq!(d.title, "A Threat Within"); + assert_eq!(d.details, "Kill the kobolds."); + assert_eq!(d.objectives, "Slay 10 kobolds."); + assert_eq!( + d.choices, + vec![QuestRewardItem { + item_id: 2000, + count: 1, + display_id: 555 + }] + ); + assert_eq!( + d.rewards, + vec![QuestRewardItem { + item_id: 3000, + count: 2, + display_id: 777 + }] + ); + assert_eq!(d.money, 1234); + } + + #[test] + fn details_hidden_rewards_fork_parses_as_empty() { + // The HIDDEN_REWARDS branch writes exactly three u32 zeros where the blocks + money go. + let mut b = Vec::new(); + push_u64(&mut b, 0x42); + push_u32(&mut b, 100); + push_cstr(&mut b, "T"); + push_cstr(&mut b, "D"); + push_cstr(&mut b, "O"); + push_u32(&mut b, 0); // autoFinish + push_u32(&mut b, 0); // (hidden) choiceCount zero + push_u32(&mut b, 0); // (hidden) rewCount zero + push_u32(&mut b, 0); // (hidden) money zero + push_u32(&mut b, 42); // rewSpell + push_u32(&mut b, QUEST_EMOTE_COUNT); + for _ in 0..QUEST_EMOTE_COUNT { + push_u32(&mut b, 0); + push_u32(&mut b, 0); + } + let d = read_questgiver_quest_details(&mut b.as_slice()).unwrap(); + assert!(d.choices.is_empty()); + assert!(d.rewards.is_empty()); + assert_eq!(d.money, 0); + assert_eq!(d.reward_spell, 42); + } + + #[test] + fn offer_reward_emotes_are_delay_first() { + // OFFER_REWARD's emote pairs are {delay, emote} — the reverse of DETAILS. Encode a distinctive + // pair and prove the rest of the packet still aligns (choices/rewards/money/flags/spell land). + let mut b = Vec::new(); + push_u64(&mut b, 0x42); + push_u32(&mut b, 100); + push_cstr(&mut b, "Well done."); + push_cstr(&mut b, "Here is your reward."); + push_u32(&mut b, 1); // autoFinish + push_u32(&mut b, 2); // emoteCount + push_u32(&mut b, 500); // delay (FIRST) + push_u32(&mut b, 1); // emote + push_u32(&mut b, 0); // delay + push_u32(&mut b, 2); // emote + push_u32(&mut b, 0); // choiceCount + push_u32(&mut b, 1); // rewCount + push_triple(&mut b, 3000, 1, 888); + push_i32(&mut b, 500); // money + push_u32(&mut b, 0x200); // questFlags (HIDDEN_REWARDS bit, just a value here) + push_u32(&mut b, 7); // rewSpell + let o = read_questgiver_offer_reward(&mut b.as_slice()).unwrap(); + assert_eq!(o.quest_id, 100); + assert_eq!(o.offer_text, "Here is your reward."); + assert!(o.choices.is_empty()); + assert_eq!( + o.rewards, + vec![QuestRewardItem { + item_id: 3000, + count: 1, + display_id: 888 + }] + ); + assert_eq!(o.money, 500); + assert_eq!(o.quest_flags, 0x200); + assert_eq!(o.reward_spell, 7); + } + + #[test] + fn request_items_reads_completability() { + let mut b = Vec::new(); + push_u64(&mut b, 0x42); + push_u32(&mut b, 100); + push_cstr(&mut b, "A Threat Within"); + push_cstr(&mut b, "Bring me the tusks."); + push_u32(&mut b, 0); // emoteDelay + push_u32(&mut b, 1); // emote + push_u32(&mut b, 1); // closeOnCancel + push_u32(&mut b, 0); // requiredMoney + push_u32(&mut b, 1); // reqCount + push_triple(&mut b, 2001, 8, 111); // required item + push_u32(&mut b, 0x02); + push_u32(&mut b, 0x03); // isComplete + push_u32(&mut b, 0x04); + push_u32(&mut b, 0x08); + let ri = read_questgiver_request_items(&mut b.as_slice()).unwrap(); + assert_eq!(ri.title, "A Threat Within"); + assert_eq!(ri.request_text, "Bring me the tusks."); + assert_eq!( + ri.required_items, + vec![QuestRewardItem { + item_id: 2001, + count: 8, + display_id: 111 + }] + ); + assert!(ri.is_complete); + + // The not-complete fork: second flag word 0x00. + let mut b2 = b.clone(); + let flag_off = b2.len() - 16 + 4; // second of the four trailing u32 flags + b2[flag_off..flag_off + 4].copy_from_slice(&0u32.to_le_bytes()); + let ri2 = read_questgiver_request_items(&mut b2.as_slice()).unwrap(); + assert!(!ri2.is_complete); + } + + #[test] + fn quest_complete_reads_reward_summary() { + let mut b = Vec::new(); + push_u32(&mut b, 100); // questId + push_u32(&mut b, 0); // unknown + push_u32(&mut b, 450); // xp + push_u32(&mut b, 1234); // money + push_u32(&mut b, 2); // itemCount + push_u32(&mut b, 3000); + push_u32(&mut b, 1); + push_u32(&mut b, 3001); + push_u32(&mut b, 5); + let c = read_questgiver_quest_complete(&mut b.as_slice()).unwrap(); + assert_eq!(c.quest_id, 100); + assert_eq!(c.xp, 450); + assert_eq!(c.money, 1234); + assert_eq!(c.items, vec![(3000, 1), (3001, 5)]); + } + + #[test] + fn quest_invalid_and_failed_parse() { + // QUESTGIVER_QUEST_INVALID: one u32 message code. + let mut b = Vec::new(); + push_u32(&mut b, 7); + assert_eq!(read_questgiver_quest_invalid(&mut b.as_slice()).unwrap(), 7); + + // QUESTGIVER_QUEST_FAILED: {questId, reason}. + let mut b2 = Vec::new(); + push_u32(&mut b2, 100); + push_u32(&mut b2, 4); + assert_eq!( + read_questgiver_quest_failed(&mut b2.as_slice()).unwrap(), + (100, 4) + ); + } +} diff --git a/crates/benilla-protocol/src/messages/quest/log.rs b/crates/benilla-protocol/src/messages/quest/log.rs new file mode 100644 index 00000000..008e6c4e --- /dev/null +++ b/crates/benilla-protocol/src/messages/quest/log.rs @@ -0,0 +1,505 @@ +//! `CMSG_QUEST_QUERY`/`SMSG_QUEST_QUERY_RESPONSE` (92/93) plus the quest-log book-keeping wire: +//! `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/`SMSG_QUESTLOG_FULL` (403-405) and the +//! `SMSG_QUESTUPDATE_*` progress pushes (406-410) — all VERIFIED vmangos `Opcodes_1_12_1.h`. CMSG +//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's +//! `AppendBodyTo` writers (line citations inline). +//! +//! Wire traps this parser is written against the *byte stream* to survive (never the C++ control +//! flow): +//! - **The cstr order is `title, objectives, details, endText`** (`Quest.cpp:393-528`, the +//! `>1_9_4` branch, live on 5875) — objectives lands *before* details, reversed from the giver +//! [`super::giver::QuestDetails`] panel's `title, details, objectives`. +//! - **The reward/choice/objective arrays are fixed-count, not count-prefixed** — always 4/6/4 +//! slots regardless of how many the quest uses (the tail is zero-filled), and unlike the +//! giver-panel triples they carry **no display ids**. +//! - **The 4 objective texts trail the whole packet** — after all 4 objective quads, not +//! interleaved with them. +//! - **A gameobject objective id is `(−id)|0x80000000`** (`Quest.cpp:512-516`) — the same raw +//! encoding `SMSG_QUESTUPDATE_ADD_KILL`'s `entry` field carries for a GO kill/use event +//! (`Player.cpp:14634-14637`); both are kept undecoded on the parsed structs. + +use std::io; + +use crate::wire::{read_cstring, read_f32_le, read_i32_le, read_u32_le, read_u64_le}; + +/// `QUEST_OBJECTIVES_COUNT` (= `QUEST_ITEM_OBJECTIVES_COUNT`, vmangos `QuestDef.h:34-43`) — the +/// fixed objective-quad count `SMSG_QUEST_QUERY_RESPONSE` always writes, regardless of how many +/// objectives the quest actually has (the unused tail is zero-filled). +pub const QUEST_OBJECTIVES_COUNT: u32 = 4; +/// `QUEST_REWARDS_COUNT` (vmangos `QuestDef.h:34-43`) — the fixed count of `{itemId, count}` reward +/// slots on `SMSG_QUEST_QUERY_RESPONSE`. +pub const QUEST_REWARDS_COUNT: u32 = 4; +/// `QUEST_REWARD_CHOICES_COUNT` (vmangos `QuestDef.h:34-43`) — the fixed count of `{itemId, count}` +/// reward-choice slots on `SMSG_QUEST_QUERY_RESPONSE`. +pub const QUEST_REWARD_CHOICES_COUNT: u32 = 6; + +/// One objective slot inside [`QuestTemplate`] (vmangos `Quest.cpp:512-522`): the +/// `{reqCreatureOrGOId, reqCreatureOrGOCount, reqItemId, reqItemCount}` quad plus its trailing +/// `objectiveText` cstr (read separately, at the very end of the packet — see +/// [`read_quest_query_response`]). `creature_or_go` is kept **raw**: a positive value is a creature +/// entry, a gameobject objective is written as `(−id)|0x80000000` (`Quest.cpp:512-516`) — the same +/// encoding `SMSG_QUESTUPDATE_ADD_KILL`'s `entry` field carries (`Player.cpp:14634-14637`). +/// Deciding which case applies (and un-negating the GO id) is a UI concern; the wire doesn't +/// disambiguate beyond the sign/top bit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QuestObjective { + pub creature_or_go: u32, + pub required_count: u32, + pub item_id: u32, + pub item_count: u32, + pub text: String, +} + +/// `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch — live on 5875): +/// the full quest template, answering `CMSG_QUEST_QUERY` and feeding the quest log's detail view. +/// Unlike the giver panels this carries **fixed-count** reward/choice/objective arrays (no count +/// prefix, no display ids) and a **different cstr order** — `title, objectives, details, endText`, +/// objectives *before* details, reversed from [`super::giver::QuestDetails`]. `money`/ +/// `money_max_level` mirror the giver DETAILS panel's `rewOrReqMoney`/`rewMoneyMaxLevel` split; a +/// HIDDEN_REWARDS quest zeroes the reward/choice/money fields but keeps the same shape — this +/// parser is flag-blind, same as [`super::giver::read_questgiver_quest_details`]. +#[derive(Debug, Clone, PartialEq)] +pub struct QuestTemplate { + pub quest_id: u32, + pub method: u32, + pub level: u32, + pub zone_or_sort: i32, + pub quest_type: u32, + pub rep_objective_faction: u32, + pub rep_objective_value: u32, + pub next_quest_in_chain: u32, + pub money: i32, + pub money_max_level: u32, + pub reward_spell: u32, + pub src_item_id: u32, + pub flags: u32, + pub rewards: [(u32, u32); QUEST_REWARDS_COUNT as usize], + pub choices: [(u32, u32); QUEST_REWARD_CHOICES_COUNT as usize], + pub point_map_id: u32, + pub point_x: f32, + pub point_y: f32, + pub point_opt: u32, + pub title: String, + pub objectives_text: String, + pub details: String, + pub end_text: String, + pub objectives: [QuestObjective; QUEST_OBJECTIVES_COUNT as usize], +} + +// ── CMSG encoders (readers at vmangos `Quest.cpp:6-58`) ─────────────────────────────────────────── + +/// Body of `CMSG_QUEST_QUERY` (vmangos `Quest.cpp:6`, `Quest.h:17`): one `u32` quest entry. +/// Answered by `SMSG_QUEST_QUERY_RESPONSE` ([`QuestTemplate`]) — the quest-log detail view's +/// opener, distinct from the giver-panel `CMSG_QUESTGIVER_QUERY_QUEST` (which needs an NPC guid). +pub fn quest_query(quest_id: u32) -> Vec { + quest_id.to_le_bytes().to_vec() +} + +/// Body of `CMSG_QUESTLOG_REMOVE_QUEST` (vmangos `Quest.cpp:58`, `Quest.h:105`): one `u8` log +/// slot. Abandons the quest in that slot; the server clears the `PLAYER_QUEST_LOG` fields +/// directly — there is no ack SMSG, the field update *is* the confirmation. +pub fn questlog_remove_quest(slot: u8) -> Vec { + vec![slot] +} + +/// Body of `CMSG_QUESTLOG_SWAP_QUEST` (vmangos `Quest.cpp:52`, `Quest.h:95-96`): two `u8` log +/// slots to swap. +pub fn questlog_swap_quest(slot1: u8, slot2: u8) -> Vec { + vec![slot1, slot2] +} + +// ── SMSG parsers ────────────────────────────────────────────────────────────────────────────────── + +/// Read `SMSG_QUEST_QUERY_RESPONSE` (vmangos `Quest.cpp:393-528`, the `>1_9_4` branch, live on +/// 5875) — see [`QuestTemplate`] for the field-by-field wire-trap notes (cstr order, fixed-count +/// arrays, the trailing objective texts, the raw GO objective encoding). +pub(in crate::messages) fn read_quest_query_response(r: &mut &[u8]) -> io::Result { + let quest_id = read_u32_le(r)?; + let method = read_u32_le(r)?; + let level = read_u32_le(r)?; + let zone_or_sort = read_i32_le(r)?; + let quest_type = read_u32_le(r)?; + let rep_objective_faction = read_u32_le(r)?; + let rep_objective_value = read_u32_le(r)?; + let _opposite_rep_faction = read_u32_le(r)?; // requiredOppositeRepFaction — always 0 + let _opposite_rep_value = read_u32_le(r)?; // requiredOppositeRepValue — always 0 + let next_quest_in_chain = read_u32_le(r)?; + let money = read_i32_le(r)?; + let money_max_level = read_u32_le(r)?; + let reward_spell = read_u32_le(r)?; + let src_item_id = read_u32_le(r)?; + let flags = read_u32_le(r)?; + + // Fixed-count reward/choice arrays — NOT count-prefixed like the giver-panel item blocks, and + // no display ids. HIDDEN_REWARDS zeroes the values but keeps the same shape. + let mut rewards = [(0u32, 0u32); QUEST_REWARDS_COUNT as usize]; + for slot in rewards.iter_mut() { + *slot = (read_u32_le(r)?, read_u32_le(r)?); + } + let mut choices = [(0u32, 0u32); QUEST_REWARD_CHOICES_COUNT as usize]; + for slot in choices.iter_mut() { + *slot = (read_u32_le(r)?, read_u32_le(r)?); + } + + let point_map_id = read_u32_le(r)?; + let point_x = read_f32_le(r)?; + let point_y = read_f32_le(r)?; + let point_opt = read_u32_le(r)?; + + let title = read_cstring(r)?; + // The trap: objectives overview text lands BEFORE details here — reversed vs DETAILS + // (`QuestDetails`, whose order is title/details/objectives). + let objectives_text = read_cstring(r)?; + let details = read_cstring(r)?; + let end_text = read_cstring(r)?; + + let mut raw_objectives = [(0u32, 0u32, 0u32, 0u32); QUEST_OBJECTIVES_COUNT as usize]; + for slot in raw_objectives.iter_mut() { + *slot = ( + read_u32_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + ); + } + // The 4 objective texts trail the *whole* packet — after every quad, not interleaved. + let mut objective_texts = Vec::with_capacity(QUEST_OBJECTIVES_COUNT as usize); + for _ in 0..QUEST_OBJECTIVES_COUNT { + objective_texts.push(read_cstring(r)?); + } + let objectives: Vec = raw_objectives + .into_iter() + .zip(objective_texts) + .map( + |((creature_or_go, required_count, item_id, item_count), text)| QuestObjective { + creature_or_go, + required_count, + item_id, + item_count, + text, + }, + ) + .collect(); + let objectives: [QuestObjective; QUEST_OBJECTIVES_COUNT as usize] = objectives + .try_into() + .expect("read_quest_query_response always reads QUEST_OBJECTIVES_COUNT quads/texts"); + + Ok(QuestTemplate { + quest_id, + method, + level, + zone_or_sort, + quest_type, + rep_objective_faction, + rep_objective_value, + next_quest_in_chain, + money, + money_max_level, + reward_spell, + src_item_id, + flags, + rewards, + choices, + point_map_id, + point_x, + point_y, + point_opt, + title, + objectives_text, + details, + end_text, + objectives, + }) +} + +/// Read `SMSG_QUESTUPDATE_ADD_KILL` (vmangos `Quest.cpp:144`): `u32 questId, u32 entry, u32 +/// count, u32 required, u64 guid`. `entry` for a gameobject objective is +/// `(−ReqCreatureOrGOId)|0x80000000` (`Player.cpp:14634-14637`, the same raw encoding as +/// [`QuestObjective::creature_or_go`]); `count` is asserted `< 64` server-side (a 6-bit +/// slot-counter store) and mirrors — doesn't replace — the durable `PLAYER_QUEST_LOG` counter +/// field: this packet is the toast line, the field update is the durable state. +pub(in crate::messages) fn read_quest_update_add_kill( + r: &mut &[u8], +) -> io::Result<(u32, u32, u32, u32, u64)> { + Ok(( + read_u32_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + read_u64_le(r)?, + )) +} + +/// Read `SMSG_QUESTUPDATE_ADD_ITEM` (vmangos `Quest.cpp:138`): `u32 itemId, u32 count`. +pub(in crate::messages) fn read_quest_update_add_item(r: &mut &[u8]) -> io::Result<(u32, u32)> { + Ok((read_u32_le(r)?, read_u32_le(r)?)) +} + +/// Read `SMSG_QUESTUPDATE_COMPLETE` (vmangos `Quest.cpp:91`): one `u32` quest id. Fires once +/// every objective is complete (`Player.cpp` ~14494); the log slot's state byte gets +/// `QUEST_STATE_COMPLETE`. +pub(in crate::messages) fn read_quest_update_complete(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// Read `SMSG_QUESTUPDATE_FAILED` (vmangos `Quest.cpp:116`): one `u32` quest id. +pub(in crate::messages) fn read_quest_update_failed(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +/// Read `SMSG_QUESTUPDATE_FAILEDTIMER` (vmangos `Quest.cpp:121`): one `u32` quest id — a timed +/// quest's clock ran out. +pub(in crate::messages) fn read_quest_update_failedtimer(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Small byte-buffer builders for the hand-built parse fixtures ────────────────────────────── + fn push_u32(b: &mut Vec, v: u32) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_i32(b: &mut Vec, v: i32) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_u64(b: &mut Vec, v: u64) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_f32(b: &mut Vec, v: f32) { + b.extend_from_slice(&v.to_le_bytes()); + } + fn push_cstr(b: &mut Vec, s: &str) { + b.extend_from_slice(s.as_bytes()); + b.push(0); + } + + // ── CMSG encode goldens ────────────────────────────────────────────────────────────────────── + + #[test] + fn cmsg_quest_query_body() { + assert_eq!(quest_query(1234), vec![0xD2, 0x04, 0x00, 0x00]); + } + + #[test] + fn cmsg_questlog_remove_quest_body() { + assert_eq!(questlog_remove_quest(5), vec![5]); + } + + #[test] + fn cmsg_questlog_swap_quest_body() { + assert_eq!(questlog_swap_quest(3, 7), vec![3, 7]); + } + + // ── SMSG parse fixtures ────────────────────────────────────────────────────────────────────── + + #[test] + fn quest_query_response_parses_all_wire_traps() { + let mut b = Vec::new(); + push_u32(&mut b, 100); // questId + push_u32(&mut b, 1); // method + push_u32(&mut b, 12); // level + push_i32(&mut b, -3); // zoneOrSort + push_u32(&mut b, 2); // type + push_u32(&mut b, 169); // repObjectiveFaction + push_u32(&mut b, 500); // repObjectiveValue + push_u32(&mut b, 0); // requiredOppositeRepFaction (always 0, skipped) + push_u32(&mut b, 0); // requiredOppositeRepValue (always 0, skipped) + push_u32(&mut b, 777); // nextQuestInChain + push_i32(&mut b, 5000); // rewOrReqMoney + push_u32(&mut b, 10); // rewMoneyMaxLevel + push_u32(&mut b, 4321); // rewSpell + push_u32(&mut b, 900); // srcItemId + push_u32(&mut b, 0x10); // questFlags + + // rewards: 4 fixed slots, only 2 non-zero. + push_u32(&mut b, 3000); + push_u32(&mut b, 1); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 3005); + push_u32(&mut b, 3); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + // choices: 6 slots, only 1 non-zero. + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 4000); + push_u32(&mut b, 2); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + + push_u32(&mut b, 1); // pointMapId + push_f32(&mut b, 123.5); // pointX + push_f32(&mut b, -45.25); // pointY + push_u32(&mut b, 2); // pointOpt + + // cstr order: title, objectives, details, endText. + push_cstr(&mut b, "A Threat Within"); + push_cstr(&mut b, "Slay 10 kobolds."); + push_cstr(&mut b, "Kill the kobolds threatening Goldshire."); + push_cstr(&mut b, "You have done well, adventurer."); + + // Objective quads: [creature, gameobject, item, unused]. + let go_encoded = ((-57i32) as u32) | 0x8000_0000; + push_u32(&mut b, 100); // objective0: creatureOrGO (creature entry) + push_u32(&mut b, 10); // requiredCount + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, go_encoded); // objective1: creatureOrGO (gameobject, id 57) + push_u32(&mut b, 1); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); // objective2: item objective + push_u32(&mut b, 0); + push_u32(&mut b, 2000); + push_u32(&mut b, 5); + push_u32(&mut b, 0); // objective3: unused slot + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + // Objective texts trail the whole packet, after every quad. + push_cstr(&mut b, "Kill 10 kobolds"); + push_cstr(&mut b, "Destroy the barricade"); + push_cstr(&mut b, "Collect 5 kobold ears"); + push_cstr(&mut b, ""); + + let q = read_quest_query_response(&mut b.as_slice()).unwrap(); + assert_eq!(q.quest_id, 100); + assert_eq!(q.method, 1); + assert_eq!(q.level, 12); + assert_eq!(q.zone_or_sort, -3); + assert_eq!(q.quest_type, 2); + assert_eq!(q.rep_objective_faction, 169); + assert_eq!(q.rep_objective_value, 500); + assert_eq!(q.next_quest_in_chain, 777); + assert_eq!(q.money, 5000); + assert_eq!(q.money_max_level, 10); + assert_eq!(q.reward_spell, 4321); + assert_eq!(q.src_item_id, 900); + assert_eq!(q.flags, 0x10); + assert_eq!(q.rewards, [(3000, 1), (0, 0), (3005, 3), (0, 0)]); + assert_eq!( + q.choices, + [(0, 0), (4000, 2), (0, 0), (0, 0), (0, 0), (0, 0)] + ); + assert_eq!(q.point_map_id, 1); + assert_eq!(q.point_x, 123.5); + assert_eq!(q.point_y, -45.25); + assert_eq!(q.point_opt, 2); + assert_eq!(q.title, "A Threat Within"); + // The trap: objectives-overview text lands BEFORE details. + assert_eq!(q.objectives_text, "Slay 10 kobolds."); + assert_eq!(q.details, "Kill the kobolds threatening Goldshire."); + assert_eq!(q.end_text, "You have done well, adventurer."); + assert_eq!(q.objectives[0].creature_or_go, 100); + assert_eq!(q.objectives[0].required_count, 10); + assert_eq!(q.objectives[0].text, "Kill 10 kobolds"); + assert_eq!(q.objectives[1].creature_or_go, go_encoded); + assert_eq!(q.objectives[1].required_count, 1); + assert_eq!(q.objectives[1].text, "Destroy the barricade"); + assert_eq!(q.objectives[2].item_id, 2000); + assert_eq!(q.objectives[2].item_count, 5); + assert_eq!(q.objectives[2].text, "Collect 5 kobold ears"); + assert_eq!(q.objectives[3].text, ""); + } + + #[test] + fn quest_query_response_hidden_rewards_shape_is_blind() { + // A HIDDEN_REWARDS quest zeroes every reward/choice slot and the money field, but the + // fixed-count shape is unchanged — this parser can't see (and doesn't need) the flag. + let mut b = Vec::new(); + push_u32(&mut b, 200); // questId + push_u32(&mut b, 0); // method + push_u32(&mut b, 5); // level + push_i32(&mut b, 10); // zoneOrSort + push_u32(&mut b, 1); // type + push_u32(&mut b, 0); // repObjectiveFaction + push_u32(&mut b, 0); // repObjectiveValue + push_u32(&mut b, 0); // requiredOppositeRepFaction + push_u32(&mut b, 0); // requiredOppositeRepValue + push_u32(&mut b, 0); // nextQuestInChain + push_i32(&mut b, 0); // money (hidden rewards ⇒ 0) + push_u32(&mut b, 0); // rewMoneyMaxLevel + push_u32(&mut b, 99); // rewSpell + push_u32(&mut b, 0); // srcItemId + push_u32(&mut b, 0x200); // questFlags (a HIDDEN_REWARDS-shaped value; parser is flag-blind) + for _ in 0..QUEST_REWARDS_COUNT { + push_u32(&mut b, 0); + push_u32(&mut b, 0); + } + for _ in 0..QUEST_REWARD_CHOICES_COUNT { + push_u32(&mut b, 0); + push_u32(&mut b, 0); + } + push_u32(&mut b, 0); // pointMapId + push_f32(&mut b, 0.0); + push_f32(&mut b, 0.0); + push_u32(&mut b, 0); + push_cstr(&mut b, "T"); + push_cstr(&mut b, "O"); // objectives + push_cstr(&mut b, "D"); // details + push_cstr(&mut b, "E"); // endText + for _ in 0..QUEST_OBJECTIVES_COUNT { + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + push_u32(&mut b, 0); + } + for _ in 0..QUEST_OBJECTIVES_COUNT { + push_cstr(&mut b, ""); + } + + let q = read_quest_query_response(&mut b.as_slice()).unwrap(); + assert_eq!(q.rewards, [(0, 0); QUEST_REWARDS_COUNT as usize]); + assert_eq!(q.choices, [(0, 0); QUEST_REWARD_CHOICES_COUNT as usize]); + assert_eq!(q.money, 0); + assert_eq!(q.reward_spell, 99); + } + + #[test] + fn quest_update_add_kill_reads_guid() { + let mut b = Vec::new(); + push_u32(&mut b, 100); // questId + push_u32(&mut b, 200); // entry + push_u32(&mut b, 3); // count + push_u32(&mut b, 10); // required + push_u64(&mut b, 0xF130_0000_0000_0777); // guid + let (quest_id, entry, count, required, guid) = + read_quest_update_add_kill(&mut b.as_slice()).unwrap(); + assert_eq!(quest_id, 100); + assert_eq!(entry, 200); + assert_eq!(count, 3); + assert_eq!(required, 10); + assert_eq!(guid, 0xF130_0000_0000_0777); + } + + #[test] + fn quest_update_simple_smsgs_parse() { + // COMPLETE/FAILED/FAILEDTIMER are all a lone u32 quest id. + let mut b = Vec::new(); + push_u32(&mut b, 42); + assert_eq!(read_quest_update_complete(&mut b.as_slice()).unwrap(), 42); + assert_eq!(read_quest_update_failed(&mut b.as_slice()).unwrap(), 42); + assert_eq!( + read_quest_update_failedtimer(&mut b.as_slice()).unwrap(), + 42 + ); + + // ADD_ITEM: {itemId, count}. + let mut b2 = Vec::new(); + push_u32(&mut b2, 3000); + push_u32(&mut b2, 5); + assert_eq!( + read_quest_update_add_item(&mut b2.as_slice()).unwrap(), + (3000, 5) + ); + } +} diff --git a/crates/benilla-protocol/src/messages/quest/mod.rs b/crates/benilla-protocol/src/messages/quest/mod.rs new file mode 100644 index 00000000..203d9b58 --- /dev/null +++ b/crates/benilla-protocol/src/messages/quest/mod.rs @@ -0,0 +1,33 @@ +//! Questgiver + quest-log messages, split along the two decision-scoped slices: [`giver`] — the +//! "accept/turn-in a quest at an NPC" family (opcodes 386-402, vmangos `Opcodes_1_12_1.h`, +//! VERIFIED; decision 0088) — and [`log`] — the quest-log wire: `CMSG_QUEST_QUERY`/ +//! `SMSG_QUEST_QUERY_RESPONSE` (92/93), `CMSG_QUESTLOG_SWAP_QUEST`/`CMSG_QUESTLOG_REMOVE_QUEST`/ +//! `SMSG_QUESTLOG_FULL` (403-405), and the `SMSG_QUESTUPDATE_*` progress pushes (406-410). CMSG +//! bodies from vmangos `Server/Packets/Quest.cpp:6-78`; SMSG layouts from the same file's +//! `AppendBodyTo` writers (line citations inline in each submodule). Out of scope for both slices: +//! `CMSG_QUEST_CONFIRM_ACCEPT` / `SMSG_QUEST_CONFIRM_ACCEPT` / `CMSG_PUSHQUESTTOPARTY` / +//! `MSG_QUEST_PUSH_RESULT` — the party quest-share flow, a separate solo slice. + +mod giver; +mod log; + +pub use giver::{ + dialog_status, questgiver_accept_quest, questgiver_choose_reward, questgiver_complete_quest, + questgiver_hello, questgiver_query_quest, questgiver_request_reward, questgiver_status_query, + QuestComplete, QuestDetails, QuestGiverList, QuestListEntry, QuestOfferReward, + QuestRequestItems, QuestRequiredItem, QuestRewardItem, QUEST_EMOTE_COUNT, +}; +pub use log::{ + quest_query, questlog_remove_quest, questlog_swap_quest, QuestObjective, QuestTemplate, + QUEST_OBJECTIVES_COUNT, QUEST_REWARDS_COUNT, QUEST_REWARD_CHOICES_COUNT, +}; + +pub(super) use giver::{ + read_questgiver_offer_reward, read_questgiver_quest_complete, read_questgiver_quest_details, + read_questgiver_quest_failed, read_questgiver_quest_invalid, read_questgiver_quest_list, + read_questgiver_request_items, read_questgiver_status, +}; +pub(super) use log::{ + read_quest_query_response, read_quest_update_add_item, read_quest_update_add_kill, + read_quest_update_complete, read_quest_update_failed, read_quest_update_failedtimer, +}; diff --git a/crates/benilla-protocol/src/messages/roster.rs b/crates/benilla-protocol/src/messages/roster.rs new file mode 100644 index 00000000..cbf7c3c2 --- /dev/null +++ b/crates/benilla-protocol/src/messages/roster.rs @@ -0,0 +1,140 @@ +//! The character-select roster family: the `SMSG_CHAR_ENUM` [`Character`] entry (vmangos +//! `Player::BuildEnumData` field order, byte-goldened in `tests/char_enum.rs`) + the +//! create/delete result codes and the race/class/gender ids the create path sends. + +use std::io::{self, Read}; + +use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8, Vector3d}; + +/// `WorldResult::CharCreateSuccess` / `CharCreateNameInUse` (`SMSG_CHAR_CREATE`). +pub const CHAR_CREATE_SUCCESS: u8 = 0x2E; +pub const CHAR_CREATE_NAME_IN_USE: u8 = 0x31; +/// `WorldResult::CharDeleteSuccess` (`SMSG_CHAR_DELETE`; vmangos `SharedDefines.h` ResponseCodes, +/// counted from the verified `CHAR_CREATE_SUCCESS = 0x2E` anchor). +pub const CHAR_DELETE_SUCCESS: u8 = 0x39; +/// Race/Class/Gender values benilla's char-create sends. +pub const RACE_HUMAN: u8 = 0x1; +pub const CLASS_WARRIOR: u8 = 0x1; +pub const GENDER_MALE: u8 = 0x0; + +/// A character-creation request: the identity + the five appearance dials the create screen picked. +/// Consumed by the [`super::char_create`] body builder (`CMSG_CHAR_CREATE`) and carried to the +/// parked IO thread over the pick channel (decision 0423). `outfit_id` isn't here — it's always 0 on +/// the wire (the server reads and ignores it, then recomputes start gear; verified vmangos +/// `CharacterHandler.cpp:310`). The five appearance bytes are `ChrRaces`/`CharSections`-valid indices +/// the create UI derives from the DBCs, so every request the UI can build passes the server's +/// `Player::ValidateAppearance`. +#[derive(Debug, Clone)] +pub struct CharCreateReq { + pub name: String, + /// `ChrRaces.dbc` id (1 Human … 8 Troll). + pub race: u8, + /// `ChrClasses.dbc` id (1 Warrior … 11 Druid). + pub class: u8, + /// 0 male, 1 female. + pub gender: u8, + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, +} + +/// The enum entry's `flags` bits the select screen renders (vmangos `CHARACTER_FLAG_*`, +/// `Player::BuildEnumData`): the at-login helm/cloak hide toggles, the dead-as-ghost marker, and +/// the server-ordered rename. +pub const CHARACTER_FLAG_HIDE_HELM: u32 = 0x0400; +pub const CHARACTER_FLAG_HIDE_CLOAK: u32 = 0x0800; +pub const CHARACTER_FLAG_GHOST: u32 = 0x2000; +pub const CHARACTER_FLAG_RENAME: u32 = 0x4000; + +/// One visible-equipment entry of the enum record: the `ItemDisplayInfo.dbc` id (NOT an item id — +/// the server already resolved the template hop) and the item's `InventoryType`. `display_id == 0` +/// is an empty slot. 19 slots in the vanilla equipment order (head 0 … tabard 18). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CharEnumItem { + pub display_id: u32, + pub inventory_type: u8, +} + +/// A character from `SMSG_CHAR_ENUM` — the full roster entry the character-select screen renders +/// (decision 0465): identity, the five appearance bytes, zone/map/position, the display flags, and +/// the visible equipment display ids. Only the guild id, first-login byte, pet triple, and first-bag +/// pair are parsed for alignment and discarded (nothing at select renders them). +#[derive(Debug, Clone)] +pub struct Character { + pub guid: u64, + pub name: String, + /// `ChrRaces.dbc` id (1 Human … 8 Troll). + pub race: u8, + /// `ChrClasses.dbc` id (1 Warrior … 11 Druid). + pub class: u8, + /// 0 male, 1 female. + pub gender: u8, + /// The five appearance dials, `CharSections` indices (the create screen's tuple, echoed back). + pub skin: u8, + pub face: u8, + pub hair_style: u8, + pub hair_color: u8, + pub facial_hair: u8, + pub level: u8, + /// `AreaTable.dbc` id of the character's zone (the roster row's location line). + pub zone: u32, + pub map: u32, + pub position: Vector3d, + /// `CHARACTER_FLAG_*` bits (hide-helm/hide-cloak/ghost/rename are the rendered ones). + pub flags: u32, + /// The 19 visible equipment slots (vanilla order: head, neck, shoulders, shirt, chest, waist, + /// legs, feet, wrists, hands, finger×2, trinket×2, back, main hand, off hand, ranged, tabard). + pub equipment: [CharEnumItem; 19], +} + +impl Character { + pub(super) fn read(r: &mut impl Read) -> io::Result { + let guid = read_u64_le(r)?; + let name = read_cstring(r)?; + let race = read_u8(r)?; + let class = read_u8(r)?; + let gender = read_u8(r)?; + let skin = read_u8(r)?; + let face = read_u8(r)?; + let hair_style = read_u8(r)?; + let hair_color = read_u8(r)?; + let facial_hair = read_u8(r)?; + let level = read_u8(r)?; + let zone = read_u32_le(r)?; + let map = read_u32_le(r)?; + let position = Vector3d::read(r)?; + let _guild_id = read_u32_le(r)?; + let flags = read_u32_le(r)?; + let _first_login = read_u8(r)?; + let _pet_display_id = read_u32_le(r)?; + let _pet_level = read_u32_le(r)?; + let _pet_family = read_u32_le(r)?; + let mut equipment = [CharEnumItem::default(); 19]; + for slot in &mut equipment { + slot.display_id = read_u32_le(r)?; + slot.inventory_type = read_u8(r)?; + } + let _first_bag_display_id = read_u32_le(r)?; + let _first_bag_inventory_id = read_u8(r)?; + Ok(Self { + guid, + name, + race, + class, + gender, + skin, + face, + hair_style, + hair_color, + facial_hair, + level, + zone, + map, + position, + flags, + equipment, + }) + } +} diff --git a/crates/benilla-protocol/src/messages/skills.rs b/crates/benilla-protocol/src/messages/skills.rs new file mode 100644 index 00000000..a8e7beca --- /dev/null +++ b/crates/benilla-protocol/src/messages/skills.rs @@ -0,0 +1,13 @@ +//! Skill-line messages — the skills pane's one send verb, `CMSG_UNLEARN_SKILL` (0x202, vmangos +//! `Opcodes.cpp`'s `DEFINE_HANDLER` → `HandleUnlearnSkillOpcode`). Body from vmangos +//! `Server/Packets/Skill.h` (`UnlearnSkill { uint32 skillId }`); semantics from +//! `Handlers/SkillHandler.cpp`: the server gates on the requested line's `SkillRaceClassInfo.flags +//! & SKILL_FLAG_UNLEARNABLE (0x20)` — a request without it is DROPPED and anticheat-flagged, so +//! the client must only ever offer the unlearn where that bit is set (the skills pane's +//! `abandonable` predicate, `benilla-formats::skill_lines`) — and answers `SetSkill(id, 0, 0)`: +//! the removal comes back as a `PLAYER_SKILL_INFO` field update, never an ack packet. + +/// Body of `CMSG_UNLEARN_SKILL`: one `u32` skill line id. +pub fn unlearn_skill(skill_id: u32) -> Vec { + skill_id.to_le_bytes().to_vec() +} diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs new file mode 100644 index 00000000..bc059807 --- /dev/null +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -0,0 +1,1061 @@ +//! Spell-book, action-bar, cast, and melee-attack messages — the wire under decision 0068's +//! slice-1 action bar. Every layout here is VERIFIED against vmangos source (cited per item); +//! the opcodes are in [`super::opcode`] (verified `Opcodes_1_12_1.h`). + +use std::io::{self, Read}; + +use crate::wire::{ + read_cstring, read_f32_le, read_i32_le, read_packed_guid, read_u16_le, read_u32_le, + read_u64_le, read_u8, Vector3d, +}; + +/// Action-button kind byte (bits 24–31 of the packed slot word — vmangos `Player.h` +/// `ActionButtonType`): a spell id, a macro id, or an item id in the low 24 bits. +pub const ACTION_KIND_SPELL: u8 = 0x00; +pub const ACTION_KIND_MACRO: u8 = 0x40; +pub const ACTION_KIND_ITEM: u8 = 0x80; + +/// One *occupied* action-bar slot from `SMSG_ACTION_BUTTONS`. The wire is 120 packed `u32`s +/// (`MAX_ACTION_BUTTONS`, vmangos `MasterPlayer::SendInitialActionButtons`) — `action` in bits +/// 0–23, `kind` in bits 24–31 (`ACTION_BUTTON_ACTION/TYPE`, `Player.h`); a zero word is an empty +/// slot and is not surfaced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActionButton { + /// The bar slot index (0..119). Slots 0–11 are the main bar's buttons 1–12. + pub slot: u8, + /// The spell/macro/item id (bits 0–23). + pub action: u32, + /// The kind byte (bits 24–31): [`ACTION_KIND_SPELL`]/[`ACTION_KIND_MACRO`]/[`ACTION_KIND_ITEM`] + /// (0x01 "click?" exists in the enum, carried raw if it ever appears). + pub kind: u8, +} + +/// One active cooldown from `SMSG_INITIAL_SPELLS`' second list (vmangos `SendInitialSpells`): +/// `u16 spell, u16 castItem, u16 category, u32 spellCdMs, u32 categoryCdMs`. A *permanent* +/// cooldown (a one-per-fight ability the server re-arms) is `spell_cd_ms == 1` with the category +/// word's top bit set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpellCooldown { + pub spell_id: u16, + pub item_id: u16, + pub category: u16, + pub spell_cd_ms: u32, + pub category_cd_ms: u32, +} + +/// `SMSG_CAST_RESULT`'s verdict: `u32 spellId, u8 status` — status `0` (`SPELL_RESULT_STATUS_OKAY`) +/// ends the packet, status `2` (`SPELL_RESULT_STATUS_FAIL`) appends a `u8` failure reason +/// (vmangos `CastResult::AppendBodyTo`; some reasons append extra arg words we skip). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CastOutcome { + Ok, + Failed { reason: u8 }, +} + +/// Read `SMSG_INITIAL_SPELLS` (vmangos `Player::SendInitialSpells`): `u8 0; u16 n; n×(u16 spellId, +/// u16 0); u16 m; m×`[`SpellCooldown`]. The per-spell second word is "not slot id" (vmangos's own +/// note) and always 0 — skipped. +pub(super) fn read_initial_spells(r: &mut impl Read) -> io::Result<(Vec, Vec)> { + let _ = read_u8(r)?; + let n = read_u16_le(r)?; + let mut spells = Vec::with_capacity(n as usize); + for _ in 0..n { + spells.push(read_u16_le(r)?); + let _ = read_u16_le(r)?; + } + let m = read_u16_le(r)?; + let mut cooldowns = Vec::with_capacity(m as usize); + for _ in 0..m { + cooldowns.push(SpellCooldown { + spell_id: read_u16_le(r)?, + item_id: read_u16_le(r)?, + category: read_u16_le(r)?, + spell_cd_ms: read_u32_le(r)?, + category_cd_ms: read_u32_le(r)?, + }); + } + Ok((spells, cooldowns)) +} + +/// Read `SMSG_LEARNED_SPELL` (vmangos `WorldPackets::Spell::LearnedSpell::AppendBodyTo`, +/// `Server/Packets/Spell.cpp:175-179`): `u16 spellId, u16 actionBarSlot`. The slot is "not used on +/// client" (vmangos's own note) and dropped. This is the one wire that grows the spell book *after* +/// login — a trainer purchase, a quest reward, a level-up rank gain (decision 0237); benilla's book +/// was otherwise login-only ([`read_initial_spells`]). +pub(super) fn read_learned_spell(r: &mut impl Read) -> io::Result { + let spell_id = read_u16_le(r)?; + let _action_bar_slot = read_u16_le(r)?; + Ok(spell_id) +} + +/// Read `SMSG_SUPERCEDED_SPELL` (vmangos `SupercededSpell::AppendBodyTo`, `Spell.cpp:169-173`): `u16 +/// oldSpellId, u16 newSpellId` — a rank-up replaces the old spell with the new one in both the book +/// and the action bar (decision 0237). +pub(super) fn read_superceded_spell(r: &mut impl Read) -> io::Result<(u16, u16)> { + Ok((read_u16_le(r)?, read_u16_le(r)?)) +} + +/// Read `SMSG_ACTION_BUTTONS`: packed `u32` per slot to end-of-body (the server sends exactly 120; +/// reading to the boundary keeps us robust to a different count). Zero words (empty slots) are +/// dropped; occupied slots surface as [`ActionButton`]s. +pub(super) fn read_action_buttons(r: &mut &[u8]) -> io::Result> { + let mut buttons = Vec::new(); + let mut slot: u32 = 0; + while !r.is_empty() { + let packed = read_u32_le(r)?; + if packed != 0 { + buttons.push(ActionButton { + slot: slot.min(u8::MAX as u32) as u8, + action: packed & 0x00FF_FFFF, + kind: (packed >> 24) as u8, + }); + } + slot += 1; + } + Ok(buttons) +} + +/// Read `SMSG_CAST_RESULT` → `(spell_id, outcome)`. Reason-specific trailing args (a required +/// spell-focus id, an equip class) are left unread — the slice ends with the packet, nothing +/// follows in the stream. +pub(super) fn read_cast_result(r: &mut impl Read) -> io::Result<(u32, CastOutcome)> { + let spell_id = read_u32_le(r)?; + let status = read_u8(r)?; + let outcome = if status == 2 { + CastOutcome::Failed { + reason: read_u8(r)?, + } + } else { + CastOutcome::Ok + }; + Ok((spell_id, outcome)) +} + +/// Read `SMSG_ATTACKSTART` (vmangos `AttackStart::AppendBodyTo`): two full `u64` guids. +pub(super) fn read_attack_start(r: &mut impl Read) -> io::Result<(u64, u64)> { + Ok((read_u64_le(r)?, read_u64_le(r)?)) +} + +/// One decoded `SMSG_ATTACKERSTATEUPDATE` — a completed melee swing (vmangos +/// `Unit::SendAttackStateUpdate`, `Unit.cpp:4572-4605`; fired **exactly once per weapon-timer +/// cycle**, independently per hand — the real client plays one attacker swing animation per packet, +/// wow-re `combat-swing-anim.md`, decision 0073). The per-school sub-damage split collapses to the +/// packet's own `TotalDamage` plus the summed `absorb` (decision 0137 phase 2's floating combat +/// text feed); `hit_info` bit `0x4` marks an **offhand** swing (the anim selector keys on it), bit +/// `0x10000` suppresses the swing anim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AttackerState { + pub attacker: u64, + pub victim: u64, + pub hit_info: u32, + /// `TotalDamage` — the swing's damage before the sub-damage split. + pub damage: u32, + /// `TargetState` (vmangos `VictimState`): 1 hit · 2 dodge · 3 parry · 4 interrupt · 5 blocks …. + /// A defended outcome (dodge/parry/block/deflect) plays a dedicated victim defense clip at + /// the swing's `$CPP` keyframe (decision 0279, correcting 0073's "never a body animation"); + /// landed hits flinch/bleed; the rest is sound + floating text. + pub victim_state: u32, + /// Sum of the per-sub-damage `absorb` fields (vmangos writes exactly one sub-damage block in + /// practice; summed faithfully in case more than one ever rides the wire). + pub absorb: u32, + /// Sum of the per-sub-damage `resist` fields — the partial-resist trailer's amount + /// (decision 0580's center-text fold-back). + pub resist: i32, + /// `BlockedAmount` — the trailing blocked-damage word. + pub blocked: u32, +} + +/// Read `SMSG_ATTACKERSTATEUPDATE` (byte-verified order, attacker **PackGUID first** — settled by +/// the wow-re §5 against the handler's downstream use, decision 0073): HitInfo · attacker PackGUID · +/// victim PackGUID · TotalDamage · SubDamageCount + per-sub `{school u32, damage f32, damage u32, +/// absorb u32, resist i32}` · TargetState · two u32s (zero + "spell id, seen with heroic strike") · +/// BlockedAmount. +pub(super) fn read_attacker_state(r: &mut impl Read) -> io::Result { + let hit_info = read_u32_le(r)?; + let attacker = read_packed_guid(r)?; + let victim = read_packed_guid(r)?; + let damage = read_u32_le(r)?; + let subs = read_u8(r)?; + let mut absorb = 0u32; + let mut resist = 0i32; + for _ in 0..subs { + // school, damage f32, damage u32 — folded into TotalDamage above; absorb/resist summed. + let _school = read_u32_le(r)?; + let _damage_f = read_f32_le(r)?; + let _damage = read_u32_le(r)?; + absorb += read_u32_le(r)?; + resist += read_u32_le(r)? as i32; + } + let victim_state = read_u32_le(r)?; + let _zero = read_u32_le(r)?; + let _spell_id = read_u32_le(r)?; + let blocked = read_u32_le(r)?; + Ok(AttackerState { + attacker, + victim, + hit_info, + damage, + victim_state, + absorb, + resist, + blocked, + }) +} + +/// Read `SMSG_ATTACKSTOP` (vmangos `AttackStop::AppendBodyTo`): two **packed** guids + a `u32` +/// "victim is dead" word (dropped — death arrives through the descriptor seam). +pub(super) fn read_attack_stop(r: &mut impl Read) -> io::Result<(u64, u64)> { + let attacker = read_packed_guid(r)?; + let victim = read_packed_guid(r)?; + let _is_dead = read_u32_le(r)?; + Ok((attacker, victim)) +} + +/// Read `SMSG_AI_REACTION` → `(unit, reaction)` (vmangos `Creature::SendAIReaction`, +/// `Objects/Creature.cpp:2490-2498` → `WorldPackets::Misc::AiReaction::AppendBodyTo`, +/// `Server/Packets/Misc.cpp:445-449`): a raw (unpacked) `u64` guid + a `u32` reaction. Broadcast +/// with reaction 2 (HOSTILE) on every creature melee-attack start (`Unit::Attack`) and 0 (ALERT) +/// on stealth pre-aggro detection (`CreatureAI::TriggerAlertDirect`); 1/4 exist server-side but +/// are never sent (decision 0277). +pub(super) fn read_ai_reaction(r: &mut impl Read) -> io::Result<(u64, u32)> { + let unit = read_u64_le(r)?; + let reaction = read_u32_le(r)?; + Ok((unit, reaction)) +} + +// --- the spell-visual pipeline wire (decision 0099 phase 1) ----------------------------------------- +// +// `SpellCastTargets` bit flags (vmangos `SpellDefines.h:96-113`, `SpellCastTargetFlags`) needed to +// decode the `write()` shape (`SpellCastTargetsInfo.cpp:180-234`) SMSG_SPELL_START/GO carry. +const TARGET_FLAG_UNIT: u16 = 0x0002; +const TARGET_FLAG_ITEM: u16 = 0x0010; +const TARGET_FLAG_SOURCE_LOCATION: u16 = 0x0020; +const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040; +const TARGET_FLAG_CORPSE_ENEMY: u16 = 0x0200; +const TARGET_FLAG_GAMEOBJECT: u16 = 0x0800; +const TARGET_FLAG_TRADE_ITEM: u16 = 0x1000; +const TARGET_FLAG_STRING: u16 = 0x2000; +const TARGET_FLAG_CORPSE_ALLY: u16 = 0x8000; + +/// A decoded `SpellCastTargets` (vmangos `SpellCastTargetsInfo.cpp:180-234`, the **write** side — +/// asymmetric from the client→server `read()`: when more than one of UNIT/GAMEOBJECT/CORPSE_* is set, +/// the writer's if/else-if chain emits exactly **one** packed guid, by priority UNIT > GAMEOBJECT > +/// CORPSE_ALLY|CORPSE_ENEMY — never happens for a real spell, but the decode must follow the same +/// branch order bit for bit or it desyncs the stream). Surfaces what a consumer needs now +/// (`unit_target`, `go_target`, `dest`); item/corpse/source/string targets are read to keep the cursor +/// aligned and dropped — no consumer for them yet. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpellCastTargets { + pub mask: u16, + pub unit_target: Option, + /// The GameObject this cast targets (`TARGET_FLAG_GAMEOBJECT`) — an open-lock cast on a chest / locked + /// door rides here. Surfaced for the GO lid/door open animation (decision 0250); a unit spell leaves + /// it `None`. Mutually exclusive with `unit_target` in practice (the writer emits one guid). + pub go_target: Option, + pub dest: Option, +} + +fn read_spell_cast_targets(r: &mut impl Read) -> io::Result { + let mask = read_u16_le(r)?; + let mut unit_target = None; + let mut go_target = None; + if mask + & (TARGET_FLAG_UNIT + | TARGET_FLAG_GAMEOBJECT + | TARGET_FLAG_CORPSE_ENEMY + | TARGET_FLAG_CORPSE_ALLY) + != 0 + { + // Exactly one packed guid rides here (see the struct doc); the writer's priority is UNIT > + // GAMEOBJECT, so mirror that branch order (UNIT wins if both bits are set, never in practice). + let guid = read_packed_guid(r)?; + if mask & TARGET_FLAG_UNIT != 0 { + unit_target = Some(guid); + } else if mask & TARGET_FLAG_GAMEOBJECT != 0 { + go_target = Some(guid); + } + } + if mask & (TARGET_FLAG_ITEM | TARGET_FLAG_TRADE_ITEM) != 0 { + let _item_guid = read_packed_guid(r)?; + } + if mask & TARGET_FLAG_SOURCE_LOCATION != 0 { + let _src = Vector3d::read(r)?; + } + let dest = if mask & TARGET_FLAG_DEST_LOCATION != 0 { + Some(Vector3d::read(r)?) + } else { + None + }; + if mask & TARGET_FLAG_STRING != 0 { + let _string_target = read_cstring(r)?; + } + Ok(SpellCastTargets { + mask, + unit_target, + go_target, + dest, + }) +} + +/// `CAST_FLAG_AMMO` (vmangos `Spell.h:53`) — the projectile-visual bit, set for every ranged spell on +/// both `SMSG_SPELL_START` and `SMSG_SPELL_GO`; its presence gates the trailing ammo block. +const CAST_FLAG_AMMO: u16 = 0x0020; + +/// Read the ammo block (`Spell::WriteAmmoToPacket`, `Spell.cpp:4540-4606`): `u32 displayId, u32 +/// inventoryType`. Only `displayId` has a consumer today (the projectile model, phase 4/5) — +/// `inventoryType` is read to stay aligned and dropped. +fn read_ammo(r: &mut impl Read) -> io::Result { + let display_id = read_u32_le(r)?; + let _inventory_type = read_u32_le(r)?; + Ok(display_id) +} + +/// One decoded `SMSG_SPELL_START` — a non-triggered cast began, instants included (`cast_time_ms == +/// 0` — the precast trigger, decision 0099 phase 1). VERIFIED vmangos `Spell::SendSpellStart` +/// (`Spell.cpp:4468-4503`): `item_or_caster` pguid (the cast item's guid when one is in play, else +/// the caster's own — `WriteGuidHelper`, `Spell.cpp:4453-4466`) · `caster` pguid (`m_casterUnit`, +/// always the casting Unit) · `u32 spellId` · `u16 castFlags` (always `CAST_FLAG_UNKNOWN2` 0x2, + +/// `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u32` remaining cast-time ms (`m_timer`) · +/// [`SpellCastTargets`] · the ammo block iff `castFlags & 0x20`. +#[derive(Debug, Clone, PartialEq)] +pub struct SpellStart { + pub item_or_caster: u64, + pub caster: u64, + pub spell_id: u32, + pub cast_flags: u16, + pub cast_time_ms: u32, + pub targets: SpellCastTargets, + pub ammo_display_id: Option, +} + +pub(super) fn read_spell_start(r: &mut impl Read) -> io::Result { + let item_or_caster = read_packed_guid(r)?; + let caster = read_packed_guid(r)?; + let spell_id = read_u32_le(r)?; + let cast_flags = read_u16_le(r)?; + let cast_time_ms = read_u32_le(r)?; + let targets = read_spell_cast_targets(r)?; + let ammo_display_id = if cast_flags & CAST_FLAG_AMMO != 0 { + Some(read_ammo(r)?) + } else { + None + }; + Ok(SpellStart { + item_or_caster, + caster, + spell_id, + cast_flags, + cast_time_ms, + targets, + ammo_display_id, + }) +} + +/// `SPELL_MISS_REFLECT` (vmangos `SpellDefines.h:173`) — the one `SpellMissInfo` that carries a +/// trailing byte (the reflected spell's own outcome against its new target). +const SPELL_MISS_REFLECT: u8 = 11; + +/// One decoded `SMSG_SPELL_GO` — the cast launched. VERIFIED vmangos `Spell::SendSpellGo` +/// (`Spell.cpp:4505-4538`) + its target-list writer `WriteSpellGoTargets` (`Spell.cpp:4608-4659`): +/// the same guid pair + spellId as [`SpellStart`] · `u16 castFlags` (always `CAST_FLAG_UNKNOWN9` +/// 0x100, + `CAST_FLAG_AMMO` 0x20 for a ranged spell) · `u8` hit count + that many **raw** (unpacked) +/// `u64` hit guids · `u8` miss count + that many `{u64 guid, u8 SpellMissInfo, u8 reflectResult iff +/// the reason is `SPELL_MISS_REFLECT`}` · [`SpellCastTargets`] · the ammo block iff `castFlags & +/// 0x20`. Sent at **launch** — the server schedules impact itself off `Spell.dbc` Speed, so nothing +/// about missile travel rides this packet (decision 0099). +#[derive(Debug, Clone, PartialEq)] +pub struct SpellGo { + pub item_or_caster: u64, + pub caster: u64, + pub spell_id: u32, + pub cast_flags: u16, + pub hits: Vec, + /// `(guid, SpellMissInfo reason)` — the reflect-outcome byte (present only when `reason == + /// SPELL_MISS_REFLECT`) is read to stay aligned and dropped; no consumer yet. + pub misses: Vec<(u64, u8)>, + pub targets: SpellCastTargets, + pub ammo_display_id: Option, +} + +pub(super) fn read_spell_go(r: &mut impl Read) -> io::Result { + let item_or_caster = read_packed_guid(r)?; + let caster = read_packed_guid(r)?; + let spell_id = read_u32_le(r)?; + let cast_flags = read_u16_le(r)?; + + let hit_count = read_u8(r)?; + let mut hits = Vec::with_capacity(hit_count as usize); + for _ in 0..hit_count { + hits.push(read_u64_le(r)?); // raw guid (Spell.cpp:4627,4635) — the hit list is never packed + } + let miss_count = read_u8(r)?; + let mut misses = Vec::with_capacity(miss_count as usize); + for _ in 0..miss_count { + let guid = read_u64_le(r)?; + let reason = read_u8(r)?; + if reason == SPELL_MISS_REFLECT { + let _reflect_result = read_u8(r)?; + } + misses.push((guid, reason)); + } + + let targets = read_spell_cast_targets(r)?; + let ammo_display_id = if cast_flags & CAST_FLAG_AMMO != 0 { + Some(read_ammo(r)?) + } else { + None + }; + Ok(SpellGo { + item_or_caster, + caster, + spell_id, + cast_flags, + hits, + misses, + targets, + ammo_display_id, + }) +} + +/// Read `SMSG_SPELL_FAILED_OTHER` → `(caster, spell_id)` (vmangos `Spell::SendInterrupted`, +/// `Spell.cpp:4780-4789`): the broadcast cast-cancel notice for **observers** — a raw (unpacked) +/// `u64` guid + `u32` spellId. Our own cast's failure rides `SMSG_CAST_RESULT` instead; +/// `SMSG_SPELL_FAILURE` is never constructed server-side (decision 0099). +pub(super) fn read_spell_failed_other(r: &mut impl Read) -> io::Result<(u64, u32)> { + let caster = read_u64_le(r)?; + let spell_id = read_u32_le(r)?; + Ok((caster, spell_id)) +} + +/// Read `SMSG_SPELL_DELAYED` → `(caster, delay_ms)` (vmangos `Spell::Delayed`, `Spell.cpp:7472`): +/// a raw (unpacked) `u64` caster guid + `u32` pushback time in ms, sent **to the caster** when a +/// pushback-eligible cast takes damage (Fireball carries `DAMAGE_PUSHBACK`; the server extends its +/// own cast timer by `delay_ms`). The cast bar shifts its window out by the same (decision 0256). +pub(super) fn read_spell_delayed(r: &mut impl Read) -> io::Result<(u64, u32)> { + let caster = read_u64_le(r)?; + let delay_ms = read_u32_le(r)?; + Ok((caster, delay_ms)) +} + +/// Read `MSG_CHANNEL_START` → `(spell_id, duration_ms)` (vmangos `Spell::SendChannelStart`, +/// `Spell.cpp:4951-4954`): `u32 spellId` + `u32 duration` — **self-only** (SendDirectMessage to +/// the casting player; no guid on the wire). The cast bar's channel-open edge (decision 0137). +pub(super) fn read_channel_start(r: &mut impl Read) -> io::Result<(u32, u32)> { + let spell_id = read_u32_le(r)?; + let duration_ms = read_u32_le(r)?; + Ok((spell_id, duration_ms)) +} + +/// Read `MSG_CHANNEL_UPDATE` → `remaining_ms` (vmangos `Player::SendChannelUpdate`, +/// `Player.cpp:21106-21110`): a single `u32` time-left — **self-only**, `0` = the channel is over +/// (sent on natural end and on interrupt alike). The cast bar's channel tick/close (decision 0137). +pub(super) fn read_channel_update(r: &mut impl Read) -> io::Result { + read_u32_le(r) +} + +/// Read `SMSG_UPDATE_AURA_DURATION` → `(slot, remaining_ms)` (vmangos +/// `SpellAuraHolder::UpdateAuraDuration`, `SpellAuras.cpp:7511-7523`): a `u8` `UNIT_FIELD_AURA` slot +/// index and a `u32` of milliseconds left. **Self-only** — it goes to the aura's target, never to +/// the caster or an onlooker — and never sent for a permanent aura, so an occupied slot that has no +/// duration is the reference's "until cancelled" (decision 0255). +pub(super) fn read_update_aura_duration(r: &mut impl Read) -> io::Result<(u8, u32)> { + let slot = read_u8(r)?; + let remaining_ms = read_u32_le(r)?; + Ok((slot, remaining_ms)) +} + +/// Read `SMSG_SPELL_COOLDOWN` → `(caster, Vec<(spell_id, cooldown_ms)>)` (VERIFIED both sides: +/// vmangos `WorldPackets::Spell::SpellCooldown::AppendBodyTo`, `Server/Packets/Spell.cpp:142-151` — +/// a raw `u64` guid then pairs to end-of-body, NO flags byte in 1.12 (vmangos's own commented-out +/// `uint8`); the client handler `0x6e9460` reads exactly guid + `(GetInt32, GetUInt32)*` until the +/// stream runs dry, wow-re `wave-handlers.md`). `cooldown_ms == 0` means "use the spell's own +/// `Spell.dbc` RecoveryTime/CategoryRecoveryTime" (the handler's `cooldownMs!=0` fork); nonzero is +/// a server-set duration (the school-lockout path sends these). vmangos sends this for lockouts +/// (`Player::LockOutSpells`) and pet cooldowns — a normal cast's cooldown is CLIENT-tracked. +pub(super) fn read_spell_cooldown(r: &mut &[u8]) -> io::Result<(u64, Vec<(u32, u32)>)> { + let caster = read_u64_le(r)?; + let mut cooldowns = Vec::new(); + while !r.is_empty() { + let spell_id = read_u32_le(r)?; + let cooldown_ms = read_u32_le(r)?; + cooldowns.push((spell_id, cooldown_ms)); + } + Ok((caster, cooldowns)) +} + +/// Read `SMSG_ITEM_COOLDOWN` → `(item_guid, spell_id)` (VERIFIED both sides: vmangos +/// `WorldPackets::Item::ItemCooldown::AppendBodyTo`, `Server/Packets/Item.cpp:229-233` — raw `u64` +/// item guid + `u32` spell id; the client handler `0x6e95d0` resolves the item object and inserts a +/// **fixed 30 000 ms** cooldown on it, wow-re `wave-handlers.md` — the 30 s is the client's +/// hardcode, nothing more rides the wire). Sent when a proc puts an equipped on-use item on its +/// shared 30 s use-cooldown (vmangos `Player.cpp:19370-19383`). +pub(super) fn read_item_cooldown(r: &mut impl Read) -> io::Result<(u64, u32)> { + Ok((read_u64_le(r)?, read_u32_le(r)?)) +} + +/// Read `SMSG_COOLDOWN_EVENT` / `SMSG_CLEAR_COOLDOWN` → `(spell_id, caster)` — the two share one +/// body shape (VERIFIED both sides: vmangos `CooldownEvent`/`ClearCooldown::AppendBodyTo`, +/// `Server/Packets/Spell.cpp:152-167` — `u32` spell id THEN raw `u64` guid; the client handler +/// `0x6e9670` reads GetInt32 then GetGuid, wow-re `wave-handlers.md`). EVENT **starts** an on-hold +/// (`SPELL_ATTR_COOLDOWN_ON_EVENT`) record's parked timers now; CLEAR removes the record outright. +pub(super) fn read_cooldown_event(r: &mut impl Read) -> io::Result<(u32, u64)> { + Ok((read_u32_le(r)?, read_u64_le(r)?)) +} + +/// Read `SMSG_COOLDOWN_CHEAT` → the target guid (VERIFIED both sides: vmangos +/// `CooldownCheat::AppendBodyTo` — one raw `u64`; the client handler `0x6e9730` wipes the whole +/// self/pet cooldown list when the guid matches, wow-re `wave-handlers.md`). The GM `.cooldown` +/// reset. +pub(super) fn read_cooldown_cheat(r: &mut impl Read) -> io::Result { + read_u64_le(r) +} + +/// Read `SMSG_PLAY_SPELL_VISUAL` → `(unit, kit_id)` (vmangos +/// `WorldPackets::Spell::PlaySpellVisual::AppendBodyTo`, `Server/Packets/Spell.cpp:54-58`): a raw +/// (unpacked) `u64` guid + `u32` kit id, bounds-checked against `SpellVisualKit.dbc` and played +/// at the client's hardcoded stage 0 (`0x6e98d0` — the eat/drink cadence; decision 0280). +pub(super) fn read_play_spell_visual(r: &mut impl Read) -> io::Result<(u64, u32)> { + let unit = read_u64_le(r)?; + let kit_id = read_u32_le(r)?; + Ok((unit, kit_id)) +} + +/// Body of `CMSG_CAST_SPELL` (vmangos `CastSpell::ReadFromWorldPacket` → `SpellCastTargets::read`): +/// `u32 spellId` + the target block. `None` = a self/implicit cast — mask `TARGET_FLAG_SELF (0)`, +/// nothing follows (the server fills the target from the spell's implicit targeting). +/// `Some(guid)` = an explicit unit target — mask `TARGET_FLAG_UNIT (0x0002)` + the guid **packed** +/// (`ReadAsPackedClientBuildAware` is the packed reader for builds > 1.8.4). +pub fn cast_spell(spell_id: u32, target: Option) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&spell_id.to_le_bytes()); + match target { + None => body.extend_from_slice(&0u16.to_le_bytes()), + Some(guid) => { + body.extend_from_slice(&2u16.to_le_bytes()); + crate::wire::write_packed_guid(guid, &mut body).expect("vec write"); + } + } + body +} + +/// `TARGET_FLAG_LOCKED` — set alongside `GAMEOBJECT` when the client casts an OPEN_LOCK spell at a +/// lockable object (decision 0239). It carries **no** wire data of its own (VERIFIED vmangos +/// `SpellCastTargets` read/write — `LOCKED` is in no read branch); the real client sends the bit, so +/// benilla mirrors it. +const TARGET_FLAG_LOCKED: u16 = 0x4000; + +/// Body of `CMSG_CAST_SPELL` aimed at a **GameObject** (decision 0239): the OPEN_LOCK cast a +/// right-click on a locked chest / mining vein / herb node sends instead of `CMSG_GAMEOBJ_USE`. +/// `spell_id`, then the target mask `GAMEOBJECT | LOCKED` (0x4800), then the GameObject's **packed** +/// guid — the only field the mask reads (the GameObject branch reads one packed guid; `LOCKED` adds +/// nothing). Distinct from [`cast_spell`]'s unit/self target shape (flag `0x2` + packed guid). +pub fn cast_spell_gameobject(spell_id: u32, go_guid: u64) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&(TARGET_FLAG_GAMEOBJECT | TARGET_FLAG_LOCKED).to_le_bytes()); + crate::wire::write_packed_guid(go_guid, &mut body).expect("vec write"); + body +} + +/// Body of `CMSG_CAST_SPELL` aimed at an **ITEM** (decision 0437 phase 3): the enchant/poison +/// cast the CraftFrame's item pick completes. `spell_id`, mask `TARGET_FLAG_ITEM (0x0010)`, then +/// the item's **packed** guid — the one field the mask reads (vmangos `SpellCastTargets::read`, +/// `SpellCastTargetsInfo.cpp:159-160`: `ITEM | TRADE_ITEM → one packed guid`; the trade-window +/// sentinel form (`TRADE_ITEM 0x1000`) is the player-trade arc's, not built here). +pub fn cast_spell_item(spell_id: u32, item_guid: u64) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&TARGET_FLAG_ITEM.to_le_bytes()); + crate::wire::write_packed_guid(item_guid, &mut body).expect("vec write"); + body +} + +/// Body of `CMSG_ATTACKSWING` (vmangos `AttackSwing::ReadFromWorldPacket`): one full `u64` victim +/// guid. Starts melee auto-attack; the server answers `SMSG_ATTACKSTART` (or an attack-swing error +/// packet). `CMSG_ATTACKSTOP`'s body is empty — no builder needed. +pub fn attack_swing(guid: u64) -> Vec { + guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_SET_ACTION_BUTTON` (VERIFIED vmangos `WorldPackets::Misc::SetActionButton:: +/// ReadFromWorldPacket`, `Server/Packets/Misc.cpp:87-90`; opcode 296 `Opcodes_1_12_1.h:299`): +/// `button u8` + `packetData u32` (`action | kind<<24`, [`ActionButton`]'s own packing) — 5 +/// bytes. `packed == 0` clears the slot (`HandleSetActionButtonOpcode`'s `!packet.packetData` +/// branch calls `removeActionButton`, never sent back over the wire — decision 0216 §7/0218 §4: +/// the client sends ONE of these per local slot mutation, a drag-swap is two sends, never atomic). +pub fn set_action_button(button: u8, packed: u32) -> Vec { + let mut body = Vec::with_capacity(5); + body.push(button); + body.extend_from_slice(&packed.to_le_bytes()); + body +} + +/// Body of `CMSG_SETSHEATHED` (vmangos `SetSheathed::ReadFromWorldPacket`: `recv_data >> sheathed`): +/// one `u32` sheath state (0 unarmed/stowed, 1 melee drawn, 2 ranged drawn). Purely +/// client-volunteered — `HandleSetSheathedOpcode` (`CombatHandler.cpp:80-87`) just stores whatever +/// we send via `Unit::SetSheath`, which lands in our own `UNIT_FIELD_BYTES_2` and relays to nearby +/// observers on the next values update; the server has no independent way to know a weapon is drawn. +pub fn set_sheathed(state: u32) -> Vec { + state.to_le_bytes().to_vec() +} + +/// Body of `CMSG_CANCEL_AURA` (vmangos `WorldPackets::Spell::CancelAura`, `Server/Packets/Spell.h:55-62`): +/// one `u32` spell id. The server cancels **by spell, not by slot** — `HandleCancelAuraOpcode` +/// (`SpellHandler.cpp:333-405`) looks the spell up, refuses passives, `SPELL_ATTR_NO_AURA_CANCEL` +/// spells and debuffs, then calls `RemoveAurasDueToSpellByCancel`. The wire's own +/// `AURA_FLAG_CANCELABLE` nibble bit is the matching client-side gate (decision 0255). +pub fn cancel_aura(spell_id: u32) -> Vec { + spell_id.to_le_bytes().to_vec() +} + +// --- the combat-log wire (decision 0137 phase 2's floating-combat-text data feed) -------------------- + +/// One decoded `SMSG_SPELLNONMELEEDAMAGELOG` — non-melee (spell) damage dealt (vmangos +/// `WorldPackets::Spell::SpellNonMeleeDamageLog::AppendBodyTo`, `Server/Packets/Spell.cpp:124-140` + +/// `Spell.h:178-198`). `hit_info` bit `0x2` is `SPELL_HIT_TYPE_CRIT` (vmangos `SpellDefines.h:179`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpellDamageLog { + pub target: u64, + pub attacker: u64, + pub spell_id: u32, + pub damage: u32, + pub school: u8, + pub absorb: u32, + pub resist: i32, + pub periodic: bool, + pub blocked: u32, + pub hit_info: u32, +} + +/// Read `SMSG_SPELLNONMELEEDAMAGELOG`: target PackedGuid · attacker PackedGuid · spellId u32 · +/// damage u32 · school u8 · absorbed u32 · resist i32 · periodicLog u8 (bool) · unused u8 · blocked +/// u32 · hitInfo u32 · extendedData u8 (always 0 — read and dropped). +pub(super) fn read_spell_damage_log(r: &mut impl Read) -> io::Result { + let target = read_packed_guid(r)?; + let attacker = read_packed_guid(r)?; + let spell_id = read_u32_le(r)?; + let damage = read_u32_le(r)?; + let school = read_u8(r)?; + let absorb = read_u32_le(r)?; + let resist = read_i32_le(r)?; + let periodic = read_u8(r)? != 0; + let _unused = read_u8(r)?; + let blocked = read_u32_le(r)?; + let hit_info = read_u32_le(r)?; + let _extended_data = read_u8(r)?; + Ok(SpellDamageLog { + target, + attacker, + spell_id, + damage, + school, + absorb, + resist, + periodic, + blocked, + hit_info, + }) +} + +/// One tick of `SMSG_PERIODICAURALOG` — the payload shape depends on the tick's `AuraType` (vmangos +/// `SpellAuraDefines.h`): `PERIODIC_DAMAGE` (3) / `PERIODIC_DAMAGE_PERCENT` (89) carry a damage +/// breakdown; `PERIODIC_HEAL` (8) / `OBS_MOD_HEALTH` (20) a plain heal amount; `OBS_MOD_MANA` (21) / +/// `PERIODIC_ENERGIZE` (24) a power+amount pair; `PERIODIC_MANA_LEECH` (64) a power+amount+multiplier +/// triple. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PeriodicTick { + Damage { + amount: u32, + school: u32, + absorb: u32, + resist: i32, + }, + Heal { + amount: u32, + }, + Energize { + power: u32, + amount: u32, + }, + ManaLeech { + power: u32, + amount: u32, + multiplier: f32, + }, +} + +/// One decoded `SMSG_PERIODICAURALOG` — periodic (DoT/HoT/regen) aura ticks (vmangos +/// `Unit::SendPeriodicAuraLog`, `Unit.cpp:4395-4443`). vmangos always writes `count == 1`; the loop +/// is decoded faithfully regardless. +#[derive(Debug, Clone, PartialEq)] +pub struct PeriodicAuraLog { + pub target: u64, + pub caster: u64, + pub spell_id: u32, + pub ticks: Vec, +} + +const AURA_PERIODIC_DAMAGE: u32 = 3; +const AURA_PERIODIC_HEAL: u32 = 8; +const AURA_OBS_MOD_HEALTH: u32 = 20; +const AURA_OBS_MOD_MANA: u32 = 21; +const AURA_PERIODIC_ENERGIZE: u32 = 24; +const AURA_PERIODIC_MANA_LEECH: u32 = 64; +const AURA_PERIODIC_DAMAGE_PERCENT: u32 = 89; + +/// Read `SMSG_PERIODICAURALOG`: target PackedGuid · caster PackedGuid · spellId u32 · count u32 · +/// `count` entries of `{auraType u32, payload}` — see [`PeriodicTick`] for the payload shapes. An +/// aura type outside that set cannot be skipped without desyncing the stream, so it errors instead. +pub(super) fn read_periodic_aura_log(r: &mut impl Read) -> io::Result { + let target = read_packed_guid(r)?; + let caster = read_packed_guid(r)?; + let spell_id = read_u32_le(r)?; + let count = read_u32_le(r)?; + let mut ticks = Vec::with_capacity(count as usize); + for _ in 0..count { + let aura_type = read_u32_le(r)?; + let tick = match aura_type { + AURA_PERIODIC_DAMAGE | AURA_PERIODIC_DAMAGE_PERCENT => PeriodicTick::Damage { + amount: read_u32_le(r)?, + school: read_u32_le(r)?, + absorb: read_u32_le(r)?, + resist: read_i32_le(r)?, + }, + AURA_PERIODIC_HEAL | AURA_OBS_MOD_HEALTH => PeriodicTick::Heal { + amount: read_u32_le(r)?, + }, + AURA_OBS_MOD_MANA | AURA_PERIODIC_ENERGIZE => PeriodicTick::Energize { + power: read_u32_le(r)?, + amount: read_u32_le(r)?, + }, + AURA_PERIODIC_MANA_LEECH => PeriodicTick::ManaLeech { + power: read_u32_le(r)?, + amount: read_u32_le(r)?, + multiplier: read_f32_le(r)?, + }, + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("SMSG_PERIODICAURALOG: unknown aura type {other}"), + )) + } + }; + ticks.push(tick); + } + Ok(PeriodicAuraLog { + target, + caster, + spell_id, + ticks, + }) +} + +/// One decoded `SMSG_SPELLHEALLOG` — a direct heal landing (vmangos +/// `WorldPackets::Spell::SpellHealLog::AppendBodyTo`, `Server/Packets/Spell.cpp:105-112` + +/// `Spell.h:151-163`) — the center combat text's HEAL/HEAL_CRIT feed (decision 0578). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpellHealLog { + pub target: u64, + pub healer: u64, + pub spell_id: u32, + pub amount: u32, + pub critical: bool, +} + +/// Read `SMSG_SPELLHEALLOG`: target PackedGuid · healer PackedGuid · spellId u32 · amount u32 · +/// critical u8 (bool). +pub(super) fn read_spell_heal_log(r: &mut impl Read) -> io::Result { + Ok(SpellHealLog { + target: read_packed_guid(r)?, + healer: read_packed_guid(r)?, + spell_id: read_u32_le(r)?, + amount: read_u32_le(r)?, + critical: read_u8(r)? != 0, + }) +} + +/// One decoded `SMSG_SPELLENERGIZELOG` — an instant power gain (vmangos +/// `WorldPackets::Spell::SpellEnergizeLog::AppendBodyTo`, `Server/Packets/Spell.cpp:114-121` + +/// `Spell.h:165-176`). `power` is the vmangos `Powers` enum (0 mana · 1 rage · 2 focus · +/// 3 energy · 4 happiness) — the center combat text's MANA/RAGE/FOCUS/ENERGY feed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpellEnergizeLog { + pub target: u64, + pub caster: u64, + pub spell_id: u32, + pub power: u32, + pub amount: u32, +} + +/// Read `SMSG_SPELLENERGIZELOG`: target PackedGuid · caster PackedGuid · spellId u32 · +/// powerType u32 · amount u32. +pub(super) fn read_spell_energize_log(r: &mut impl Read) -> io::Result { + Ok(SpellEnergizeLog { + target: read_packed_guid(r)?, + caster: read_packed_guid(r)?, + spell_id: read_u32_le(r)?, + power: read_u32_le(r)?, + amount: read_u32_le(r)?, + }) +} + +/// One decoded `SMSG_SPELLDAMAGESHIELD` — a damage-shield (Thorns-style) return hit (vmangos +/// `WorldPackets::Combat::SpellDamageShield::AppendBodyTo`, `Server/Packets/Combat.cpp:73-79` + +/// `Combat.h:124-134`). `victim` is the shield's bearer; `attacker` is the unit that struck them and +/// now **receives** this damage back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DamageShield { + pub victim: u64, + pub attacker: u64, + pub damage: u32, + pub school: u32, +} + +/// Read `SMSG_SPELLDAMAGESHIELD`: victim raw `u64` guid · attacker raw `u64` guid · damage u32 · +/// school u32. +pub(super) fn read_damage_shield(r: &mut impl Read) -> io::Result { + Ok(DamageShield { + victim: read_u64_le(r)?, + attacker: read_u64_le(r)?, + damage: read_u32_le(r)?, + school: read_u32_le(r)?, + }) +} + +/// One decoded `SMSG_ENVIRONMENTALDAMAGELOG` — environmental damage taken: fall, drowning, +/// fatigue, lava, slime, fire (vmangos `Unit::SendEnvironmentalDamageLog`, `Objects/Unit.cpp:5392` +/// → `WorldPackets::Combat::EnvironmentalDamageLog::AppendBodyTo`, `Server/Packets/Combat.cpp:58-67`; +/// the absorb/resist tail is the `> 1.6.1` layout our 5875 wire carries). `damage_type` is +/// vmangos `EnvironmentalDamageType` (`Objects/Player.h:590`): 0 exhausted · 1 drowning · +/// 2 **fall** · 3 lava · 4 slime · 5 fire — the index into the client's `EnvironmentalDamage.dbc` +/// 6-slot damage-type → SpellVisualKit table (its fall row is the landing dust puff). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EnvironmentalDamageLog { + pub victim: u64, + pub damage_type: u8, + pub damage: u32, + pub absorb: u32, + pub resist: i32, +} + +/// Read `SMSG_ENVIRONMENTALDAMAGELOG`: victim raw `u64` guid (vmangos `ObjectGuid.cpp:174` +/// streams the raw value; the client reads it with its plain 8-byte guid reader `0x4190b0`) · +/// damageType u8 · damage u32 · absorbed u32 · resist i32. +pub(super) fn read_environmental_damage_log( + r: &mut impl Read, +) -> io::Result { + Ok(EnvironmentalDamageLog { + victim: read_u64_le(r)?, + damage_type: read_u8(r)?, + damage: read_u32_le(r)?, + absorb: read_u32_le(r)?, + resist: read_i32_le(r)?, + }) +} + +/// One decoded `SMSG_SPELLLOGMISS` — a spell cast's per-target miss list (vmangos +/// `WorldPackets::Spell::SpellLogMiss::AppendBodyTo`, `Server/Packets/Spell.cpp:68-86` + +/// `Spell.h:109-124`). Each entry's `u8` is a `SpellMissInfo` (vmangos `SpellDefines.h:160-174`, +/// same vocabulary as [`SPELL_MISS_REFLECT`]'s family): 1 MISS · 2 RESIST · 3 DODGE · 4 PARRY · +/// 5 BLOCK · 6 EVADE · 7/8 IMMUNE · 9 DEFLECT · 10 ABSORB · 11 REFLECT. +#[derive(Debug, Clone, PartialEq)] +pub struct SpellLogMiss { + pub spell_id: u32, + pub caster: u64, + pub misses: Vec<(u64, u8)>, +} + +/// Read `SMSG_SPELLLOGMISS`: spellId u32 · caster raw `u64` · useExtended u8 (vmangos always 0) · +/// count u32 · `count` entries of `{target raw u64, missInfo u8}`. When `useExtended != 0`, each +/// entry additionally carries a trailing `2×f32` — read and dropped to keep the cursor aligned; no +/// consumer needs it. +pub(super) fn read_spell_log_miss(r: &mut impl Read) -> io::Result { + let spell_id = read_u32_le(r)?; + let caster = read_u64_le(r)?; + let use_extended = read_u8(r)?; + let count = read_u32_le(r)?; + let mut misses = Vec::with_capacity(count as usize); + for _ in 0..count { + let target = read_u64_le(r)?; + let miss_info = read_u8(r)?; + if use_extended != 0 { + let _arg1 = read_f32_le(r)?; + let _arg2 = read_f32_le(r)?; + } + misses.push((target, miss_info)); + } + Ok(SpellLogMiss { + spell_id, + caster, + misses, + }) +} + +/// One decoded `SMSG_LOG_XPGAIN` — an XP award, kill or non-kill (vmangos +/// `WorldPackets::Misc::LogXpGain::AppendBodyTo`, `Server/Packets/Misc.cpp:512-522` + +/// `Misc.h:779-790`). `victim` is `0` for non-kill xp (quest/exploration/GM command). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct XpGain { + pub victim: u64, + pub total: u32, + /// The award before the rested bonus — only a kill carries it on the wire (rested applies + /// to kill XP only, so a non-kill packet's `base` is `= total` here). `total − base` is the + /// chat line's "(+N exp Rested bonus)" amount (decision 0304). + pub base: u32, + pub kill: bool, +} + +/// Read `SMSG_LOG_XPGAIN`: victim raw `u64` (0 for non-kill) · totalXp u32 · xpType u8 (0 = kill, +/// 1 = non-kill) · iff `xpType == 0`: baseXp u32 · groupBonus f32 (read and dropped — composing +/// the "+N group bonus" line form waits for parties to exist; decision 0304 scope call). +pub(super) fn read_xp_gain(r: &mut impl Read) -> io::Result { + let victim = read_u64_le(r)?; + let total = read_u32_le(r)?; + let xp_type = read_u8(r)?; + let kill = xp_type == 0; + let mut base = total; + if kill { + base = read_u32_le(r)?; + let _group_bonus = read_f32_le(r)?; + } + Ok(XpGain { + victim, + total, + base, + kill, + }) +} + +/// Body of `CMSG_LEARN_TALENT` (vmangos `Server/Packets/Skill.h:10-19` + +/// `Player::LearnTalent`, Player.cpp:20807): `u32 talentId` (a `Talent.dbc` row id) + +/// `u32 requestedRank`, **0-based** — requesting rank k learns *up to* k (the server spends +/// `k − current + 1` points), so the click sends the current rank count itself (the next +/// 0-based rank). Decision 0304. +pub fn learn_talent(talent_id: u32, requested_rank: u32) -> Vec { + let mut body = Vec::with_capacity(8); + body.extend_from_slice(&talent_id.to_le_bytes()); + body.extend_from_slice(&requested_rank.to_le_bytes()); + body +} + +/// One decoded `SMSG_LEVELUP_INFO` — our own ding, self-addressed only (vmangos +/// `WorldPackets::Misc::LevelUpInfo`, `Misc.h:793-802` + `Misc.cpp:524-532`; filled by +/// `Player::GiveLevel`, Player.cpp:3210-3218, and sent straight to the leveling session — no +/// guid). Decision 0304. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LevelUpInfo { + /// The level just reached. + pub level: u32, + /// Hit points gained. + pub health: u32, + /// Power gains indexed mana, rage, focus, energy, happiness. The 1.12 server fills mana (or + /// zero for non-mana classes) and leaves the rest zero; `powers[0]` is the reference chat + /// line's mana arg. + pub powers: [u32; 5], + /// Stat gains indexed str, agi, stam, int, spirit — the reference chat's `SPELL_STAT0..4` + /// order, one line per positive entry. + pub stats: [u32; 5], +} + +/// Read `SMSG_LEVELUP_INFO`: level u32 · healthGain u32 · powerGains u32×5 · statGains u32×5 +/// (48 bytes, `Misc.cpp:524-532`). +pub(super) fn read_level_up_info(r: &mut impl Read) -> io::Result { + let level = read_u32_le(r)?; + let health = read_u32_le(r)?; + let mut powers = [0u32; 5]; + for p in &mut powers { + *p = read_u32_le(r)?; + } + let mut stats = [0u32; 5]; + for s in &mut stats { + *s = read_u32_le(r)?; + } + Ok(LevelUpInfo { + level, + health, + powers, + stats, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cast_spell_gameobject_body_golden() { + // spell_id (LE) + target mask GAMEOBJECT|LOCKED = 0x4800 (LE `00 48`) + packed guid 0x1234 + // (mask 0x03, bytes 34 12). VERIFIED vmangos `SpellCastTargets`: LOCKED reads no bytes; the + // GameObject branch reads one packed guid. + assert_eq!( + cast_spell_gameobject(1, 0x1234), + [0x01, 0x00, 0x00, 0x00, 0x00, 0x48, 0x03, 0x34, 0x12], + "CMSG_CAST_SPELL (GameObject/OPEN_LOCK) body" + ); + // The unit-target twin still carries flag 0x2, not 0x4800 — the two shapes stay distinct. + assert_eq!(cast_spell(1, None), [0x01, 0x00, 0x00, 0x00, 0x00, 0x00]); + } + + #[test] + fn cooldown_bodies_golden() { + // SMSG_SPELL_COOLDOWN: raw u64 guid, then (u32 spell, u32 ms) pairs to end-of-body — NO + // flags byte in 1.12 (vmangos's own commented-out uint8; the client handler reads the + // pairs straight after the guid). Guid 0x10, then {133, 0} ("use Spell.dbc") and + // {5384, 30000}. + let body: Vec = [ + 0x10u64.to_le_bytes().to_vec(), + 133u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + 5384u32.to_le_bytes().to_vec(), + 30_000u32.to_le_bytes().to_vec(), + ] + .concat(); + let mut r = &body[..]; + assert_eq!( + read_spell_cooldown(&mut r).unwrap(), + (0x10, vec![(133, 0), (5384, 30_000)]) + ); + assert!(r.is_empty()); + + // SMSG_ITEM_COOLDOWN: raw u64 item guid + u32 spell id — nothing else (the 30 s is the + // client's hardcode). + let body: Vec = [ + 0x40u64.to_le_bytes().to_vec(), + 439u32.to_le_bytes().to_vec(), + ] + .concat(); + let mut r = &body[..]; + assert_eq!(read_item_cooldown(&mut r).unwrap(), (0x40, 439)); + + // SMSG_COOLDOWN_EVENT / SMSG_CLEAR_COOLDOWN: u32 spell id FIRST, then the raw u64 guid + // (vmangos `CooldownEvent::AppendBodyTo`; the client reads GetInt32 then GetGuid). + let body: Vec = [ + 1784u32.to_le_bytes().to_vec(), + 0x22u64.to_le_bytes().to_vec(), + ] + .concat(); + let mut r = &body[..]; + assert_eq!(read_cooldown_event(&mut r).unwrap(), (1784, 0x22)); + + // SMSG_COOLDOWN_CHEAT: the lone raw u64. + let body = 0x33u64.to_le_bytes(); + let mut r = &body[..]; + assert_eq!(read_cooldown_cheat(&mut r).unwrap(), 0x33); + } + + #[test] + fn aura_bodies_golden() { + // CMSG_CANCEL_AURA: a lone u32 spell id, LE. 1126 (Mark of the Wild) = 0x0000_0466. The + // server cancels by spell — there is no slot byte to get wrong. + assert_eq!(cancel_aura(1126), [0x66, 0x04, 0x00, 0x00]); + + // SMSG_UPDATE_AURA_DURATION: u8 slot, then u32 ms LE. Slot 3, 12_000 ms = 0x0000_2EE0. + let body = [0x03, 0xE0, 0x2E, 0x00, 0x00]; + let mut r = &body[..]; + assert_eq!(read_update_aura_duration(&mut r).unwrap(), (3, 12_000)); + assert!( + r.is_empty(), + "the body is exactly 5 bytes — slot is a byte, not a dword" + ); + } +} diff --git a/crates/benilla-protocol/src/messages/taxi.rs b/crates/benilla-protocol/src/messages/taxi.rs new file mode 100644 index 00000000..dee70ac1 --- /dev/null +++ b/crates/benilla-protocol/src/messages/taxi.rs @@ -0,0 +1,140 @@ +//! Taxi/flight-master messages — the "right-click a flight master → pick a destination → fly" +//! wire (opcodes 425-431 + 786, vmangos `Opcodes_1_12_1.h`, VERIFIED; decision 0484). Bodies from +//! vmangos `Handlers/TaxiHandler.cpp` (the SMSG writers) and the hand-serialized +//! `Server/Packets/Taxi.{h,cpp}` (the CMSG readers + the two hand-serialized SMSGs, +//! `TaxiNodeStatus`/`ActivateTaxiReply`/`NewTaxiPath`). Every guid on this wire, both directions, +//! is a **plain** u64 — `ObjectGuid`'s own `ByteBuffer` stream operators (`ObjectGuid.cpp:174-186`) +//! read/write the raw value; `PackedGuid` is a distinct type these packets never reach for. The +//! flight itself (`SMSG_MONSTER_MOVE`, the mount rails) rides existing wire families +//! (`messages::monster_move`, decision 0441) — this module is only the menu + activate send. + +use std::io; + +use crate::wire::{read_u32_le, read_u64_le, read_u8}; + +/// The known-node bitmask (`SHOWTAXINODES`'s tail; vmangos `PlayerTaxi::m_taximask`, a `TaxiMask` +/// = `uint32[8]`, `DBCStructure.h:842-844` `TaxiMaskSize`/`TaxiMask`, written by +/// `PlayerTaxi::AppendTaximaskTo`, `PlayerTaxi.cpp:53-65`). Bit law (`PlayerTaxi.h:37-49`, +/// `IsTaximaskNodeKnown`/`SetTaximaskNode`): a node id is 1-based, `field = (id-1)/32`, `bit = +/// (id-1)%32` within that word. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TaxiMask(pub [u32; 8]); + +impl TaxiMask { + /// Whether `node_id` is known. Node id `0` is never valid (ids are 1-based) and reads + /// unknown; an id past the mask's 256-node range reads unknown too (the shipped 1.12 catalog + /// tops out at 85 nodes, so real data never reaches that branch). + pub fn is_known(&self, node_id: u32) -> bool { + node_id != 0 + && self + .0 + .get(((node_id - 1) / 32) as usize) + .is_some_and(|word| word & (1 << ((node_id - 1) % 32)) != 0) + } +} + +/// `TaxiError` (vmangos `Objects/Player.h:404-416`) — `SMSG_ACTIVATETAXIREPLY`'s `replyCode`. +/// Codes actually assigned by `Player::ActivateTaxiPathTo` (`Player.cpp:18008-18252`, this +/// session's grep): OK, UNSPECIFIED_SERVER_ERROR, NO_SUCH_PATH, NOT_ENOUGH_MONEY, TOO_FAR, BUSY, +/// ALREADY_MOUNTED, SHAPESHIFTED. `NO_VENDOR_NEARBY`/`NOT_VISITED`/`PLAYER_MOVING`/`SAME_NODE`/ +/// `NOT_STANDING` are defined in the enum but no send site ever assigns them — kept for +/// completeness (a private-server fork could still emit one) but never observed on the wire. +pub mod taxi_reply { + pub const OK: u32 = 0; + pub const UNSPECIFIED_SERVER_ERROR: u32 = 1; + pub const NO_SUCH_PATH: u32 = 2; + pub const NOT_ENOUGH_MONEY: u32 = 3; + pub const TOO_FAR: u32 = 4; + pub const NO_VENDOR_NEARBY: u32 = 5; + pub const NOT_VISITED: u32 = 6; + pub const BUSY: u32 = 7; + pub const ALREADY_MOUNTED: u32 = 8; + pub const SHAPESHIFTED: u32 = 9; + pub const PLAYER_MOVING: u32 = 10; + pub const SAME_NODE: u32 = 11; + pub const NOT_STANDING: u32 = 12; +} + +/// Body of `CMSG_TAXINODE_STATUS_QUERY` (vmangos `Packets/Taxi.cpp`, +/// `TaxiNodeStatusQuery::ReadFromWorldPacket`): one full 8-byte guid — "the GUID of the +/// flightmaster" per vmangos's own comment (a normal client sends the NPC it's standing near, not +/// itself). Answered by `SMSG_TAXINODE_STATUS`. +pub fn taxi_node_status_query(flightmaster_guid: u64) -> Vec { + flightmaster_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_TAXIQUERYAVAILABLENODES` (vmangos `Packets/Taxi.cpp`, +/// `TaxiQueryAvailableNodes::ReadFromWorldPacket`): one full 8-byte flight-master guid — the +/// direct taxi-map opener (decision 0496 I4: CONFIRMED as built — the client's interact ladder is +/// first-match-wins low→high over `UNIT_NPC_FLAGS`, so a gossip+taxi NPC pre-empts to gossip and +/// only a pure flightmaster sends this; the gossip taxi option reaches the same menu server-side +/// through `SendTaxiMenu`). Answered by `SMSG_SHOWTAXINODES` on a known node; a +/// never-visited node instead answers `SMSG_NEW_TAXI_PATH` + `SMSG_TAXINODE_STATUS(known=1)` — +/// the server "learns, never opens" on first contact (vmangos `SendLearnNewTaxiNode`). +pub fn taxi_query_available_nodes(flightmaster_guid: u64) -> Vec { + flightmaster_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_ACTIVATETAXI` (vmangos `Packets/Taxi.cpp`, `ActivateTaxi::ReadFromWorldPacket`): +/// `u64 flightmasterGuid, u32 node1 (source), u32 node2 (dest)` — a single-hop flight. Answered +/// by `SMSG_ACTIVATETAXIREPLY`; success continues into the mount + `SMSG_MONSTER_MOVE` flight. +pub fn activate_taxi(flightmaster_guid: u64, node1: u32, node2: u32) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&flightmaster_guid.to_le_bytes()); + body.extend_from_slice(&node1.to_le_bytes()); + body.extend_from_slice(&node2.to_le_bytes()); + body +} + +/// Body of `CMSG_ACTIVATETAXIEXPRESS` (vmangos `Packets/Taxi.cpp`, +/// `ActivateTaxiExpress::ReadFromWorldPacket`, `> CLIENT_BUILD_1_9_4` — active for 5875): `u64 +/// flightmasterGuid, u32 totalcost, u32 nodeCount, nodeCount x u32 node` — the full multi-hop +/// chain in one send when **no direct `TaxiPath` edge exists current→target** (decision 0496 +/// §TU-3, byte-verified discriminator at `0x4dbad0`: it is edge presence, not hop count — a +/// direct edge sends the single-hop `ActivateTaxi` below even when the drawn route detours +/// through intermediate stops). +pub fn activate_taxi_express(flightmaster_guid: u64, total_cost: u32, nodes: &[u32]) -> Vec { + let mut body = Vec::with_capacity(16 + nodes.len() * 4); + body.extend_from_slice(&flightmaster_guid.to_le_bytes()); + body.extend_from_slice(&total_cost.to_le_bytes()); + body.extend_from_slice(&(nodes.len() as u32).to_le_bytes()); + for &n in nodes { + body.extend_from_slice(&n.to_le_bytes()); + } + body +} + +/// Read `SMSG_SHOWTAXINODES` (vmangos `WorldSession::SendTaxiMenu`, `TaxiHandler.cpp:82-96`): +/// `u32 gate`, then **iff `gate != 0`** `u64 flightmasterGuid` (plain, not packed), `u32 +/// nearestNode` (the node closest to the flight master — the map's "you are here"), `u32[8] +/// knownMask` ([`TaxiMask`], `AppendTaximaskTo`). The leading word is a real conditional gate in +/// the client's own parser (`0x5ece60` reads the rest only when it is nonzero — byte-verified, +/// decision 0496 §claim-1), not a constant to skip; vmangos always sends `1` (48 bytes), so the +/// gated-off 4-byte body never occurs live — parsed faithfully anyway. Returns `(gate, +/// flightmaster, nearest_node, mask)`, all-zero past a zero gate (decode drops it — no event). +pub(super) fn read_show_taxi_nodes(r: &mut &[u8]) -> io::Result<(u32, u64, u32, TaxiMask)> { + let window = read_u32_le(r)?; + if window == 0 { + return Ok((0, 0, 0, TaxiMask::default())); + } + let flightmaster = read_u64_le(r)?; + let nearest_node = read_u32_le(r)?; + let mut mask = [0u32; 8]; + for word in &mut mask { + *word = read_u32_le(r)?; + } + Ok((window, flightmaster, nearest_node, TaxiMask(mask))) +} + +/// Read `SMSG_TAXINODE_STATUS` (vmangos `TaxiNodeStatus::AppendBodyTo`, `Packets/Taxi.cpp:36-40`): +/// `u64 guid` (plain), `u8 known` (a C++ `bool` — `ByteBuffer::operator<<(bool const&)` appends +/// exactly one byte, `ByteBuffer.h:194-197`). Returns `(guid, known)`. +pub(super) fn read_taxi_node_status(r: &mut &[u8]) -> io::Result<(u64, u8)> { + Ok((read_u64_le(r)?, read_u8(r)?)) +} + +/// Read `SMSG_ACTIVATETAXIREPLY` (vmangos `ActivateTaxiReply::AppendBodyTo`, +/// `Packets/Taxi.cpp:46-49`): one [`taxi_reply`] `u32` code. +pub(super) fn read_activate_taxi_reply(r: &mut &[u8]) -> io::Result { + read_u32_le(r) +} diff --git a/crates/benilla-protocol/src/messages/trade.rs b/crates/benilla-protocol/src/messages/trade.rs new file mode 100644 index 00000000..fa4fe017 --- /dev/null +++ b/crates/benilla-protocol/src/messages/trade.rs @@ -0,0 +1,480 @@ +//! The player-to-player trade arc's wire layer (decision 0592 phase P0): the request verbs the +//! trade window sends and the two status packets the server pushes back. Layout VERIFIED against +//! vmangos `Handlers/TradeHandler.cpp`, `Server/Packets/Trade.{h,cpp}`, `Objects/TradeData.{h,cpp}` +//! and `SharedDefines.h` (read directly at decision time). Trade renders nothing derived from +//! WoW.exe, so vmangos is the whole wire authority: its `ReadFromWorldPacket` pins every CMSG body +//! below, its `AppendBodyTo`/`SendUpdateTrade` pin both SMSG parses — no wow-re dispatch is +//! load-bearing (decision 0592, "No wow-re dispatch"). + +use std::io; + +use crate::wire::{read_i32_le, read_u32_le, read_u64_le, read_u8}; + +/// `TRADE_SLOT_COUNT` (`TradeData.h`): seven slots per side — six tradeable (0..6) plus the +/// seventh **non-traded / enchant** slot ([`TRADE_SLOT_NONTRADED`]), whose item is not exchanged +/// but is the target an enchant/lockpick spell is applied to through the window. +pub const TRADE_SLOT_COUNT: usize = 7; +/// `TRADE_SLOT_TRADED_COUNT` — the six slots (0..6) whose items actually change hands. +pub const TRADE_SLOT_TRADED_COUNT: usize = 6; +/// `TRADE_SLOT_NONTRADED` — the 7th slot (index 6, UI id 7): an item parked here stays with its +/// owner; it is the enchant/spell target, not part of the exchange. +pub const TRADE_SLOT_NONTRADED: usize = 6; + +/// The trade state machine's status codes (`SharedDefines.h` `enum TradeStatus`, 0..=22). The +/// tail-carrying members hold their `SMSG_TRADE_STATUS` payload inline (VERIFIED vmangos +/// `WorldPackets::Trade::TradeStatus::AppendBodyTo`): only `BEGIN_TRADE`, `CLOSE_WINDOW` and +/// `ONLY_CONJURED` ride a tail; every other status is the bare `u32`. [`TradeStatus::Unknown`] +/// keeps an out-of-range code (never emitted by vmangos on 5875) parseable rather than fatal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TradeStatus { + /// `0` — the target is already trading / busy (also the initiator's own "already trading"). + Busy, + /// `1` — sent to the *target* of a fresh `CMSG_INITIATE_TRADE`; `partner` is the initiator's + /// guid. The client answers `CMSG_BEGIN_TRADE`, which makes the server emit `OpenWindow` to + /// both sides. + BeginTrade { partner: u64 }, + /// `2` — open the trade window (fires the client's `TRADE_SHOW`). Sent to both sides once the + /// target's `CMSG_BEGIN_TRADE` arrives. + OpenWindow, + /// `3` — the trade was cancelled; close both windows. + Canceled, + /// `4` — the *partner* pressed Trade (drives the accept-glow on their column). + Accept, + /// `5` — a second "busy" code, handled as busy. + Busy2, + /// `6` — no such target for `CMSG_INITIATE_TRADE`. + NoTarget, + /// `7` — a change happened after an accept (or the 200 ms scam-delay bounced an accept): + /// drop the accept and go back to editing. + BackToTrade, + /// `8` — both sides accepted and the swap completed; close both windows. + Complete, + /// `9` — the trade was rejected. + Rejected, + /// `10` — the partner is out of `TRADE_DISTANCE` (also the flying/no-map initiate refusal). + TargetTooFar, + /// `11` — cross-faction trade refused (unless the server allows two-side interaction). + WrongFaction, + /// `12` — close the window; `result`/`item_limit_category` are the vanilla tail (usually 0; + /// the middle `u8` vmangos writes is consumed but carries nothing for player trade). + CloseWindow { + result: u32, + item_limit_category: u32, + }, + /// `13` — vmangos comments it is "handled with TRADE_STATUS_TRADE_CANCELED"; kept distinct so + /// the wire round-trips. + Unknown13, + /// `14` — the target has you on ignore. + IgnoreYou, + /// `15` — you are stunned. + YouStunned, + /// `16` — the target is stunned. + TargetStunned, + /// `17` — you are dead. + YouDead, + /// `18` — the target is dead. + TargetDead, + /// `19` — you are logging out. + YouLogout, + /// `20` — the target is logging out. + TargetLogout, + /// `21` — a trial account restriction. + TrialAccount, + /// `22` — "you can only trade conjured items…"; `slot` names the offending trade slot. + OnlyConjured { slot: u8 }, + /// A code outside 0..=22 (vmangos never sends one on 5875) — kept parseable, no tail read. + Unknown(u32), +} + +impl TradeStatus { + /// The raw `u32` code this status serializes as (the enum discriminant on the wire). + pub fn code(self) -> u32 { + match self { + TradeStatus::Busy => 0, + TradeStatus::BeginTrade { .. } => 1, + TradeStatus::OpenWindow => 2, + TradeStatus::Canceled => 3, + TradeStatus::Accept => 4, + TradeStatus::Busy2 => 5, + TradeStatus::NoTarget => 6, + TradeStatus::BackToTrade => 7, + TradeStatus::Complete => 8, + TradeStatus::Rejected => 9, + TradeStatus::TargetTooFar => 10, + TradeStatus::WrongFaction => 11, + TradeStatus::CloseWindow { .. } => 12, + TradeStatus::Unknown13 => 13, + TradeStatus::IgnoreYou => 14, + TradeStatus::YouStunned => 15, + TradeStatus::TargetStunned => 16, + TradeStatus::YouDead => 17, + TradeStatus::TargetDead => 18, + TradeStatus::YouLogout => 19, + TradeStatus::TargetLogout => 20, + TradeStatus::TrialAccount => 21, + TradeStatus::OnlyConjured { .. } => 22, + TradeStatus::Unknown(code) => code, + } + } +} + +/// One item shown in a trade slot, as it rides `SMSG_TRADE_STATUS_EXTENDED`'s fixed 60-byte +/// (15×u32) per-slot block (VERIFIED vmangos `WorldSession::SendUpdateTrade`). An empty slot is +/// all-zero on the wire and folds to `None` rather than a zeroed `Some` (the `entry == 0` signal, +/// same convention as [`super::MailAttachment`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TradeItem { + /// `Item.dbc` entry (`OBJECT_FIELD_ENTRY`) — the key to name/icon via `ITEM_QUERY_SINGLE`. + pub entry: u32, + pub display_id: u32, + pub count: u32, + /// A wrapped gift: the client hides stats and shows the gift-creator name. + pub wrapped: bool, + pub gift_creator: u64, + pub perm_enchant: u32, + pub creator: u64, + /// Spell charges — signed (a negative count means "N uses left" on some items). + pub charges: i32, + pub suffix_factor: u32, + pub random_prop_id: u32, + pub lock_id: u32, + pub max_durability: u32, + pub durability: u32, +} + +/// A decoded `SMSG_TRADE_STATUS_EXTENDED` — the full item/gold snapshot for **one** window side. +/// The server pushes one of these per side whenever that side's offer changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TradeStatusExtended { + /// `true` = the *partner's* (recipient / right) column, `false` = our own (left) column + /// (vmangos's `trader_state`: `1` for the trader's data, `0` for ours). + pub their_window: bool, + /// Gold offered on this side, in copper. + pub gold: u32, + /// The spell applied to this side's non-traded slot item (`0` = none) — the enchant slot. + pub enchant_spell_id: u32, + /// The seven slots (0..[`TRADE_SLOT_COUNT`]); `None` = empty. + pub slots: [Option; TRADE_SLOT_COUNT], +} + +// ── Client → server bodies (what vmangos `ReadFromWorldPacket` consumes) ───────────────────── + +/// Body of `CMSG_INITIATE_TRADE` (vmangos `InitiateTrade::ReadFromWorldPacket`): one full 8-byte +/// target-player guid. The server answers the initiator on any refusal (`SMSG_TRADE_STATUS`) and, +/// on success, sends the *target* `BeginTrade`. +pub fn initiate_trade(target: u64) -> Vec { + target.to_le_bytes().to_vec() +} + +/// Body of `CMSG_ACCEPT_TRADE` (vmangos `AcceptTrade::ReadFromWorldPacket`): one `u32` the server +/// **read-skips**. The real client sends `1` once it has seen `OPEN_WINDOW`; we send `1` too (the +/// value is discarded either way). +pub fn accept_trade() -> Vec { + 1u32.to_le_bytes().to_vec() +} + +/// Body of `CMSG_SET_TRADE_ITEM` (vmangos `SetTradeItem::ReadFromWorldPacket`): `u8 tradeSlot, +/// u8 bag, u8 slot` — put the item at inventory (`bag`, `slot`) into trade slot `tradeSlot`. +pub fn set_trade_item(trade_slot: u8, bag: u8, slot: u8) -> Vec { + vec![trade_slot, bag, slot] +} + +/// Body of `CMSG_CLEAR_TRADE_ITEM` (vmangos `ClearTradeItem::ReadFromWorldPacket`): `u8 tradeSlot`. +pub fn clear_trade_item(trade_slot: u8) -> Vec { + vec![trade_slot] +} + +/// Body of `CMSG_SET_TRADE_GOLD` (vmangos `SetTradeGold::ReadFromWorldPacket`): `u32 copper`. +pub fn set_trade_gold(copper: u32) -> Vec { + copper.to_le_bytes().to_vec() +} + +// `CMSG_BEGIN_TRADE`, `CMSG_BUSY_TRADE`, `CMSG_IGNORE_TRADE`, `CMSG_UNACCEPT_TRADE` and +// `CMSG_CANCEL_TRADE` carry **empty** bodies (vmangos reads them as `NullClientPacket`); the +// writer sends `&[]` directly, so they need no builder here. + +// ── Server → client parses (what vmangos `AppendBodyTo` / `SendUpdateTrade` emit) ───────────── + +/// Read `SMSG_TRADE_STATUS` (VERIFIED vmangos `TradeStatus::AppendBodyTo`): `u32 status`, then a +/// per-status tail — `BEGIN_TRADE → u64 partnerGuid`; `CLOSE_WINDOW → u32 result, u8 unk, u32 +/// itemLimitCategory`; `ONLY_CONJURED → u8 slot`; every other status has none. +pub(super) fn read_trade_status(r: &mut &[u8]) -> io::Result { + let code = read_u32_le(r)?; + Ok(match code { + 0 => TradeStatus::Busy, + 1 => TradeStatus::BeginTrade { + partner: read_u64_le(r)?, + }, + 2 => TradeStatus::OpenWindow, + 3 => TradeStatus::Canceled, + 4 => TradeStatus::Accept, + 5 => TradeStatus::Busy2, + 6 => TradeStatus::NoTarget, + 7 => TradeStatus::BackToTrade, + 8 => TradeStatus::Complete, + 9 => TradeStatus::Rejected, + 10 => TradeStatus::TargetTooFar, + 11 => TradeStatus::WrongFaction, + 12 => { + let result = read_u32_le(r)?; + let _unk = read_u8(r)?; // vmangos writes it; carries nothing for player trade + let item_limit_category = read_u32_le(r)?; + TradeStatus::CloseWindow { + result, + item_limit_category, + } + } + 13 => TradeStatus::Unknown13, + 14 => TradeStatus::IgnoreYou, + 15 => TradeStatus::YouStunned, + 16 => TradeStatus::TargetStunned, + 17 => TradeStatus::YouDead, + 18 => TradeStatus::TargetDead, + 19 => TradeStatus::YouLogout, + 20 => TradeStatus::TargetLogout, + 21 => TradeStatus::TrialAccount, + 22 => TradeStatus::OnlyConjured { slot: read_u8(r)? }, + other => TradeStatus::Unknown(other), + }) +} + +/// Read `SMSG_TRADE_STATUS_EXTENDED` (VERIFIED vmangos `WorldSession::SendUpdateTrade`): +/// `u8 which`, `u32 slotCount`, `u32 slotCount` (again — the two counts match), `u32 gold`, +/// `u32 enchantSpellId`, then [`TRADE_SLOT_COUNT`] records of `u8 slotIndex` + a fixed 60-byte +/// (15×u32) item block (all-zero → the slot is empty). The `slotIndex` byte places the block, so +/// a block is stored at `slots[slotIndex]` (defensive against reordering; vmangos writes 0..7 in +/// order). +pub(super) fn read_trade_status_extended(r: &mut &[u8]) -> io::Result { + let which = read_u8(r)?; + let _slot_count_a = read_u32_le(r)?; + let _slot_count_b = read_u32_le(r)?; + let gold = read_u32_le(r)?; + let enchant_spell_id = read_u32_le(r)?; + + let mut slots: [Option; TRADE_SLOT_COUNT] = [None; TRADE_SLOT_COUNT]; + for _ in 0..TRADE_SLOT_COUNT { + let index = read_u8(r)? as usize; + let entry = read_u32_le(r)?; + let display_id = read_u32_le(r)?; + let count = read_u32_le(r)?; + let wrapped = read_u32_le(r)? != 0; + let gift_creator = read_u64_le(r)?; + let perm_enchant = read_u32_le(r)?; + let creator = read_u64_le(r)?; + let charges = read_i32_le(r)?; + let suffix_factor = read_u32_le(r)?; + let random_prop_id = read_u32_le(r)?; + let lock_id = read_u32_le(r)?; + let max_durability = read_u32_le(r)?; + let durability = read_u32_le(r)?; + if entry != 0 && index < TRADE_SLOT_COUNT { + slots[index] = Some(TradeItem { + entry, + display_id, + count, + wrapped, + gift_creator, + perm_enchant, + creator, + charges, + suffix_factor, + random_prop_id, + lock_id, + max_durability, + durability, + }); + } + } + Ok(TradeStatusExtended { + their_window: which == 1, + gold, + enchant_spell_id, + slots, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── CMSG body goldens (byte-exact against vmangos's ReadFromWorldPacket layout) ── + + #[test] + fn initiate_trade_is_the_target_guid_le() { + assert_eq!( + initiate_trade(0x1122_3344_5566_7788), + vec![0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11] + ); + } + + #[test] + fn accept_trade_is_u32_one() { + assert_eq!(accept_trade(), vec![1, 0, 0, 0]); + } + + #[test] + fn set_trade_item_is_three_u8s() { + // Put bag 0 / slot 23 into trade slot 2. + assert_eq!(set_trade_item(2, 0, 23), vec![2, 0, 23]); + } + + #[test] + fn clear_trade_item_is_one_u8() { + assert_eq!(clear_trade_item(5), vec![5]); + } + + #[test] + fn set_trade_gold_is_u32_le() { + assert_eq!(set_trade_gold(0x0001_E240), vec![0x40, 0xE2, 0x01, 0x00]); + } + + // ── SMSG_TRADE_STATUS parse goldens ── + + #[test] + fn trade_status_bare_code_has_no_tail() { + let buf = 2u32.to_le_bytes(); // OPEN_WINDOW + let mut r = &buf[..]; + assert_eq!(read_trade_status(&mut r).unwrap(), TradeStatus::OpenWindow); + assert!(r.is_empty(), "no tail should be consumed for a bare status"); + } + + #[test] + fn trade_status_begin_trade_reads_the_partner_guid() { + let mut buf = Vec::new(); + buf.extend_from_slice(&1u32.to_le_bytes()); // BEGIN_TRADE + buf.extend_from_slice(&0xDEAD_BEEF_0000_0001u64.to_le_bytes()); + let mut r = &buf[..]; + assert_eq!( + read_trade_status(&mut r).unwrap(), + TradeStatus::BeginTrade { + partner: 0xDEAD_BEEF_0000_0001 + } + ); + assert!(r.is_empty()); + } + + #[test] + fn trade_status_close_window_consumes_u32_u8_u32() { + let mut buf = Vec::new(); + buf.extend_from_slice(&12u32.to_le_bytes()); // CLOSE_WINDOW + buf.extend_from_slice(&7u32.to_le_bytes()); // result + buf.push(0); // unk (dropped) + buf.extend_from_slice(&3u32.to_le_bytes()); // itemLimitCategory + let mut r = &buf[..]; + assert_eq!( + read_trade_status(&mut r).unwrap(), + TradeStatus::CloseWindow { + result: 7, + item_limit_category: 3 + } + ); + assert!(r.is_empty(), "the full u32+u8+u32 tail must be consumed"); + } + + #[test] + fn trade_status_only_conjured_reads_the_slot_byte() { + let mut buf = Vec::new(); + buf.extend_from_slice(&22u32.to_le_bytes()); // ONLY_CONJURED + buf.push(4); // slot + let mut r = &buf[..]; + assert_eq!( + read_trade_status(&mut r).unwrap(), + TradeStatus::OnlyConjured { slot: 4 } + ); + assert!(r.is_empty()); + } + + #[test] + fn trade_status_out_of_range_is_unknown_no_tail() { + let buf = 99u32.to_le_bytes(); + let mut r = &buf[..]; + assert_eq!(read_trade_status(&mut r).unwrap(), TradeStatus::Unknown(99)); + assert!(r.is_empty()); + } + + // ── SMSG_TRADE_STATUS_EXTENDED parse golden ── + + /// Build a full 444-byte snapshot the way vmangos does: a 17-byte header, then 7 slot records + /// (each a `u8 index` + 60-byte block). `items[i] = Some(entry)` fills slot `i`; `None` zeroes it. + fn extended_wire( + which: u8, + gold: u32, + spell: u32, + entries: [Option; TRADE_SLOT_COUNT], + ) -> Vec { + let mut b = Vec::new(); + b.push(which); + b.extend_from_slice(&(TRADE_SLOT_COUNT as u32).to_le_bytes()); + b.extend_from_slice(&(TRADE_SLOT_COUNT as u32).to_le_bytes()); + b.extend_from_slice(&gold.to_le_bytes()); + b.extend_from_slice(&spell.to_le_bytes()); + for (i, entry) in entries.iter().enumerate() { + b.push(i as u8); + match entry { + Some(e) => { + b.extend_from_slice(&e.to_le_bytes()); // entry + b.extend_from_slice(&11u32.to_le_bytes()); // display_id + b.extend_from_slice(&5u32.to_le_bytes()); // count + b.extend_from_slice(&0u32.to_le_bytes()); // wrapped + b.extend_from_slice(&0u64.to_le_bytes()); // gift_creator + b.extend_from_slice(&0u32.to_le_bytes()); // perm_enchant + b.extend_from_slice(&0u64.to_le_bytes()); // creator + b.extend_from_slice(&(-3i32).to_le_bytes()); // charges (signed) + b.extend_from_slice(&0u32.to_le_bytes()); // suffix_factor + b.extend_from_slice(&0u32.to_le_bytes()); // random_prop_id + b.extend_from_slice(&0u32.to_le_bytes()); // lock_id + b.extend_from_slice(&100u32.to_le_bytes()); // max_durability + b.extend_from_slice(&80u32.to_le_bytes()); // durability + } + None => b.extend_from_slice(&[0u8; 60]), + } + } + b + } + + #[test] + fn extended_snapshot_is_always_444_bytes() { + // 17-byte header (u8 which + 4×u32) + 7 slot records of (u8 index + 60-byte block). + let wire = extended_wire(0, 0, 0, [None; TRADE_SLOT_COUNT]); + assert_eq!(wire.len(), 17 + TRADE_SLOT_COUNT * 61); + assert_eq!(wire.len(), 444); + } + + #[test] + fn extended_parses_header_slots_and_signed_charges() { + let mut entries = [None; TRADE_SLOT_COUNT]; + entries[0] = Some(0xABCD); + entries[TRADE_SLOT_NONTRADED] = Some(0x1111); // the enchant slot carries an item too + let wire = extended_wire(1, 12_345, 777, entries); + let mut r = &wire[..]; + let ext = read_trade_status_extended(&mut r).unwrap(); + assert!(r.is_empty(), "the whole snapshot must be consumed"); + + assert!(ext.their_window); + assert_eq!(ext.gold, 12_345); + assert_eq!(ext.enchant_spell_id, 777); + + let slot0 = ext.slots[0].expect("slot 0 filled"); + assert_eq!(slot0.entry, 0xABCD); + assert_eq!(slot0.display_id, 11); + assert_eq!(slot0.count, 5); + assert_eq!(slot0.charges, -3); + assert_eq!(slot0.max_durability, 100); + assert_eq!(slot0.durability, 80); + + assert!( + ext.slots[TRADE_SLOT_NONTRADED].is_some(), + "enchant slot parsed" + ); + assert!(ext.slots[1].is_none(), "empty slot folds to None"); + assert!(ext.slots[5].is_none()); + } + + #[test] + fn extended_own_window_flag() { + let wire = extended_wire(0, 0, 0, [None; TRADE_SLOT_COUNT]); + let mut r = &wire[..]; + let ext = read_trade_status_extended(&mut r).unwrap(); + assert!(!ext.their_window, "which == 0 is our own column"); + } +} diff --git a/crates/benilla-protocol/src/messages/trainer.rs b/crates/benilla-protocol/src/messages/trainer.rs new file mode 100644 index 00000000..39cde4ed --- /dev/null +++ b/crates/benilla-protocol/src/messages/trainer.rs @@ -0,0 +1,129 @@ +//! Trainer messages — the class/profession trainer window's wire (opcodes 432-436, vmangos +//! `Opcodes_1_12_1.h:433-437`, VERIFIED). Bodies from vmangos `NPCHandler.cpp` (`SendTrainerList` + +//! its per-service helper `SendTrainerSpellHelper`) and the hand-serialized `Server/Packets/Npc.cpp` +//! (the `TrainerList`/`TrainerBuySpell`/`TrainerBuySucceeded`/`TrainerBuyFailed` structs). One frame +//! (`ClassTrainerFrame`) serves both class and tradeskill trainers; the window is reached through the +//! gossip trainer option (`GOSSIP_OPTION_TRAINER`, [`super::gossip`]), not a trainer-specific open +//! verb — so this arc is the receive side plus the two send verbs (decision 0237). + +use std::io; + +use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8}; + +/// One trainer service (`SMSG_TRAINER_LIST`, vmangos `SendTrainerSpellHelper`, +/// `NPCHandler.cpp:97-139`) — a spell or tradeskill step the trainer can teach. The 38-byte wire +/// record; the list is **pre-filtered server-side** to services fitting the player's class/race, so +/// what arrives is already the player's own menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TrainerSpell { + /// The service spell id — what `CMSG_TRAINER_BUY_SPELL` names to purchase it (see + /// [`trainer_buy_spell`]). + pub spell: u32, + /// A [`trainer_spell_state`] byte: the green/red/gray colouring the client renders (it does not + /// recompute it). `GREEN_DISABLED` (server-internal, 10) never rides the wire — it is sent as + /// `GREEN`. + pub state: u8, + /// Cost in copper, already reputation-discounted server-side. + pub cost: u32, + /// Wire field 4 (`primary_prof_first_rank && can_learn_primary_prof`): the client enables the + /// "Learn" button only when this equals [`is_primary_prof_first_rank`] — so a primary-profession + /// first rank you *can't* take (already holding two professions) arrives as `false` here with + /// `is_primary_prof_first_rank == true`, greying the button. + pub can_learn_primary_prof: bool, + /// Wire field 5 (`primary_prof_first_rank`): this service is a first-rank primary profession, so + /// taking it spends a profession slot (drives the client's confirmation dialog). + pub is_primary_prof_first_rank: bool, + /// Character level required to learn (`spellLevel`). + pub req_level: u8, + /// Required `SkillLine.dbc` id (0 = none) — e.g. the tradeskill this step belongs to. + pub req_skill: u32, + /// Required skill points in [`req_skill`] (0 = none). + pub req_skill_value: u32, + /// Up to three prerequisite spell ids (`SpellChainNode` req/prev + a trailing slot). The trailing + /// slot is structurally always 0 on 5875; kept as an array to match the client's own + /// `GetTrainerServiceAbilityReq(i)` model. + pub req_spells: [u32; 3], +} + +/// `TrainerSpellState` (vmangos `Player.h:119-122`) — the service `state` byte. `GREEN_DISABLED` (10) +/// is server-internal and is sent on the wire as `GREEN`, so only these three ever arrive. +pub mod trainer_spell_state { + /// Learnable now. + pub const GREEN: u8 = 0; + /// Requirements unmet (level / skill / prerequisite). + pub const RED: u8 = 1; + /// Already known. + pub const GRAY: u8 = 2; +} + +/// The `errorCode` on `SMSG_TRAINER_BUY_FAILED` (vmangos `SharedDefines.h:1120-1122`, +/// `TrainerServiceType`-adjacent `TRAIN_FAIL_*`). A `u32` on the wire. +pub mod train_fail { + /// Trainer service unavailable (not a trainer of yours, out of LOS, service not in the list). + pub const UNAVAILABLE: u32 = 0; + /// Not enough money. + pub const NOT_ENOUGH_MONEY: u32 = 1; + /// Not enough skill points. + pub const NOT_ENOUGH_SKILL: u32 = 2; +} + +/// Body of `CMSG_TRAINER_LIST` (vmangos `Npc.cpp`, `TrainerList::ReadFromWorldPacket`): one full +/// 8-byte trainer guid. Requests (and, after a purchase, re-requests to repaint green→gray) the +/// service list — the *refresh* verb, not the open verb: the window first opens off the gossip +/// trainer option's `SMSG_TRAINER_LIST` (decision 0237). +pub fn trainer_list(trainer_guid: u64) -> Vec { + trainer_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_TRAINER_BUY_SPELL` (vmangos `Npc.cpp`, `TrainerBuySpell::ReadFromWorldPacket`): +/// `u64 trainerGuid, u32 spellId` (the [`TrainerSpell::spell`] of the chosen service). The server +/// answers `SMSG_TRAINER_BUY_SUCCEEDED` (+ the learned spell via `SMSG_LEARNED_SPELL`) or +/// `SMSG_TRAINER_BUY_FAILED`. +pub fn trainer_buy_spell(trainer_guid: u64, spell_id: u32) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&trainer_guid.to_le_bytes()); + body.extend_from_slice(&spell_id.to_le_bytes()); + body +} + +/// Read `SMSG_TRAINER_LIST` (vmangos `WorldSession::SendTrainerList`, `NPCHandler.cpp:141-241`): +/// `u64 trainerGuid, u32 trainerType, u32 count, count ×` [`TrainerSpell`] (38 bytes each)`, cstr +/// title`. `trainerType` is the window-framing kind (0 class · 1 mount · 2 tradeskill · 3 pet); the +/// title is the greeting line. Returns `(trainer, trainer_type, services, title)`. +pub(super) fn read_trainer_list( + r: &mut &[u8], +) -> io::Result<(u64, u32, Vec, String)> { + let trainer = read_u64_le(r)?; + let trainer_type = read_u32_le(r)?; + let count = read_u32_le(r)?; + let mut services = Vec::with_capacity(count as usize); + for _ in 0..count { + // Struct-literal fields evaluate top-to-bottom, so this reads in wire order. + services.push(TrainerSpell { + spell: read_u32_le(r)?, + state: read_u8(r)?, + cost: read_u32_le(r)?, + can_learn_primary_prof: read_u32_le(r)? != 0, + is_primary_prof_first_rank: read_u32_le(r)? != 0, + req_level: read_u8(r)?, + req_skill: read_u32_le(r)?, + req_skill_value: read_u32_le(r)?, + req_spells: [read_u32_le(r)?, read_u32_le(r)?, read_u32_le(r)?], + }); + } + let title = read_cstring(r)?; + Ok((trainer, trainer_type, services, title)) +} + +/// Read `SMSG_TRAINER_BUY_SUCCEEDED` (vmangos `TrainerBuySucceeded::AppendBodyTo`): `u64 +/// trainerGuid, u32 spellId`. Confirmation + sound only — the learned spell itself lands via +/// `SMSG_LEARNED_SPELL`, and the green→gray repaint needs a `CMSG_TRAINER_LIST` re-request. +pub(super) fn read_trainer_buy_succeeded(r: &mut &[u8]) -> io::Result<(u64, u32)> { + Ok((read_u64_le(r)?, read_u32_le(r)?)) +} + +/// Read `SMSG_TRAINER_BUY_FAILED` (vmangos `TrainerBuyFailed::AppendBodyTo`): `u64 trainerGuid, u32 +/// serviceId (the spell), u32 errorCode` (a [`train_fail`] code). +pub(super) fn read_trainer_buy_failed(r: &mut &[u8]) -> io::Result<(u64, u32, u32)> { + Ok((read_u64_le(r)?, read_u32_le(r)?, read_u32_le(r)?)) +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/mod.rs b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs new file mode 100644 index 00000000..e25cab51 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/fields/mod.rs @@ -0,0 +1,521 @@ +use std::collections::BTreeMap; +use std::io::{self, Read}; + +use crate::wire::{read_u32_le, read_u8, Vector3d}; + +use super::movement::ObjectType; + +// ObjectFields descriptor-field indices (build 5875), shared across object types. +const FIELD_OBJECT_TYPE: u16 = 2; +const FIELD_OBJECT_SCALE_X: u16 = 4; +const FIELD_GAMEOBJECT_DISPLAYID: u16 = 8; +const FIELD_GAMEOBJECT_FLAGS: u16 = 9; +// `GAMEOBJECT_ROTATION` = OBJECT_END(6) + 0x4 .. +0x7 (vmangos `UpdateFields_1_12_1.h`): the +// spawn's local-rotation quaternion (x, y, z, w), 4 floats. The type-11 transport evaluator +// rotates its keyframe offsets by this (decision 0438 phase 3's second consumer). +const FIELD_GAMEOBJECT_ROTATION: u16 = 10; +const FIELD_GAMEOBJECT_STATE: u16 = 14; +const FIELD_GAMEOBJECT_POS_X: u16 = 15; +const FIELD_GAMEOBJECT_POS_Y: u16 = 16; +const FIELD_GAMEOBJECT_POS_Z: u16 = 17; +const FIELD_GAMEOBJECT_FACING: u16 = 18; +// `OBJECT_END + 0x34` bytes ⇒ `OBJECT_END(6) + field 13` = 19 (wow-re cursor-system §4a byte offset; +// vmangos' `UpdateFields_1_5_1.h` inserts a stray `GAMEOBJECT_TIMESTAMP` that would shift this to 20, +// but that header is not the 5875 layout — STATE@14 / TYPE_ID@21 both work against this same server). +const FIELD_GAMEOBJECT_DYN_FLAGS: u16 = 19; +const FIELD_GAMEOBJECT_TYPE_ID: u16 = 21; +// GAMEOBJECT_LEVEL = OBJECT_END(6) + 0x10 (vmangos UpdateFields_1_12_1.h:317). +const FIELD_GAMEOBJECT_LEVEL: u16 = 22; +// UNIT descriptor fields (verified against wow-5875-re object-layer node, build 5875; FLAGS / +// COMBATREACH / DYNAMIC_FLAGS / NPC_FLAGS additionally cross-checked against the cursor RE's client +// offsets `+0xa0`/`+0x1f0`/`+0x224`/`+0x234` and vmangos `UpdateFields_1_12_1.h`). +/// `UNIT_FIELD_TARGET` (idx 16, a 2-field guid) — who the unit is attacking/interacting with. The +/// real client turns an idle unit's body to *face* this target (there is no wire facing for a +/// stationary re-face — vmangos `SetInFront` is server-only); benilla mirrors that (`face_target`). +const FIELD_UNIT_TARGET: u16 = 16; +/// `UNIT_FIELD_SUMMONEDBY` (idx 12, `OBJECT_END(6) + 0x6`, a 2-field guid — vmangos +/// `UpdateFields_1_12_1.h`) — the summoner of a pet/guardian/totem. With CREATEDBY below, the +/// combat-text source-ownership classifier's "owned by me" test (wow-re `0x5efea0`). +const FIELD_UNIT_SUMMONEDBY: u16 = 12; +/// `UNIT_FIELD_CREATEDBY` (idx 14, a 2-field guid) — the creator (totems, summoned objects). +const FIELD_UNIT_CREATEDBY: u16 = 14; +/// `UNIT_FIELD_CHANNEL_OBJECT` (idx 20, `OBJECT_END(6) + 0xE`, a 2-field guid, PUBLIC — VERIFIED +/// vmangos `UpdateFields_1_12_1.h:48`) — the object a channeled spell is aimed at (may be the +/// caster itself). Public, so an **observer** renders another unit's channel loop from this field +/// pair, not from the self-only `MSG_CHANNEL_START`/`UPDATE` UI packets (decision 0099 phase 1). +const FIELD_UNIT_CHANNEL_OBJECT: u16 = 20; +const FIELD_UNIT_HEALTH: u16 = 22; +/// `UNIT_FIELD_POWER1` — first of 5 consecutive power slots (mana, rage, focus, energy, happiness); +/// `MAXPOWER1..5` sit symmetrically after `MAXHEALTH` (vmangos `UpdateFields_1_12_1.h`). +const FIELD_UNIT_POWER1: u16 = 23; +const FIELD_UNIT_MAXHEALTH: u16 = 28; +const FIELD_UNIT_MAXPOWER1: u16 = 29; +const FIELD_UNIT_LEVEL: u16 = 34; +const FIELD_UNIT_FACTIONTEMPLATE: u16 = 35; +const FIELD_UNIT_BYTES_0: u16 = 36; +const FIELD_UNIT_BYTES_1: u16 = 138; +const FIELD_UNIT_FLAGS: u16 = 46; +// The aura block — four parallel arrays, all PUBLIC, so every unit we can see streams its full set +// (vmangos `UpdateFields_1_12_1.h:67-70`, `OBJECT_END(6) + 0x29/0x59/0x5F/0x6B`). wow-re places the +// same arrays in the client at `descriptor + 0xa4` / `+0x164` (= `0x29`/`0x59` × 4 — the same field +// indices, derived independently from the binary: `object-layer/scratch/w2d1.md`, VERIFIED). +// Duration and caster are NOT here and are not in any other field: duration reaches only the aura's +// own target, over `SMSG_UPDATE_AURA_DURATION`; a caster guid never reaches anyone (decision 0255). +const FIELD_UNIT_AURA: u16 = 47; +/// Nibble-packed, 8 slots per `u32` (`SpellAuraHolder::SetAuraFlag`, `SpellAuras.cpp:7456-7462`). +const FIELD_UNIT_AURAFLAGS: u16 = 95; +/// Byte-packed, 4 slots per `u32` — the *caster's level* (`SetAuraLevel`, `SpellAuras.cpp:7484`). +const FIELD_UNIT_AURALEVELS: u16 = 101; +/// Byte-packed, 4 slots per `u32`, holding `stack - 1` ("field expect count-1 for proper amount +/// show" — `UpdateAuraApplication`, `SpellAuras.cpp:7500-7507`). +const FIELD_UNIT_AURAAPPLICATIONS: u16 = 113; +/// `UNIT_FIELD_AURASTATE` (`OBJECT_END(6) + 0x77` = 125 — vmangos `UpdateFields_1_12_1.h`) — the +/// aura-state bit set the usable walk's CasterAuraState/TargetAuraState legs test with +/// `1 << (state-1)` (wow-re §2a; the client reads its parsed-descriptor copy at +/// `[unit+0x110]+0x1dc`, the update-field identity corroborated by the bit-test semantics + +/// vmangos `Unit::ModifyAuraState`). +const FIELD_UNIT_AURASTATE: u16 = 125; +/// `UNIT_FIELD_BOUNDINGRADIUS` (`OBJECT_END(6) + 0x7B` = 129, right before COMBATREACH — vmangos +/// `UpdateFields_1_12_1.h`) — the unit's horizontal bounding radius (yd). +const FIELD_UNIT_BOUNDINGRADIUS: u16 = 129; +const FIELD_UNIT_COMBATREACH: u16 = 130; +const FIELD_UNIT_BASE_MANA: u16 = 162; +const FIELD_UNIT_DISPLAYID: u16 = 131; +/// `UNIT_FIELD_MOUNTDISPLAYID` (`OBJECT_END(6) + 0x7F` = 133, PUBLIC — vmangos +/// `UpdateFields_1_12_1.h:78`; byte-verified client-side at `[unit+0x110]+0x1fc` = index 0x85, +/// wow-re `rf39-field-index-map.md`/`d1.md`, HARD). A raw `CreatureDisplayInfo` id: the mount the +/// unit is riding, `0`/absent = not mounted. The server writes it on aura-78 apply and zeroes it +/// on every dismount path (`Unit::Mount`/`Unmount`) — this field, not the aura, IS the mounted +/// state (`Unit::IsMounted` reads it back; no flag or bytes field accompanies it). Decision 0441. +const FIELD_UNIT_MOUNTDISPLAYID: u16 = 133; +const FIELD_UNIT_DYNAMIC_FLAGS: u16 = 143; +/// `UNIT_CHANNEL_SPELL` (idx 144, `OBJECT_END(6) + 0x8A`, PUBLIC — VERIFIED vmangos +/// `UpdateFields_1_12_1.h:89`) — the spell id a unit is channeling (`0` = not channeling). +const FIELD_UNIT_CHANNEL_SPELL: u16 = 144; +const FIELD_UNIT_NPC_FLAGS: u16 = 147; +/// `UNIT_NPC_EMOTESTATE` (`OBJECT_END + 0x8E`; PUBLIC — VERIFIED vmangos `UpdateFields_1_12_1.h:93`) +/// — the unit's looping **state emote** as an `Emotes.dbc` id (dance, NPC cooking/working flavor). +/// For a player it's the server echo of a state-class text emote (`HandleEmote`, +/// `ChatHandler.cpp:738`); `0` = none. +const FIELD_UNIT_NPC_EMOTESTATE: u16 = 148; +/// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY` (`OBJECT_END(6) + 0x1F`) — 3 consecutive dwords, one per +/// `WeaponAttackType` slot (0 mainhand / 1 offhand / 2 ranged): a creature's virtual-weapon +/// **display id**, written straight from `creature_equip_template`'s item's `DisplayInfoID` +/// (VERIFIED vmangos `Creature.cpp` `Creature::SetVirtualItem`, `Creature.cpp:4158-4181`) — there's +/// no item guid behind it, purely a render hint the client never resolves through the item system. +const FIELD_UNIT_VIRTUAL_ITEM_SLOT_DISPLAY: u16 = 37; +/// `UNIT_VIRTUAL_ITEM_INFO` (`OBJECT_END + 0x22`) — 6 dwords, 2 per slot. Dword 0 of a slot's pair +/// packs `(class, subclass, material, inventoryType)` bytes (VERIFIED vmangos +/// `CreatureDefines.h:621-627` `VirtualItemInfoByteOffset`); dword 1's byte 0 is the slot's Sheath +/// (`CreatureDefines.h:628` `VIRTUAL_ITEM_INFO_1_OFFSET_SHEATH`), the rest of that dword unused. +const FIELD_UNIT_VIRTUAL_ITEM_INFO: u16 = 40; +/// `UNIT_FIELD_BYTES_2` (`OBJECT_END + 0x9E`; PUBLIC) — byte 0 is the sheath state (VERIFIED vmangos +/// `UnitDefines.h:93` `UNIT_BYTES_2_OFFSET_SHEATH_STATE = 0`, written by `Unit::SetSheath`; for a +/// player it's purely client-volunteered via `CMSG_SETSHEATHED` — the server has no other way to +/// know a weapon is drawn). **Not** [`FIELD_PLAYER_BYTES_2`] (194, facialHair) — an unrelated, +/// differently-indexed field; this UNIT-block index was cross-checked clean of the a783a5b +/// PLAYER-block hex-comment drift (vmangos's own enum arithmetic and its stale hex comment agree +/// here: `UNIT_END + 0x9E` both ways). +const FIELD_UNIT_BYTES_2: u16 = 164; +// UNIT combat/stat block (decision 0208 phase 1a — the paper-doll stats pane). VERIFIED against +// vmangos `UpdateFields_1_12_1.h` **enum arithmetic** this session (`OBJECT_END` = 6; unlike the +// CONTAINER-onward block below, this block's own `//0x..` hex comments agree with the arithmetic — +// no staleness here). Every index below chain-locks to the file's already-tested +// `FIELD_PLAYER_INV_SLOT_HEAD` (486) anchor through `UNIT_END` (188) — see the tests. +const FIELD_UNIT_BASEATTACKTIME: u16 = 126; // OBJECT_END+0x78; 2 slots [main, offhand], ms, INT +const FIELD_UNIT_RANGEDATTACKTIME: u16 = 128; // +0x7A, INT +const FIELD_UNIT_MINDAMAGE: u16 = 134; // +0x80, FLOAT +const FIELD_UNIT_MAXDAMAGE: u16 = 135; // +0x81, FLOAT +const FIELD_UNIT_MINOFFHANDDAMAGE: u16 = 136; // +0x82, FLOAT +const FIELD_UNIT_MAXOFFHANDDAMAGE: u16 = 137; // +0x83, FLOAT +const FIELD_UNIT_STAT0: u16 = 150; // +0x90 ×5, INT (`Unit::SetStat`→`SetStatInt32Value`) +const FIELD_UNIT_RESISTANCES: u16 = 155; // +0x95 ×7, INT; [0] = armor (`Unit::SetArmor` writes +0) +const FIELD_UNIT_ATTACK_POWER: u16 = 165; // +0x9F, INT +/// `UNIT_FIELD_ATTACK_POWER_MODS` (166, +0xA0, `TWO_SHORT`) — `(positiveMods, negativeMods)` as +/// **signed 16-bit halves**, lo = positive, hi = negative (VERIFIED vmangos +/// `StatSystem.cpp:335-336`, `Player::UpdateAttackPowerAndDamage`: `SetInt16Value(index_mod, 0, +/// (int16)attackPowerModPositive)` / `SetInt16Value(index_mod, 1, (int16)attackPowerModNegative)`; +/// `Object::SetUInt16Value`'s `offset*16` shift confirms offset 0 = the low half). The negative +/// half is stored **already negative-or-zero** — `Unit::HandleAttackPowerModifier`'s +/// `AP_MOD_NEGATIVE_FLAT` branch accumulates `apply ? amount : -amount`, and its caller +/// (`SpellAuras.cpp:5116`) only routes there when `!IsPositive()`, i.e. `amount` is already ≤ 0. +const FIELD_UNIT_ATTACK_POWER_MODS: u16 = 166; +const FIELD_UNIT_ATTACK_POWER_MULTIPLIER: u16 = 167; // +0xA1, FLOAT: (multiplier − 1.0), StatSystem.cpp:339-340 +const FIELD_UNIT_RANGED_ATTACK_POWER: u16 = 168; // +0xA2, INT +const FIELD_UNIT_RANGED_ATTACK_POWER_MODS: u16 = 169; // +0xA3, same TWO_SHORT packing as melee above +const FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER: u16 = 170; // +0xA4, FLOAT +const FIELD_UNIT_MINRANGEDDAMAGE: u16 = 171; // +0xA5, FLOAT +const FIELD_UNIT_MAXRANGEDDAMAGE: u16 = 172; // +0xA6, FLOAT + +/// Aura slots per unit (vmangos `MAX_AURAS`, `SpellAuraDefines.h:25`). +pub const UNIT_AURA_SLOTS: u8 = 48; +/// Slots `0..UNIT_AURA_POSITIVE_SLOTS` hold **buffs**; the rest hold **debuffs** (vmangos +/// `MAX_POSITIVE_AURAS`, `SpellAuraDefines.h:26`). The server picks the lowest free slot in the +/// matching half (`SpellAuraHolder::_AddSpellAuraHolder`, `SpellAuras.cpp:6715-6736`), and assigns +/// no slot at all to an aura that fails `IsNeedVisibleSlot` — so **passives never reach the wire** +/// and a client renders the array as-is, with no filtering of its own. +pub const UNIT_AURA_POSITIVE_SLOTS: u8 = 32; + +/// `AFLAG_CANCELABLE` (vmangos `SpellAuraDefines.h:31`) — the right-click gate. The server sets it +/// iff the aura is positive and its spell lacks `SPELL_ATTR_NO_AURA_CANCEL` (`SpellAuras.cpp:7467`), +/// which is exactly the condition `CMSG_CANCEL_AURA`'s handler re-checks, so honouring this bit is +/// enough — no `Spell.dbc` lookup needed to know whether a buff can be clicked off. +pub const AURA_FLAG_CANCELABLE: u8 = 0x01; +/// `AFLAG_EFF_INDEX_0|1|2` — which of the spell's three effects this holder actually applied. An +/// occupied slot always has at least one set, which is why the real client uses `flags & 0x0E` as +/// its liveness test (wow-re `object-layer/scratch/w2d1.md`), and why [`ObjectFields::unit_aura`] +/// gates on it too: a slot the server clears can keep a **stale spell id** after its flags nibble +/// goes to zero, so `flags & 0x0E` — not "has a spell id" — is what tells a live aura from a husk. +/// Gating on the id alone surfaces the husk as a phantom buff the real client never shows. +pub const AURA_FLAG_EFF_INDEX_MASK: u8 = 0x0E; + +/// One **occupied** aura slot, decoded from the four parallel `UNIT_FIELD_AURA*` arrays. Everything +/// the 1.12 wire says about a resident aura is here; note what is missing and cannot be added — the +/// **remaining duration** (self-only, and it arrives out-of-band on `SMSG_UPDATE_AURA_DURATION`) and +/// the **caster** (which no packet ever associates with a slot). Decision 0255. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnitAuraSlot { + /// The slot index it occupies, `0..UNIT_AURA_SLOTS`. Also its buff/debuff class, and the key + /// `SMSG_UPDATE_AURA_DURATION` uses. + pub slot: u8, + /// The `Spell.dbc` id. + pub spell_id: u32, + /// The raw `UNIT_FIELD_AURAFLAGS` nibble for this slot. + pub flags: u8, + /// The **caster's** level at apply time — the only thing the wire says about who cast this. + pub level: u8, + /// Stack count, ≥ 1 (the wire byte holds `stack - 1`). + pub stacks: u8, +} + +impl UnitAuraSlot { + /// A buff (slot in the positive half) rather than a debuff. + pub fn is_helpful(&self) -> bool { + self.slot < UNIT_AURA_POSITIVE_SLOTS + } + /// Whether the server will honour a `CMSG_CANCEL_AURA` for this aura. + pub fn is_cancelable(&self) -> bool { + self.flags & AURA_FLAG_CANCELABLE != 0 + } +} + +// PLAYER descriptor fields — player customization (RF-0056, byte-verified against the client's own +// appearance decode `0x5fb200` + corpse cross-check). The PLAYER block starts at field 188. +const FIELD_PLAYER_BYTES: u16 = 193; +const FIELD_PLAYER_BYTES_2: u16 = 194; +// PLAYER_QUEST_LOG_1_1 = UNIT_END(188) + 0xA = 198 (vmangos `UpdateFields_1_12_1.h:128`; this block +// precedes the CONTAINER-onward hex-comment drift — enum arithmetic and hex comment agree here, and +// decision 0088 §4 live-verified an accepted quest id landing on 198 + 3·slot). 20 slots +// (MAX_QUEST_LOG_SIZE, vmangos `QuestDef.h:34`) × 3 fields (MAX_QUEST_OFFSET, `Player.h:439-444`): +// +0 quest id, +1 packed objective counters + state byte, +2 timer. The `_1` id field is GROUP_ONLY +// and the `_2` pair PRIVATE — both always stream for our own player, the exact consumer (the log). +const FIELD_PLAYER_QUEST_LOG_1_1: u16 = 198; + +/// The number of `PLAYER_QUEST_LOG` slots (vmangos `MAX_QUEST_LOG_SIZE`, `QuestDef.h:34`). +pub const PLAYER_QUEST_LOG_SLOTS: u8 = 20; + +/// One decoded `PLAYER_QUEST_LOG` slot — the descriptor's durable quest-log state (the +/// `SMSG_QUESTUPDATE_*` packets are the transient "toast" lines; this is what survives). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QuestLogSlot { + /// The quest id occupying the slot (`0` = the server cleared it — an empty slot). + pub quest_id: u32, + /// The four 6-bit kill/cast/interact objective counters (counter `i` at bits `6i..6i+6` of the + /// count-state field — vmangos `Player.h:1100-1106` `SetQuestSlotCounter`). Item-objective + /// progress is *not* here; the client counts bag items itself. + pub counters: [u8; 4], + /// The state byte (byte 3 of the count-state field — `Player.h:1107` `SetQuestSlotState`): + /// [`quest_slot_state::COMPLETE`] / [`quest_slot_state::FAIL`], `0` = in progress. + pub state: u8, + /// The timer field — the absolute end time for a timed quest, else `0`. + pub timer: u32, +} + +/// `QUEST_STATE_*` (vmangos `Player.h:447-451`) — [`QuestLogSlot::state`] bit values. +pub mod quest_slot_state { + pub const COMPLETE: u8 = 0x01; + pub const FAIL: u8 = 0x02; +} +// ITEM/CONTAINER/PLAYER inventory fields (VERIFIED vmangos `UpdateFields_1_12_1.h` **enum +// arithmetic** — never its hex comments; see below) — the T2 container groundwork. The PLAYER +// slot arrays are PRIVATE-flagged: the server sends them only for our own player, which is +// exactly the consumer (our bags). Each slot is a 2-field guid. +const FIELD_ITEM_STACK_COUNT: u16 = 14; // OBJECT_END(6) + 0x8 (the ITEM-block comments are still true) +const FIELD_CONTAINER_NUM_SLOTS: u16 = 48; // ITEM_END = 6 + 0x2A (comment says 42 — stale, 6 low) +const FIELD_CONTAINER_SLOT_1: u16 = 50; // ITEM_END + 0x2; 36 slots × 2 + +// CORRECTED 2026-07-03: vmangos's UpdateFields_1_12_1.h hex COMMENTS go stale from the CONTAINER +// block on (6 low vs its own enum arithmetic, which is what the server compiles): INV_SLOT_HEAD = +// UNIT_END(0xBC) + 0x12A = 0x1E6 = 486, not the commented 0x1E0. VERIFIED against a live +// descriptor dump: with these bases One's shirt/legs/feet/mainhand/offhand/bags land on exactly +// the slots the character visibly wears (visible-item cross-check below). +const FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR: u16 = 258; // UNIT_END + 0x46; 12 fields per slot +const FIELD_PLAYER_INV_SLOT_HEAD: u16 = 486; // 23 slots × 2 (equipment 0–18, bag bags 19–22) +const FIELD_PLAYER_PACK_SLOT_1: u16 = 532; // 16 slots × 2 (the backpack) +const FIELD_PLAYER_BANK_SLOT_1: u16 = 564; // 24 slots × 2 +const FIELD_PLAYER_BANK_BAG_SLOT_1: u16 = 612; // 564 + 24×2; 6 bag slots × 2 (item guids) + // The buyback trio (VERIFIED two ways: the slot array is chain-locked between the tested BANK + // anchor and the tested XP anchor — 564 +48 bank = 612 bankbags, +12 = 624 buyback, +24 keyring + // 648, +64 farsight 712, +2 combo 714, +2 = XP 716 ✓ — and price/timestamp are wow-re byte + // anchors (ui/scratch/buyback-data-path.md: playerBlock +0x1038/+0x1068) rebased through the + // tested COINAGE anchor: block base = 1176 − 0xf70/4 = 188 → 188+1038 = 1226, 188+1050 = 1238). +const FIELD_PLAYER_VENDORBUYBACK_SLOT_1: u16 = 624; // 12 slots × 2 (item guids) +const FIELD_PLAYER_BUYBACK_PRICE_1: u16 = 1226; // 12 × u32 copper, indexed slot−69 +const FIELD_PLAYER_BUYBACK_TIMESTAMP_1: u16 = 1238; // 12 × u32 — the client's sort key only + // PLAYER_FIELD_COINAGE, our purse in copper (INT, PRIVATE — sent only for our own player, the exact + // consumer). Same comment drift as the slot arrays above: from the CONTAINER block on, vmangos's + // UpdateFields_1_12_1.h hex comments run 6 low vs its own enum arithmetic (which is what the server + // compiles). Derived by that arithmetic: COINAGE = UNIT_END(0xBC = 188) + 0x3DC(988) = 1176; the + // stale hex comment reads 0x492 = 1170 (decision 0081 quoted "field 1170" from it — 6 low). + // Cross-checked against the corrected neighbours: 486 (INV_SLOT_HEAD) + (0x3DC − 0x12A = 690) = 1176; + // 564 (BANK_SLOT_1) + (0x3DC − 0x178 = 612) = 1176. Both agree. +const FIELD_PLAYER_FIELD_COINAGE: u16 = 1176; +// PLAYER_XP / PLAYER_NEXT_LEVEL_XP (INT, PRIVATE — sent only for our own player, the exact consumer: +// the MainMenuBar XP bar). Derived by the same server enum arithmetic as COINAGE above: +// PLAYER_XP = UNIT_END(0xBC = 188) + 0x210(528) = 716; PLAYER_NEXT_LEVEL_XP = 717. Cross-checked +// against COINAGE: 1176 − 716 = 460 = (0x3DC − 0x210). (vmangos UpdateFields_1_12_1.h lists them at +// UNIT_END + 0x210 / 0x211; its stale hex comments read 0x2C6/0x2C7, the same 6-low drift as COINAGE.) +const FIELD_PLAYER_XP: u16 = 716; +const FIELD_PLAYER_NEXT_LEVEL_XP: u16 = 717; +// PLAYER_EXPLORED_ZONES_1 (BYTES ×64, PRIVATE — self only): the discovery bitset, 2048 bits +// indexed by AreaTable's exploreFlag column; the world map's exploration overlays key off it +// (decision 0203 phase 3). Derived by the same server enum arithmetic as COINAGE/XP above: +// UNIT_END(0xBC = 188) + 0x39B(923) = 1111. Cross-checked against COINAGE: 0x3DC − 0x39B = 65 = +// the 64 bitset slots + PLAYER_FIELD_WATCHED_FACTION_INDEX between them (1111 + 64 = 1175, then +// 1176 ✓); the stale hex comment reads 0x451 — the usual 6-low drift. +const FIELD_PLAYER_EXPLORED_ZONES_1: u16 = 1111; +/// The bitset's slot count (`Size: 64` in the server enum). +pub const PLAYER_EXPLORED_ZONES_SLOTS: u16 = 64; +// PLAYER stat/skill block (decision 0208 phase 1a). Derived the same way as the buyback trio above: +// `UNIT_END`(188) + vmangos's own symbolic offset, in **decimal** (never the hex `//` comments, stale +// from the CONTAINER block on) — chain-verified against the already-tested COINAGE(1176) and +// BUYBACK_PRICE_1(1226) anchors (see the tests). +const FIELD_PLAYER_POSSTAT0: u16 = 1177; // UNIT_END+0x3DD ×5; FLOAT despite the header's "Type: INT" + // tag (VERIFIED vmangos `Player.h:1505,1511` + // `ApplyStatBuffMod`/`GetPosStat` route through + // `ApplyModSignedFloatValue`/`GetFloatValue`). +const FIELD_PLAYER_NEGSTAT0: u16 = 1182; // ×5, FLOAT, same header-tag mismatch; **negative-or-zero** + // (VERIFIED `Player.h:1505`: a stat *debuff* routes its + // already-negative `val` into this array through the same + // signed accumulator). +const FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE: u16 = 1187; // ×7, FLOAT (`Player.h:1513-1514`) +const FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE: u16 = 1194; // ×7, FLOAT; negative-or-zero (VERIFIED + // `SpellAuras.cpp:4496-4499`: `change` is the signed + // resistance delta, routed here unchanged). +const FIELD_PLAYER_MOD_DAMAGE_DONE_POS: u16 = 1201; // ×7 schools ([0]=physical), INT (VERIFIED + // `StatSystem.cpp:87` `SetStatInt32Value` for the magic + // schools, `SpellAuras.cpp:5208-5210` + // `ApplyModUInt32Value` for school 0/physical). +const FIELD_PLAYER_MOD_DAMAGE_DONE_NEG: u16 = 1208; // ×7, INT; negative-or-zero (VERIFIED + // `SpellAuras.cpp:5211-5213,5244-5247` + // `ApplyModInt32Value(..., m_modifier.m_amount, apply)` + // with an already-negative `m_amount` on the debuff branch). +const FIELD_PLAYER_MOD_DAMAGE_DONE_PCT: u16 = 1215; // ×7, **FLOAT despite the header's "Type: INT" + // tag** (VERIFIED vmangos `Player.cpp:3336` + // `SetFloatValue(PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + i, + // 1.00f)` init and `Player.cpp:7274` + // `SetFloatValue(PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + + // school, multiplier)` — decision 0208's flagged + // question, now closed: a true float, default 1.0. +const FIELD_PLAYER_SKILL_INFO_1_1: u16 = 718; // UNIT_END+0x212, ×384 (128 skills × 3 dwords) — see + // [`PlayerSkillSlot`] for the verified packing. + +// PLAYER_CHARACTER_POINTS1/2 (INT, PRIVATE — self only): unspent talent points / free primary +// professions — the reference's `UnitCharacterPoints("player")` pair (decision 0304). Derived by +// the same server enum arithmetic as the anchors above: UNIT_END(188) + 0x392(914) = 1102 and +// +0x393 = 1103 — exactly one past the skill array (718 + 384 = 1102 ✓, the tests' chain). +const FIELD_PLAYER_CHARACTER_POINTS1: u16 = 1102; +const FIELD_PLAYER_CHARACTER_POINTS2: u16 = 1103; +// PLAYER_TRACK_CREATURES / PLAYER_TRACK_RESOURCES (INT, PRIVATE — self only): the active tracking +// bitmasks the minimap dot classifier tests — bit `1 << (n − 1)` where `n` is a creature type +// (TRACK_CREATURES) or a `LockType.dbc` id (TRACK_RESOURCES); the server sets one bit per active +// tracking aura from the aura's MiscValue (vmangos `SpellAuras.cpp` `HandleAuraTrackCreatures`/ +// `HandleAuraTrackResources`, `1 << (m_miscvalue − 1)`). Derived by the same server enum +// arithmetic as the anchors above: UNIT_END(188) + 0x394(916) = 1104 and +0x395 = 1105 — +// contiguous past the CHARACTER_POINTS pair (1103 + 1 = 1104 ✓). +const FIELD_PLAYER_TRACK_CREATURES: u16 = 1104; +const FIELD_PLAYER_TRACK_RESOURCES: u16 = 1105; +const FIELD_PLAYER_AMMO_ID: u16 = 1223; // UNIT_END+0x40B, INT — the equipped ammo item id + // (`Player.cpp:7656` etc., `Player::SetUInt32Value`). + // PLAYER_FLAGS = UNIT_END(188) + 0x2 (VERIFIED vmangos `UpdateFields_1_12_1.h:120`; the duel + // arbiter guid takes +0x0/+0x1). Bit 0x10 = PLAYER_FLAGS_GHOST (`Player.h:319`), set/cleared by + // the ghost aura 8326's HandleAuraGhost at release/resurrect (decision 0308 §1). +const FIELD_PLAYER_FLAGS: u16 = 190; +const FIELD_PLAYER_FIELD_BYTES: u16 = 1222; // UNIT_END+0x40A, BYTES — vmangos `PLAYER_FIELD_BYTES` + // (distinct from the appearance `PLAYER_BYTES`(193)): + // flags / comboPoints / actionBars / HIGHEST_HONOR_RANK + // (`Player.h` PLAYER_FIELD_BYTES_OFFSET_*). + +/// The number of `PLAYER_SKILL_INFO` slots the descriptor reserves (384 fields ÷ 3 per slot; +/// vmangos `UpdateFields_1_12_1.h`). vmangos itself only ever *populates* `PLAYER_MAX_SKILLS` (127, +/// `Player.h:69`) of these — the last reserved slot is never written; `player_skill` honors the +/// full wire capacity so an out-of-range read is a real bug, not a silently-narrowed one. +pub const PLAYER_SKILL_SLOTS: u8 = 128; + +/// One decoded `PLAYER_SKILL_INFO` slot (VERIFIED packing vmangos `Player.cpp:90-99` +/// `PLAYER_SKILL_INDEX`/`_VALUE_INDEX`/`_BONUS_INDEX` + the `MAKE_PAIR32`/`PAIR32_LOPART`/`_HIPART` +/// macros, `shared/Common.h:119-121`, and `Player::SetSkill`, `Player.cpp:5664-5677`): three +/// consecutive dwords per slot — `(id | step<<16)`, `(value | max<<16)`, +/// `(tempBonus | permBonus<<16)`, with the bonus halves **signed** (`SKILL_TEMP_BONUS`/ +/// `SKILL_PERM_BONUS` cast to `int16` — a malus reads negative). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlayerSkillSlot { + /// The `SkillLine.dbc` id (`0` = an unused slot). + pub skill_id: u16, + /// The skill-tier step (`Player::SetSkill`'s `step` — 0 unless a tier-raising effect is live). + pub step: u16, + /// Current skill value. + pub value: u16, + /// Max skill value (the level/tier cap). + pub max: u16, + /// Temporary bonus (auras/consumables/enchants); signed — a malus reads negative. + pub temp_bonus: i16, + /// Permanent bonus (talents); signed. + pub perm_bonus: i16, +} + +/// A sparse update-field set: a bitmask of `u32` blocks followed by one `u32` per set bit. We keep the +/// values in an index→value map and expose **named** accessors for the descriptor fields consumers read +/// — the raw `get_*(index)` reads stay module-private, so field-index knowledge (a wire fact) never +/// leaves this crate (decision 0061). The ECS mirrors one of these per object and [`Self::merge`]s each +/// `Values` delta into it; the codec itself stays stateless (decision 0006). +/// +/// **Absent-field semantics** hinge on [`Self::created`]: a CREATE block is a *complete* snapshot — +/// the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set iff +/// `value != 0`), and the real client reads it into a zero-initialized descriptor buffer — so on a +/// create-seeded store an absent field reads `Some(0)`, the truth. A bare `Values` delta carries +/// only *changed* fields; there an absent field means "not touched", so it reads `None` (and a +/// merge must not clobber it). Director-caught: an item at durability 0 streams its create with +/// `MAXDURABILITY` but no `DURABILITY` word, and the sparse `None` sent the tooltip to the +/// template's max/max — 100% on fully broken gear. +#[derive(Debug, Clone, Default)] +pub struct ObjectFields { + fields: BTreeMap, + /// Seeded by a CREATE block (set by the parser via [`Self::into_created`]): every field is + /// known — absent = `0`. + created: bool, +} + +impl ObjectFields { + pub(super) fn read(r: &mut impl Read) -> io::Result { + let amount_of_blocks = read_u8(r)?; + let mut blocks = Vec::with_capacity(amount_of_blocks as usize); + for _ in 0..amount_of_blocks { + blocks.push(read_u32_le(r)?); + } + let mut fields = BTreeMap::new(); + for (index, block) in blocks.iter().enumerate() { + for bit in 0..32u16 { + if block & (1 << bit) != 0 { + fields.insert(index as u16 * 32 + bit, read_u32_le(r)?); + } + } + } + Ok(Self { + fields, + created: false, + }) + } + + /// Mark this set as a CREATE snapshot (see the struct doc's absent-field semantics) — the + /// parser calls it on a create block's mask; a `Values` delta never gets it. + pub fn into_created(mut self) -> Self { + self.created = true; + self + } + + /// Build a field set directly from `(index, value)` pairs — the fixture constructor (unit + /// tests, the capture harness's synthetic self-player, probes). The wire path is + /// [`Self::read`]; live code never builds fields by hand. Chain [`Self::into_created`] when + /// the fixture stands in for a create snapshot. + pub fn from_pairs(pairs: &[(u16, u32)]) -> Self { + Self { + fields: pairs.iter().copied().collect(), + created: false, + } + } + + /// A 2-field guid: `lo | hi << 32`, present iff the low half is (the wire always sends guid + /// halves together; an absent slot reads `None`, a sent-empty slot reads `Some(0)`). + /// `CORPSE_FIELD_OWNER` (field 6, `OBJECT_END + 0x0`, a 2-field guid — VERIFIED vmangos + /// `UpdateFields_1_12_1.h:339`): the dead player this corpse belongs to. Only meaningful on a + /// TYPEID_CORPSE (7) object; the net bridge uses it to recognize OUR corpse for the reclaim + /// send (decision 0308 §5). `None`/0 when absent. + pub fn corpse_owner(&self) -> Option { + self.get_guid(6).filter(|&g| g != 0) + } + + fn get_guid(&self, index: u16) -> Option { + // Raw map read on purpose: a guid slot stays "present iff the low half is" even on a + // created store — every consumer already treats a sent-empty `Some(0)` as no-guid, so the + // absent-is-zero fallback would only blur the two identical cases. + let lo = self.fields.get(&index).copied()?; + let hi = self.get_u32(index + 1).unwrap_or(0); + Some(u64::from(lo) | (u64::from(hi) << 32)) + } + + fn get_u32(&self, index: u16) -> Option { + self.fields + .get(&index) + .copied() + .or(self.created.then_some(0)) + } + fn get_f32(&self, index: u16) -> Option { + self.get_u32(index).map(f32::from_bits) + } + fn get_i32(&self, index: u16) -> Option { + self.get_u32(index).map(|v| v as i32) + } + /// Split a `u32` field into its low/high 16-bit halves — a `TWO_SHORT`/`PAIR32` field + /// (`MAKE_PAIR32`/`PAIR32_LOPART`/`_HIPART`, vmangos `shared/Common.h:119-121`). + fn get_u16_pair(&self, index: u16) -> Option<(u16, u16)> { + self.get_u32(index).map(|v| (v as u16, (v >> 16) as u16)) + } + + /// One slot's nibble out of a 8-slots-per-`u32` array (`UNIT_FIELD_AURAFLAGS`). An absent word + /// reads `0`, matching the client's zero-initialized descriptor array. + fn get_aura_nibble(&self, slot: u8) -> u8 { + let word = self + .get_u32(FIELD_UNIT_AURAFLAGS + u16::from(slot >> 3)) + .unwrap_or(0); + ((word >> ((slot & 7) * 4)) & 0x0F) as u8 + } + + /// One slot's byte out of a 4-slots-per-`u32` array (`UNIT_FIELD_AURALEVELS`, + /// `UNIT_FIELD_AURAAPPLICATIONS`). An absent word reads `0`, as above. + fn get_aura_byte(&self, base: u16, slot: u8) -> u8 { + let word = self.get_u32(base + u16::from(slot >> 2)).unwrap_or(0); + (word >> ((slot & 3) * 8)) as u8 + } + + /// Fold an update into this store. A `Values` delta overlays: every field it carries replaces + /// ours (WoW only resends *changed* fields, and a field going to `0` is an explicit `0`, never + /// a removal), so the ECS accumulates the object's full descriptor set across packets + /// (decision 0061). A re-CREATE snapshot *replaces* instead: it is the complete descriptor + /// (absent = 0), and overlaying it would keep stale non-zero values for every field that + /// dropped to zero since the last stream (an aura that expired out of view, durability that + /// broke) — exactly the absent-vs-zero bug the `created` flag exists to kill. + pub fn merge(&mut self, delta: ObjectFields) { + if delta.created { + *self = delta; + } else { + self.fields.extend(delta.fields); + } + } + + /// Whether this carried no fields — the codec skips emitting an empty `Values` delta. + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + /// Every present `(index, value)` pair — the debug/probe affordance for descriptor + /// archaeology (named accessors stay the consumer surface; decision 0061). + pub fn raw_fields(&self) -> impl Iterator + '_ { + self.fields.iter().map(|(&i, &v)| (i, v)) + } +} + +mod player; +mod unit; + +#[cfg(test)] +mod tests; diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs new file mode 100644 index 00000000..ee2d5356 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -0,0 +1,422 @@ +//! The item/container/PLAYER named accessors over [`ObjectFields`] — inventory and equipment +//! slots, purse/XP, the quest log, skills, buyback, reputation-adjacent player bytes. Split from +//! the one descriptor map (`mod.rs` holds the indices + codec). + +use super::*; + +impl ObjectFields { + /// `OBJECT_FIELD_ENTRY` (3) — the object's template entry (an item object's item id, a + /// GameObject's template). The one field that names WHAT a streamed object is. + pub fn object_entry(&self) -> Option { + self.get_u32(3) + } + /// `ITEM_FIELD_STACK_COUNT` — an item object's stack size. + pub fn item_stack_count(&self) -> Option { + self.get_u32(FIELD_ITEM_STACK_COUNT) + } + /// `ITEM_FIELD_FLAGS` (field 21 — wire index; the client's item-relative index 15 + the 6 + /// object fields, VERIFIED wow-re inventory-alert-law): bit `0x08` = wrapped (a gift — never + /// alerts, never reads broken), bit `0x10` = force-red (status 4 regardless of durability; + /// the bit's game meaning is unnamed in the binary, the behavior byte-verified). + pub fn item_flags(&self) -> Option { + self.get_u32(21) + } + /// `ITEM_FIELD_CREATOR` (field 10, guid — vmangos `UpdateFields_1_12_1.h` OBJECT_END+0x4) — + /// the item's author/crafter. On a mail-made permanent letter this is the letter's SENDER + /// (vmangos `HandleMailCreateTextItem` stamps it) — the reader window's "From," line. + pub fn item_creator(&self) -> Option { + self.get_guid(10).filter(|&g| g != 0) + } + /// `ITEM_FIELD_ITEM_TEXT_ID` (field 45, OWNER_ONLY — vmangos `UpdateFields_1_12_1.h` + /// OBJECT_END+0x27) — the item *instance*'s letter text (a mail-made permanent copy). Nonzero + /// = right-click reads it (`CMSG_ITEM_TEXT_QUERY`) instead of using it. + pub fn item_text_id(&self) -> Option { + self.get_u32(45) + } + /// `ITEM_FIELD_DURABILITY` — an item's current durability (field 46: chain-locked between the + /// tested STACK_COUNT 14 and CONTAINER_NUM_SLOTS 48 = ITEM_END anchors). + pub fn item_durability(&self) -> Option { + self.get_u32(46) + } + /// `ITEM_FIELD_MAXDURABILITY` — the item's durability ceiling (0 = has no durability). + pub fn item_max_durability(&self) -> Option { + self.get_u32(47) + } + /// `CONTAINER_FIELD_NUM_SLOTS` — a bag object's capacity. + pub fn container_num_slots(&self) -> Option { + self.get_u32(FIELD_CONTAINER_NUM_SLOTS) + } + /// `CONTAINER_FIELD_SLOT_1 + 2i` — the item guid in a bag's slot `i` (< 36). `Some(0)` = the + /// server said "empty"; `None` = the field wasn't sent. + pub fn container_slot(&self, i: u8) -> Option { + (i < 36).then(|| self.get_guid(FIELD_CONTAINER_SLOT_1 + 2 * u16::from(i)))? + } + /// `PLAYER_VISIBLE_ITEM__0` — the **public** item entry visibly worn in equipment + /// slot `i` (what other clients render from; 12 fields per slot after the 2-field creator). + /// The cross-check for the private INV_SLOT guids — and the entry source for our own + /// equipment until item objects have a decode path. + pub fn player_visible_item_entry(&self, i: u8) -> Option { + (i < 19) + .then(|| { + self.get_u32(FIELD_PLAYER_VISIBLE_ITEM_1_CREATOR + 2 + 12 * u16::from(i)) + .filter(|&e| e != 0) + }) + .flatten() + } + /// `PLAYER_FIELD_INV_SLOT_HEAD + 2i` — our equipment (0–18) and equipped-bag (19–22) guids. + pub fn player_inv_slot(&self, i: u8) -> Option { + (i < 23).then(|| self.get_guid(FIELD_PLAYER_INV_SLOT_HEAD + 2 * u16::from(i)))? + } + /// `PLAYER_FIELD_PACK_SLOT_1 + 2i` — our backpack's 16 item guids. + pub fn player_pack_slot(&self, i: u8) -> Option { + (i < 16).then(|| self.get_guid(FIELD_PLAYER_PACK_SLOT_1 + 2 * u16::from(i)))? + } + /// `PLAYER_FIELD_BANK_SLOT_1 + 2i` — our bank's 24 item guids. + pub fn player_bank_slot(&self, i: u8) -> Option { + (i < 24).then(|| self.get_guid(FIELD_PLAYER_BANK_SLOT_1 + 2 * u16::from(i)))? + } + /// `PLAYER_FIELD_BANK_BAG_SLOT_1 + 2i` — the item guid of bank bag `i` (0–5, the 6 purchasable + /// bank bag slots); the bag's *contents* stream as an ordinary `CONTAINER_FIELD_SLOT_*` block + /// on the bag item itself, addressed by bag byte 63–68 (decision 0604). + pub fn player_bank_bag_slot(&self, i: u8) -> Option { + (i < 6).then(|| self.get_guid(FIELD_PLAYER_BANK_BAG_SLOT_1 + 2 * u16::from(i)))? + } + /// `PLAYER_FIELD_VENDORBUYBACK_SLOT_1 + 2i` — the item guid parked in buyback slot `i` (0–11; + /// the wire's absolute inventory slot is `69 + i`). `Some(0)` = empty. + pub fn player_buyback_slot(&self, i: u8) -> Option { + (i < 12).then(|| self.get_guid(FIELD_PLAYER_VENDORBUYBACK_SLOT_1 + 2 * u16::from(i)))? + } + /// `PLAYER_FIELD_BUYBACK_PRICE_1 + i` — what buying slot `i` back costs, in copper. + pub fn player_buyback_price(&self, i: u8) -> Option { + (i < 12).then(|| self.get_u32(FIELD_PLAYER_BUYBACK_PRICE_1 + u16::from(i)))? + } + /// `PLAYER_FIELD_BUYBACK_TIMESTAMP_1 + i` — slot `i`'s expiry stamp; the client uses it only + /// to order the buyback list oldest→newest (wow-re `0x4fafd0`). + pub fn player_buyback_timestamp(&self, i: u8) -> Option { + (i < 12).then(|| self.get_u32(FIELD_PLAYER_BUYBACK_TIMESTAMP_1 + u16::from(i)))? + } + /// `PLAYER_FIELD_COINAGE` — our purse in copper. `None` for a non-player unit (no PLAYER block) + /// or before the field has streamed in; the descriptor's zero-initialized default is 0 copper. + pub fn player_money(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_COINAGE) + } + /// `PLAYER_XP` — our current experience within the level (`UnitXP("player")`). `None` for a + /// non-player unit (no PLAYER block) or before the field has streamed; a PRIVATE field, so it + /// only ever arrives for our own avatar's descriptor. + pub fn player_xp(&self) -> Option { + self.get_u32(FIELD_PLAYER_XP) + } + /// `PLAYER_NEXT_LEVEL_XP` — experience required to reach the next level (`UnitXPMax("player")`). + /// Same PRIVATE/own-player caveat as [`Self::player_xp`]. + pub fn player_next_level_xp(&self) -> Option { + self.get_u32(FIELD_PLAYER_NEXT_LEVEL_XP) + } + /// `PLAYER_CHARACTER_POINTS1` — unspent talent points (`UnitCharacterPoints("player")`'s + /// first return; the talent frame's "N talent points" line, decision 0304). Same + /// PRIVATE/own-player caveat as [`Self::player_xp`]. + pub fn player_talent_points(&self) -> Option { + self.get_u32(FIELD_PLAYER_CHARACTER_POINTS1) + } + /// `PLAYER_CHARACTER_POINTS2` — free primary professions (`UnitCharacterPoints`'s second + /// return; the "You now have %d free professions." chat line). Same caveat as above. + pub fn player_free_professions(&self) -> Option { + self.get_u32(FIELD_PLAYER_CHARACTER_POINTS2) + } + /// `PLAYER_TRACK_CREATURES` — the active creature-tracking bitmask (bit `1 << (creatureType − + /// 1)` per active TRACK_CREATURES aura): the minimap red-dot predicate's mask. PRIVATE — self + /// only; absent (no PLAYER block, or nothing ever tracked) reads as no tracking. + pub fn player_track_creatures(&self) -> u32 { + self.get_u32(FIELD_PLAYER_TRACK_CREATURES).unwrap_or(0) + } + /// `PLAYER_TRACK_RESOURCES` — the active resource-tracking bitmask (bit `1 << (lockType − 1)` + /// per active TRACK_RESOURCES aura, e.g. Find Minerals → the Mining `LockType.dbc` bit): the + /// minimap gold-dot predicate's mask. Same PRIVATE/self-only caveat as above. + pub fn player_track_resources(&self) -> u32 { + self.get_u32(FIELD_PLAYER_TRACK_RESOURCES).unwrap_or(0) + } + /// `PLAYER_FIELD_BYTES` byte 0 bit `0x2` — track-stealthed active (vmangos + /// `PLAYER_FIELD_BYTE_TRACK_STEALTHED`, set by the TRACK_STEALTHED(151) aura handler): the + /// tracking classifier's stealth always-show clause — with it set, any CREEP-flagged unit + /// red-dots (`0x5ed210` @ the `+0x1028` read, wow-re `track-predicates.md`). Same + /// PRIVATE/self-only caveat as the tracking masks. + pub fn player_track_stealthed(&self) -> bool { + self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x2 != 0 + } + /// `PLAYER_QUEST_LOG__*` — one decoded quest-log slot (0-based, `slot < + /// PLAYER_QUEST_LOG_SLOTS`). `None` when the slot's id field never streamed (or the index is out + /// of range); `Some` with `quest_id == 0` is a slot the server has explicitly cleared (an + /// abandon/turn-in leaves this). The count-state and timer fields default to `0` when absent — + /// the server writes them with the id on every occupy (vmangos `Player.h:1094-1099` + /// `SetQuestSlot`), so an absent pair only ever accompanies an untouched slot. + pub fn player_quest_log(&self, slot: u8) -> Option { + if slot >= PLAYER_QUEST_LOG_SLOTS { + return None; + } + let base = FIELD_PLAYER_QUEST_LOG_1_1 + 3 * u16::from(slot); + let quest_id = self.get_u32(base)?; + let count_state = self.get_u32(base + 1).unwrap_or(0); + let timer = self.get_u32(base + 2).unwrap_or(0); + let mut counters = [0u8; 4]; + for (i, c) in counters.iter_mut().enumerate() { + *c = ((count_state >> (6 * i)) & 0x3F) as u8; + } + Some(QuestLogSlot { + quest_id, + counters, + state: (count_state >> 24) as u8, + timer, + }) + } + /// `PLAYER_EXPLORED_ZONES_1 + i` — the discovery bitset's `i`-th u32 (absent slots read 0: + /// a fresh descriptor means nothing explored yet). The full set is + /// [`PLAYER_EXPLORED_ZONES_SLOTS`] slots; bit `n` of the whole = AreaTable exploreFlag `n`. + pub fn player_explored_zone_slot(&self, i: u16) -> u32 { + if i >= PLAYER_EXPLORED_ZONES_SLOTS { + return 0; + } + self.get_u32(FIELD_PLAYER_EXPLORED_ZONES_1 + i).unwrap_or(0) + } + /// `PLAYER_FIELD_POSSTAT0 + i` — stat `i`'s **positive** buff delta (float despite the header's + /// "Type: INT" tag; see the field's doc comment). `i >= 5` reads `None`. + pub fn player_posstat(&self, i: u8) -> Option { + (i < 5).then(|| self.get_f32(FIELD_PLAYER_POSSTAT0 + u16::from(i)))? + } + /// `PLAYER_FIELD_NEGSTAT0 + i` — stat `i`'s **negative** buff delta; negative-or-zero (see the + /// field's doc comment for the verified sign). `i >= 5` reads `None`. + pub fn player_negstat(&self, i: u8) -> Option { + (i < 5).then(|| self.get_f32(FIELD_PLAYER_NEGSTAT0 + u16::from(i)))? + } + /// `PLAYER_FIELD_RESISTANCEBUFFMODSPOSITIVE + school` — the positive resistance buff for + /// `school` (0..6). `school >= 7` reads `None`. + pub fn player_resistance_buff_pos(&self, school: u8) -> Option { + (school < 7) + .then(|| self.get_f32(FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE + u16::from(school)))? + } + /// `PLAYER_FIELD_RESISTANCEBUFFMODSNEGATIVE + school` — negative-or-zero (see the field's doc + /// comment). `school >= 7` reads `None`. + pub fn player_resistance_buff_neg(&self, school: u8) -> Option { + (school < 7) + .then(|| self.get_f32(FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE + u16::from(school)))? + } + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_POS + school` — positive damage-done bonus (`[0]` = physical). + /// `school >= 7` reads `None`. + pub fn player_mod_damage_done_pos(&self, school: u8) -> Option { + (school < 7).then(|| self.get_i32(FIELD_PLAYER_MOD_DAMAGE_DONE_POS + u16::from(school)))? + } + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_NEG + school` — negative-or-zero (see the field's doc + /// comment). `school >= 7` reads `None`. + pub fn player_mod_damage_done_neg(&self, school: u8) -> Option { + (school < 7).then(|| self.get_i32(FIELD_PLAYER_MOD_DAMAGE_DONE_NEG + u16::from(school)))? + } + /// `PLAYER_FIELD_MOD_DAMAGE_DONE_PCT + school` — the damage-done percent multiplier (a true + /// float; see the field's doc comment — decision 0208's flagged question, now closed). + /// `school >= 7` reads `None`; an in-range-but-absent read (no aura has touched this school + /// yet) is the caller's job to default to `1.0` — the wire only sends fields that changed. + pub fn player_mod_damage_done_pct(&self, school: u8) -> Option { + (school < 7).then(|| self.get_f32(FIELD_PLAYER_MOD_DAMAGE_DONE_PCT + u16::from(school)))? + } + /// `PLAYER_SKILL_INFO__*` — one decoded skill slot (`slot < `[`PLAYER_SKILL_SLOTS`]). + /// `None` when the id dword never streamed (or `slot` is out of range); the value/max and + /// bonus dwords default to `0` when absent (mirroring [`Self::player_quest_log`]'s tolerance — + /// vmangos writes id and value together on `SetSkill`, so an absent pair only ever accompanies + /// a slot whose snapshot hasn't landed yet). + pub fn player_skill(&self, slot: u8) -> Option { + if slot >= PLAYER_SKILL_SLOTS { + return None; + } + let base = FIELD_PLAYER_SKILL_INFO_1_1 + 3 * u16::from(slot); + let (skill_id, step) = self.get_u16_pair(base)?; + let (value, max) = self.get_u16_pair(base + 1).unwrap_or((0, 0)); + let (temp_bonus, perm_bonus) = self.get_u16_pair(base + 2).unwrap_or((0, 0)); + Some(PlayerSkillSlot { + skill_id, + step, + value, + max, + temp_bonus: temp_bonus as i16, + perm_bonus: perm_bonus as i16, + }) + } + /// `PLAYER_AMMO_ID` — the equipped ammo pouch/quiver's item id (arrows/bullets), or `None` + /// before the field streams. `0` = no ammo selected. + pub fn player_ammo_id(&self) -> Option { + self.get_u32(FIELD_PLAYER_AMMO_ID) + } + /// `PLAYER_BYTES` — packed skinColor (byte 0) / faceType (byte 1) / hairStyle (byte 2) / hairColor + /// (byte 3). The character compositor's customization input (RF-0056). + pub fn player_bytes(&self) -> Option { + self.get_u32(FIELD_PLAYER_BYTES) + } + /// `PLAYER_BYTES_2` — byte 0 = facialHair, byte 1 = unk, byte 2 = bankBagSlots (see + /// [`Self::player_bank_bag_slots_purchased`]), byte 3 = restState (unread here). + pub fn player_bytes_2(&self) -> Option { + self.get_u32(FIELD_PLAYER_BYTES_2) + } + /// `PLAYER_FLAGS` (field 190) — the player state flags. `0` when absent (the descriptor's + /// zero default — a create block skips zero fields). + pub fn player_flags(&self) -> u32 { + self.get_u32(FIELD_PLAYER_FLAGS).unwrap_or(0) + } + /// Whether the player is a released **ghost** — `PLAYER_FLAGS_GHOST (0x10)`, set/cleared by + /// the ghost aura 8326 at release/resurrect (decision 0308 §1). NB a ghost's health is **1** + /// on the wire, so `unit_is_dead` is FALSE for a ghost: the three predicates are + /// dead = health 0 · ghost = this bit · dead-or-ghost = either (the real client's + /// UnitIsDead/UnitIsGhost/UnitIsDeadOrGhost trio). + pub fn player_is_ghost(&self) -> bool { + self.player_flags() & 0x10 != 0 + } + /// `PLAYER_FIELD_BYTES` byte 0 bit `0x08` — `PLAYER_FIELD_BYTE_RELEASE_TIMER`, "Display time + /// till auto release spirit" (the header's own comment): set at death iff the map is + /// non-instanceable, i.e. exactly when the server will force-release after 6:00. The client + /// mirrors that constant for the DEATH popup's countdown (byte-VERIFIED — wow-re death-ui.md + /// §1: the client arms `now + 360000 ms` at its own alive→dead edge; bit clear ⇒ + /// `GetReleaseTimeRemaining() == -1`). + pub fn player_release_timer_running(&self) -> bool { + self.get_u32(FIELD_PLAYER_FIELD_BYTES).unwrap_or(0) & 0x08 != 0 + } + /// `PLAYER_FIELD_BYTES` byte 3 — the player's **highest lifetime honor rank** (vmangos + /// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's + /// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the + /// values byte at `+0x102b`). `None` before the field streams; rank 0 = never ranked. + pub fn player_honor_rank(&self) -> Option { + self.get_u32(FIELD_PLAYER_FIELD_BYTES) + .map(|b| ((b >> 24) & 0xff) as u8) + } + /// `UNIT_FIELD_BYTES_0` byte 0 — the unit's race. `None` when the field wasn't sent. + pub fn unit_race(&self) -> Option { + self.unit_bytes_0().map(|b| (b & 0xff) as u8) + } + /// `UNIT_FIELD_BYTES_0` byte 1 — the unit's class. + pub fn unit_class(&self) -> Option { + self.unit_bytes_0().map(|b| ((b >> 8) & 0xff) as u8) + } + /// `UNIT_FIELD_BYTES_0` byte 2 — the unit's gender/sex. + pub fn unit_gender(&self) -> Option { + self.unit_bytes_0().map(|b| ((b >> 16) & 0xff) as u8) + } + /// `PLAYER_BYTES` byte 0 — skinColor. `None` for a non-player unit (no PLAYER block). + pub fn player_skin(&self) -> Option { + self.player_bytes().map(|b| (b & 0xff) as u8) + } + /// `PLAYER_BYTES` byte 1 — faceType. + pub fn player_face(&self) -> Option { + self.player_bytes().map(|b| ((b >> 8) & 0xff) as u8) + } + /// `PLAYER_BYTES` byte 2 — hairStyle. + pub fn player_hair_style(&self) -> Option { + self.player_bytes().map(|b| ((b >> 16) & 0xff) as u8) + } + /// `PLAYER_BYTES` byte 3 — hairColor. + pub fn player_hair_color(&self) -> Option { + self.player_bytes().map(|b| ((b >> 24) & 0xff) as u8) + } + /// `PLAYER_BYTES_2` byte 0 — facialHair. + pub fn player_facial_hair(&self) -> Option { + self.player_bytes_2().map(|b| (b & 0xff) as u8) + } + /// `PLAYER_BYTES_2` byte 2 — `PLAYER_BYTES_2_OFFSET_BANK_BAG_SLOTS` (vmangos `Player.h:347`): + /// how many of the 6 purchasable bank bag slots are bought. Byte **2**, not byte 1 — the + /// server writes byte 0 facialHair, byte 1 unused, byte 2 bankBagSlots, byte 3 restState. The + /// only success signal for `CMSG_BUY_BANK_SLOT` (decision 0604): the server sends no packet on + /// success, only this descriptor delta (`PLAYERBANKBAGSLOTS_CHANGED` on the reference client). + pub fn player_bank_bag_slots_purchased(&self) -> Option { + self.player_bytes_2().map(|b| ((b >> 16) & 0xff) as u8) + } + /// `GAMEOBJECT_DISPLAYID`. + pub fn gameobject_displayid(&self) -> Option { + self.get_i32(FIELD_GAMEOBJECT_DISPLAYID) + } + /// `GAMEOBJECT_STATE` — 0 active (door open/triggered), 1 ready (closed/armed), 2 active-alt + /// (vmangos `GOState`, `OBJECT_END + 0x8` in 1.12). Drives the open/close sound slots. + pub fn gameobject_state(&self) -> Option { + self.get_u32(FIELD_GAMEOBJECT_STATE) + } + /// `GAMEOBJECT_ROTATION` — the spawn's local-rotation quaternion `(x, y, z, w)`. `None` when + /// the wire carried no component at all (vmangos omits zero fields from the create mask, and + /// an identity-ish quat still sends its nonzero `w`/`z`); absent components within a + /// partially-sent quat read as the create-block zero default, so a pure-yaw spawn + /// (`x = y = 0`, the live-data norm) round-trips exactly. Consumed by the type-11 transport + /// evaluator (elevators/lifts rotate their keyframe offsets through this). + pub fn gameobject_rotation(&self) -> Option<[f32; 4]> { + let any_sent = + (0..4u16).any(|i| self.fields.contains_key(&(FIELD_GAMEOBJECT_ROTATION + i))); + any_sent.then(|| { + [ + self.get_f32(FIELD_GAMEOBJECT_ROTATION).unwrap_or(0.0), + self.get_f32(FIELD_GAMEOBJECT_ROTATION + 1).unwrap_or(0.0), + self.get_f32(FIELD_GAMEOBJECT_ROTATION + 2).unwrap_or(0.0), + self.get_f32(FIELD_GAMEOBJECT_ROTATION + 3).unwrap_or(0.0), + ] + }) + } + /// GameObject position + facing (`GAMEOBJECT_POS_*` / `GAMEOBJECT_FACING`). On a create block this + /// is `Some` **even when none of the three were sent** — the created-store zero-fill + /// (`get_f32`/`get_u32`'s `created.then_some(0)`) can't tell "genuinely at the origin" from "never + /// carried a position at all" (a transport GameObject, whose stationary spot rides the movement + /// block's `HAS_POSITION` slot instead — decision 0438 "the wire"). [`Self::gameobject_pos_sent`] + /// answers that distinction; the create-placement fallback (`events/decode.rs`) gates on it. + pub fn gameobject_position(&self) -> Option<(Vector3d, f32)> { + Some(( + Vector3d { + x: self.get_f32(FIELD_GAMEOBJECT_POS_X)?, + y: self.get_f32(FIELD_GAMEOBJECT_POS_Y)?, + z: self.get_f32(FIELD_GAMEOBJECT_POS_Z)?, + }, + self.get_f32(FIELD_GAMEOBJECT_FACING).unwrap_or(0.0), + )) + } + /// Whether the wire genuinely carried **any** `GAMEOBJECT_POS_*` field — a raw map read on purpose + /// (like `get_guid`'s, bypassing the create block's absent-is-zero fold) so a consumer can tell + /// "this GO's create never carried a position" from "it did, and it's `0.0` on some axis". A normal + /// GameObject sends at least one non-zero axis virtually always; a boat/zeppelin/elevator sends + /// **none** (vmangos never sets `GAMEOBJECT_POS_*` on a transport). + pub fn gameobject_pos_sent(&self) -> bool { + self.fields.contains_key(&FIELD_GAMEOBJECT_POS_X) + || self.fields.contains_key(&FIELD_GAMEOBJECT_POS_Y) + || self.fields.contains_key(&FIELD_GAMEOBJECT_POS_Z) + } + /// `GAMEOBJECT_TYPE_ID` — the GameObject sub-type (DOOR/CHEST/SPELL_FOCUS/…). **Absent ⇒ `0` = + /// DOOR**, not "unknown": vmangos omits a zero-valued field from the create mask (the same reason an + /// all-zero `PLAYER_BYTES` is never sent), and `GAMEOBJECT_TYPE_DOOR` *is* 0 — so a door sends no type + /// field at all, and the absent value is its default. Reading it as `None` silently mis-handled every + /// door (no interact cursor, no open/close animation), so this defaults to the wire default. + pub fn gameobject_type_id(&self) -> i32 { + self.get_i32(FIELD_GAMEOBJECT_TYPE_ID).unwrap_or(0) + } + /// `GAMEOBJECT_FLAGS` (`GameObjectFlags`) — static interactability bits: `0x1` IN_USE (busy), + /// `0x4` INTERACT_COND (usable only when the per-player `GAMEOBJECT_DYN_FLAGS` activate bit is set — + /// the quest gate), `0x10` NO_INTERACT. Read by the cursor/interact usability gate (decision 0243). + pub fn gameobject_flags(&self) -> u32 { + self.get_u32(FIELD_GAMEOBJECT_FLAGS).unwrap_or(0) + } + /// `GAMEOBJECT_DYN_FLAGS` — the **per-player** dynamic flags; bit `0x1` = `GO_DYNFLAG_LO_ACTIVATE` + /// (the sparkle / "usable for me now"), which the server sets from `GameObject::ActivateToQuest`. + /// Consulted only when `GAMEOBJECT_FLAGS & INTERACT_COND` is set (decision 0243). + pub fn gameobject_dynamic_flags(&self) -> u32 { + self.get_u32(FIELD_GAMEOBJECT_DYN_FLAGS).unwrap_or(0) + } + /// `GAMEOBJECT_LEVEL` — the object's level (vmangos `UpdateFields_1_12_1.h`: `OBJECT_END + 0x10` + /// = 22). The lock-refusal toast's required-rank fallback reads it: a skill lock whose + /// `Skill[0]` is 0 displays `level × 5` instead (wow-re cursor-system.md §8.8, `0x5f3490`). + /// `0` when absent (the create-block zero default). + pub fn gameobject_level(&self) -> u32 { + self.get_u32(FIELD_GAMEOBJECT_LEVEL).unwrap_or(0) + } + + /// The object's class from the type field (`OBJECT_FIELD_TYPE`), used for `Values`/`Movement` + /// updates that carry no separate `TypeId`. Unused by the create path (which has a `TypeId`). + #[allow(dead_code)] + pub fn object_type(&self) -> Option { + self.get_u32(FIELD_OBJECT_TYPE).map(|bits| { + if bits & 0x10 != 0 { + ObjectType::Player + } else if bits & 0x08 != 0 { + ObjectType::Unit + } else if bits & 0x20 != 0 { + ObjectType::GameObject + } else { + ObjectType::Object + } + }) + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/tests.rs b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs new file mode 100644 index 00000000..91718176 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/fields/tests.rs @@ -0,0 +1,404 @@ +use super::*; + +/// The 0208 combat/stat indices re-derived from vmangos's own enum arithmetic (`OBJECT_END` = +/// 6, `UNIT_END` = 188) and chain-locked to the file's two live-tested anchors: +/// `FIELD_PLAYER_INV_SLOT_HEAD` (486, descriptor-dump verified) and +/// `FIELD_PLAYER_BUYBACK_PRICE_1` (1226, wow-re byte anchor, decision 0138). If any constant +/// drifts, this is the tripwire. +#[test] +fn combat_stat_indices_chain_to_the_tested_anchors() { + const OBJECT_END: u16 = 6; + const UNIT_END: u16 = OBJECT_END + 0xB6; // UpdateFields_1_12_1.h + assert_eq!(UNIT_END, 188); + // The two tested anchors re-derive from the same arithmetic — the chain is sound. + assert_eq!(FIELD_PLAYER_INV_SLOT_HEAD, UNIT_END + 0x12A); + assert_eq!(FIELD_PLAYER_BUYBACK_PRICE_1, UNIT_END + 0x40E); + // UNIT combat block. + assert_eq!(FIELD_UNIT_BASEATTACKTIME, OBJECT_END + 0x78); + assert_eq!(FIELD_UNIT_RANGEDATTACKTIME, OBJECT_END + 0x7A); + assert_eq!(FIELD_UNIT_MINDAMAGE, OBJECT_END + 0x80); + assert_eq!(FIELD_UNIT_MAXDAMAGE, OBJECT_END + 0x81); + assert_eq!(FIELD_UNIT_MINOFFHANDDAMAGE, OBJECT_END + 0x82); + assert_eq!(FIELD_UNIT_MAXOFFHANDDAMAGE, OBJECT_END + 0x83); + assert_eq!(FIELD_UNIT_STAT0, OBJECT_END + 0x90); + assert_eq!(FIELD_UNIT_RESISTANCES, OBJECT_END + 0x95); + assert_eq!(FIELD_UNIT_ATTACK_POWER, OBJECT_END + 0x9F); + assert_eq!(FIELD_UNIT_ATTACK_POWER_MODS, OBJECT_END + 0xA0); + assert_eq!(FIELD_UNIT_ATTACK_POWER_MULTIPLIER, OBJECT_END + 0xA1); + assert_eq!(FIELD_UNIT_RANGED_ATTACK_POWER, OBJECT_END + 0xA2); + assert_eq!(FIELD_UNIT_RANGED_ATTACK_POWER_MODS, OBJECT_END + 0xA3); + assert_eq!(FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER, OBJECT_END + 0xA4); + assert_eq!(FIELD_UNIT_MINRANGEDDAMAGE, OBJECT_END + 0xA5); + assert_eq!(FIELD_UNIT_MAXRANGEDDAMAGE, OBJECT_END + 0xA6); + // PLAYER stat block: POSSTAT0 sits one past the tested COINAGE, and the run through + // AMMO_ID lands three shy of the tested BUYBACK_PRICE_1 (BYTES 1222, SELF_RES 1224, + // PVP_MEDALS 1225 between). + assert_eq!(FIELD_PLAYER_POSSTAT0, FIELD_PLAYER_FIELD_COINAGE + 1); + assert_eq!(FIELD_PLAYER_POSSTAT0, UNIT_END + 0x3DD); + assert_eq!(FIELD_PLAYER_NEGSTAT0, UNIT_END + 0x3E2); + assert_eq!(FIELD_PLAYER_RESISTANCEBUFFMODSPOSITIVE, UNIT_END + 0x3E7); + assert_eq!(FIELD_PLAYER_RESISTANCEBUFFMODSNEGATIVE, UNIT_END + 0x3EE); + assert_eq!(FIELD_PLAYER_MOD_DAMAGE_DONE_POS, UNIT_END + 0x3F5); + assert_eq!(FIELD_PLAYER_MOD_DAMAGE_DONE_NEG, UNIT_END + 0x3FC); + assert_eq!(FIELD_PLAYER_MOD_DAMAGE_DONE_PCT, UNIT_END + 0x403); + assert_eq!(FIELD_PLAYER_AMMO_ID, UNIT_END + 0x40B); + assert_eq!(FIELD_PLAYER_AMMO_ID + 3, FIELD_PLAYER_BUYBACK_PRICE_1); + assert_eq!(FIELD_PLAYER_FIELD_BYTES, UNIT_END + 0x40A); + assert_eq!(FIELD_PLAYER_FIELD_BYTES + 1, FIELD_PLAYER_AMMO_ID); + // The skill array: one past the tested XP pair, 384 dwords ending at CHARACTER_POINTS1. + assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, FIELD_PLAYER_NEXT_LEVEL_XP + 1); + assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1, UNIT_END + 0x212); + assert_eq!(FIELD_PLAYER_SKILL_INFO_1_1 + 384, UNIT_END + 0x392); + // The character-points pair sits exactly there (UNIT_END + 0x392/0x393; decision 0304). + assert_eq!( + FIELD_PLAYER_CHARACTER_POINTS1, + FIELD_PLAYER_SKILL_INFO_1_1 + 384 + ); + assert_eq!(FIELD_PLAYER_CHARACTER_POINTS2, UNIT_END + 0x393); + // The tracking-mask pair sits contiguously past it (UNIT_END + 0x394/0x395). + assert_eq!( + FIELD_PLAYER_TRACK_CREATURES, + FIELD_PLAYER_CHARACTER_POINTS2 + 1 + ); + assert_eq!(FIELD_PLAYER_TRACK_RESOURCES, UNIT_END + 0x395); +} + +#[test] +fn unit_stats_and_resistances_read_indexed_and_gate_range() { + let f = ObjectFields::from_pairs(&[ + (150, 25), // STAT0 strength + (154, 31), // STAT4 spirit + (155, 120), // RESISTANCES[0] = armor + (157, 15), // fire + (161, 8u32.wrapping_neg()), // arcane, cursed to −8 + ]); + assert_eq!(f.unit_stat(0), Some(25)); + assert_eq!(f.unit_stat(4), Some(31)); + assert_eq!(f.unit_stat(1), None, "never streamed"); + assert_eq!(f.unit_stat(5), None, "out of range"); + assert_eq!(f.unit_resistance(0), Some(120)); + assert_eq!(f.unit_resistance(2), Some(15)); + assert_eq!(f.unit_resistance(6), Some(-8), "resistances are signed"); + assert_eq!(f.unit_resistance(7), None, "out of range"); +} + +#[test] +fn attack_power_mods_split_signed_halves() { + // vmangos SetInt16Value(index_mod, 0, pos) / (index_mod, 1, neg): lo = positive, + // hi = negative (already negative-or-zero). pos 30, neg −10. + let packed = u32::from(30u16) | (u32::from((-10i16) as u16) << 16); + let f = ObjectFields::from_pairs(&[ + (165, 78), // ATTACK_POWER + (166, packed), // ATTACK_POWER_MODS + (167, 0.1f32.to_bits()), // MULTIPLIER (stores multiplier − 1.0) + (168, 52), // RANGED_ATTACK_POWER + (169, u32::from((-3i16) as u16) << 16), // ranged: pos 0, neg −3 + (170, 0.0f32.to_bits()), + ]); + assert_eq!(f.unit_attack_power(), Some(78)); + assert_eq!(f.unit_attack_power_mods(), (30, -10)); + assert_eq!(f.unit_attack_power_multiplier(), Some(0.1)); + assert_eq!(f.unit_ranged_attack_power(), Some(52)); + assert_eq!(f.unit_ranged_attack_power_mods(), (0, -3)); + assert_eq!(f.unit_ranged_attack_power_multiplier(), Some(0.0)); + // Absent mods read (0, 0) — the descriptor's zero-initialized default. + assert_eq!(ObjectFields::default().unit_attack_power_mods(), (0, 0)); +} + +#[test] +fn damage_and_attack_time_fields_read_their_slots() { + let f = ObjectFields::from_pairs(&[ + (126, 2900), // BASEATTACKTIME main + (127, 1500), // BASEATTACKTIME offhand + (128, 2800), // RANGEDATTACKTIME + (134, 12.5f32.to_bits()), // MINDAMAGE + (135, 19.5f32.to_bits()), // MAXDAMAGE + (136, 5.0f32.to_bits()), // MINOFFHANDDAMAGE + (137, 9.0f32.to_bits()), // MAXOFFHANDDAMAGE + (171, 31.0f32.to_bits()), // MINRANGEDDAMAGE + (172, 47.0f32.to_bits()), // MAXRANGEDDAMAGE + ]); + assert_eq!(f.unit_base_attack_time(0), Some(2900)); + assert_eq!(f.unit_base_attack_time(1), Some(1500)); + assert_eq!(f.unit_base_attack_time(2), None, "out of range"); + assert_eq!(f.unit_ranged_attack_time(), Some(2800)); + assert_eq!(f.unit_min_damage(), Some(12.5)); + assert_eq!(f.unit_max_damage(), Some(19.5)); + assert_eq!(f.unit_min_offhand_damage(), Some(5.0)); + assert_eq!(f.unit_max_offhand_damage(), Some(9.0)); + assert_eq!(f.unit_min_ranged_damage(), Some(31.0)); + assert_eq!(f.unit_max_ranged_damage(), Some(47.0)); +} + +#[test] +fn player_stat_buff_arrays_read_floats_with_signed_negatives() { + let f = ObjectFields::from_pairs(&[ + (1177, 10.0f32.to_bits()), // POSSTAT0 + (1182, (-4.0f32).to_bits()), // NEGSTAT0 (stored negative — ApplyStatBuffMod) + (1187, 30.0f32.to_bits()), // RESISTANCEBUFFMODSPOSITIVE[0] + (1196, (-20.0f32).to_bits()), // RESISTANCEBUFFMODSNEGATIVE[2] + ]); + assert_eq!(f.player_posstat(0), Some(10.0)); + assert_eq!(f.player_negstat(0), Some(-4.0)); + assert_eq!(f.player_posstat(5), None, "out of range"); + assert_eq!(f.player_negstat(5), None, "out of range"); + assert_eq!(f.player_resistance_buff_pos(0), Some(30.0)); + assert_eq!(f.player_resistance_buff_neg(2), Some(-20.0)); + assert_eq!(f.player_resistance_buff_pos(7), None, "out of range"); + assert_eq!(f.player_resistance_buff_neg(7), None, "out of range"); +} + +#[test] +fn player_mod_damage_done_reads_int_pos_neg_and_float_pct() { + let f = ObjectFields::from_pairs(&[ + (1201, 25), // MOD_DAMAGE_DONE_POS[0] physical + (1203, 40), // MOD_DAMAGE_DONE_POS[2] fire + (1208, 15u32.wrapping_neg()), // MOD_DAMAGE_DONE_NEG[0] = −15 + (1215, 1.1f32.to_bits()), // MOD_DAMAGE_DONE_PCT[0] — a true float + ]); + assert_eq!(f.player_mod_damage_done_pos(0), Some(25)); + assert_eq!(f.player_mod_damage_done_pos(2), Some(40)); + assert_eq!(f.player_mod_damage_done_neg(0), Some(-15)); + assert_eq!(f.player_mod_damage_done_pct(0), Some(1.1)); + assert_eq!(f.player_mod_damage_done_pct(1), None, "never streamed"); + assert_eq!(f.player_mod_damage_done_pos(7), None, "out of range"); + assert_eq!(f.player_mod_damage_done_neg(7), None, "out of range"); + assert_eq!(f.player_mod_damage_done_pct(7), None, "out of range"); +} + +#[test] +fn player_skill_unpacks_the_three_dword_triplet() { + // Slot 1 (base 718 + 3 = 721): Swords (43) step 0, 25/300, temp −3 / perm +5 — + // MAKE_PAIR32 lo|hi packing throughout, bonuses signed (Player.cpp:94-100). + let f = ObjectFields::from_pairs(&[ + (721, 43), + (722, 25 | (300 << 16)), + (723, u32::from((-3i16) as u16) | (5 << 16)), + ]); + assert_eq!( + f.player_skill(1), + Some(PlayerSkillSlot { + skill_id: 43, + step: 0, + value: 25, + max: 300, + temp_bonus: -3, + perm_bonus: 5, + }) + ); + // A never-streamed slot → None; a zeroed (server-cleared) slot → Some with id 0; + // out of range → None. + assert_eq!(f.player_skill(0), None); + assert_eq!( + ObjectFields::from_pairs(&[(718, 0)]) + .player_skill(0) + .map(|s| s.skill_id), + Some(0) + ); + assert_eq!(f.player_skill(PLAYER_SKILL_SLOTS), None); +} + +#[test] +fn player_ammo_id_reads_the_field() { + let f = ObjectFields::from_pairs(&[(1223, 2512)]); + assert_eq!(f.player_ammo_id(), Some(2512)); + assert_eq!(ObjectFields::default().player_ammo_id(), None); +} + +#[test] +fn player_honor_rank_reads_byte_3_of_player_field_bytes() { + // flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian. + let f = ObjectFields::from_pairs(&[(1222, 0x05_03_02_01)]); + assert_eq!(f.player_honor_rank(), Some(5)); + assert_eq!(ObjectFields::default().player_honor_rank(), None); +} + +/// The four aura arrays tile exactly, and the block lands on `BASEATTACKTIME` — an index this +/// file already chain-locks to two live-verified anchors. So this pins all four aura indices +/// without a new anchor of its own: 48 spell-id dwords, then 48 nibbles (6 dwords), then 48 +/// bytes (12 dwords) twice, then `AURASTATE` (+0x77), then `BASEATTACKTIME` (+0x78). +#[test] +fn aura_arrays_tile_from_object_end_onto_the_tested_anchor() { + const OBJECT_END: u16 = 6; + assert_eq!(FIELD_UNIT_AURA, OBJECT_END + 0x29); + assert_eq!(FIELD_UNIT_AURAFLAGS, OBJECT_END + 0x59); + assert_eq!(FIELD_UNIT_AURALEVELS, OBJECT_END + 0x5F); + assert_eq!(FIELD_UNIT_AURAAPPLICATIONS, OBJECT_END + 0x6B); + + assert_eq!( + FIELD_UNIT_AURA + u16::from(UNIT_AURA_SLOTS), + FIELD_UNIT_AURAFLAGS, + "48 dwords of spell id" + ); + assert_eq!( + FIELD_UNIT_AURAFLAGS + 6, + FIELD_UNIT_AURALEVELS, + "48 nibbles" + ); + assert_eq!( + FIELD_UNIT_AURALEVELS + 12, + FIELD_UNIT_AURAAPPLICATIONS, + "48 bytes" + ); + assert_eq!( + FIELD_UNIT_AURAAPPLICATIONS + 12 + 1, + FIELD_UNIT_BASEATTACKTIME, + "48 bytes, then the 1-dword AURASTATE, then the tested anchor" + ); +} + +/// The packing goldens: nibble-per-slot flags (8/dword), byte-per-slot levels and applications +/// (4/dword, holding `stack - 1`). Slot 32 deliberately omits its applications word — an absent +/// field must read as the descriptor's zero, i.e. a stack of 1, not a stack of 0. +#[test] +fn aura_slots_unpack_nibbles_bytes_and_the_stack_bias() { + let f = ObjectFields::from_pairs(&[ + // Spell ids: slot 0 and 2 (buffs), slot 32 and 47 (debuffs). + (47, 1126), + (49, 25), + (79, 589), + (94, 11), + // Flags: slot 0 nibble 0x9 (eff0 + cancelable), slot 2 nibble 0x8 (eff0, not cancelable). + (95, 0x0000_0809), + (99, 0x0000_0002), // slot 32, nibble 0x2 (eff2) + (100, 0xE000_0000), // slot 47, nibble 0xE in the top nibble + // Levels: slot 0 → 60, slot 2 → 5. + (101, 0x0005_003C), + (109, 0x0000_003C), // slot 32 → 60 + (112, 0x0100_0000), // slot 47 → 1 + // Applications: slot 0 → byte 0 (stack 1), slot 2 → byte 2 (stack 3). + (113, 0x0002_0000), + (124, 0xFE00_0000), // slot 47 → byte 254 (stack 255) + ]); + + let buff = f.unit_aura(0).expect("slot 0 occupied"); + assert_eq!( + buff, + UnitAuraSlot { + slot: 0, + spell_id: 1126, + flags: 0x9, + level: 60, + stacks: 1 + } + ); + assert!(buff.is_helpful() && buff.is_cancelable()); + assert!( + buff.flags & AURA_FLAG_EFF_INDEX_MASK != 0, + "an occupied slot always has an effect" + ); + + assert_eq!(f.unit_aura(1), None, "no spell id ⇒ empty slot"); + + let stacked = f.unit_aura(2).expect("slot 2 occupied"); + assert_eq!( + (stacked.spell_id, stacked.level, stacked.stacks), + (25, 5, 3) + ); + assert!(!stacked.is_cancelable(), "nibble 0x8 has no cancelable bit"); + + let debuff = f.unit_aura(32).expect("slot 32 occupied"); + assert_eq!( + debuff, + UnitAuraSlot { + slot: 32, + spell_id: 589, + flags: 0x2, + level: 60, + stacks: 1 + } + ); + assert!(!debuff.is_helpful() && !debuff.is_cancelable()); + + let last = f.unit_aura(47).expect("slot 47 occupied"); + assert_eq!((last.flags, last.level, last.stacks), (0xE, 1, 255)); + + assert_eq!(f.unit_aura(UNIT_AURA_SLOTS), None, "out of range"); + assert_eq!( + f.unit_auras().map(|a| a.slot).collect::>(), + [0, 2, 32, 47], + "occupied slots, ascending" + ); + assert_eq!(ObjectFields::default().unit_auras().count(), 0); +} + +/// A slot whose flags nibble the server cleared (`& 0x0E == 0`) is **empty**, even if a stale +/// spell id lingers in `UNIT_FIELD_AURA` — the client's own `flags & 0x0E` liveness test. Gating +/// on the spell id alone (as an earlier cut did) surfaced the husk as a phantom buff the real +/// client hides. The cancelable bit `0x1` on its own does not resurrect it (it is not an effect +/// bit), so this covers both a fully-zeroed nibble and a `0x1`-only one. +#[test] +fn a_stale_spell_id_with_a_cleared_flags_nibble_is_not_a_live_aura() { + let f = ObjectFields::from_pairs(&[ + // slot 0: a live buff — spell id + effect bit 0x8. + (47, 1126), + (95, 0x0000_0018), // slot 0 nibble 0x8 (eff0), slot 1 nibble 0x1 (cancelable only) + // slot 1: a HUSK — the server left the spell id but cleared every effect bit, keeping + // only the cancelable bit. `flags & 0x0E == 0`, so it is not live. + (48, 5000), + // slot 2: a HUSK — spell id present, flags nibble fully zero. + (49, 6000), + ]); + + assert!(f.unit_aura(0).is_some(), "slot 0 is a live buff"); + assert_eq!( + f.unit_aura(1), + None, + "a spell id with only the cancelable bit (no effect) is a husk, not a live aura" + ); + assert_eq!( + f.unit_aura(2), + None, + "a spell id with a zero flags nibble is a husk, not a live aura" + ); + assert_eq!( + f.unit_auras().map(|a| a.slot).collect::>(), + [0], + "only the live slot enumerates; the two husks are hidden" + ); +} + +/// The create-block absent-field semantics (director-caught: fully broken gear read as 100%). A +/// CREATE omits zero-valued fields (vmangos `_SetCreateBits`: mask bit iff `value != 0`), so a +/// create-seeded store must answer `Some(0)` for an absent field — the real client's +/// zero-initialized descriptor — while a bare `Values` delta keeps absent = `None` ("untouched"). +#[test] +fn created_store_reads_absent_fields_as_zero_a_delta_as_none() { + // A broken item's create: MAXDURABILITY present (40), DURABILITY omitted (it is 0). + let create = ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created(); + assert_eq!(create.item_durability(), Some(0), "created absent = 0"); + assert_eq!(create.item_max_durability(), Some(40)); + + // The same mask as a bare delta: absent means "not changed", never 0. + let delta = ObjectFields::from_pairs(&[(47, 40)]); + assert_eq!(delta.item_durability(), None, "delta absent = untouched"); + + // A guid slot keeps present-iff-low-half semantics even on a created store (consumers + // already treat a sent-empty `Some(0)` as no-guid; the fallback would only blur the two). + assert_eq!(create.corpse_owner(), None); +} + +/// Merging keeps the semantics straight: a delta overlays a created store without unsetting the +/// created mark, and a re-CREATE *replaces* the store — stale non-zero values for fields the +/// fresh snapshot omits (they dropped to zero out of view) must die with it. +#[test] +fn merge_overlays_deltas_and_replaces_on_recreate() { + let mut store = ObjectFields::from_pairs(&[(3, 2264), (46, 40), (47, 40)]).into_created(); + + // A durability-damage delta overlays; the store stays a complete (created) descriptor. + store.merge(ObjectFields::from_pairs(&[(46, 30)])); + assert_eq!(store.item_durability(), Some(30)); + assert_eq!( + store.item_max_durability(), + Some(40), + "untouched field kept" + ); + + // Re-create after the item broke out of view: the fresh snapshot omits DURABILITY (0). + store.merge(ObjectFields::from_pairs(&[(3, 2264), (47, 40)]).into_created()); + assert_eq!( + store.item_durability(), + Some(0), + "a re-create replaces — the stale 30 must not survive an overlay" + ); +} diff --git a/crates/benilla-protocol/src/messages/update_object/fields/unit.rs b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs new file mode 100644 index 00000000..9f3d0f02 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/fields/unit.rs @@ -0,0 +1,336 @@ +//! The unit/object/GameObject named accessors over [`ObjectFields`] — the UNIT_* family every +//! world entity carries (health/power/level/auras/combat stats), plus the object scale and the +//! GameObject block. Split from the one descriptor map (`mod.rs` holds the indices + codec). + +use super::*; + +impl ObjectFields { + /// `OBJECT_FIELD_SCALE_X` — the per-object size multiplier. + pub fn object_scale_x(&self) -> Option { + self.get_f32(FIELD_OBJECT_SCALE_X) + } + /// `UNIT_FIELD_DISPLAYID` (units + players). + pub fn unit_displayid(&self) -> Option { + self.get_i32(FIELD_UNIT_DISPLAYID) + } + /// `UNIT_FIELD_MOUNTDISPLAYID` — the `CreatureDisplayInfo` id of the mount this unit rides; + /// `0` (or absent) = not mounted. The one and only mounted signal on the wire (decision 0441). + pub fn unit_mount_display_id(&self) -> u32 { + self.get_u32(FIELD_UNIT_MOUNTDISPLAYID).unwrap_or(0) + } + /// `UNIT_FIELD_TARGET` — the guid this unit is attacking/interacting with (its "look at" target), + /// or `None` when it has none (an absent field or an explicit `0`). Drives the idle face-target turn. + pub fn unit_target(&self) -> Option { + self.get_guid(FIELD_UNIT_TARGET).filter(|&g| g != 0) + } + /// `UNIT_FIELD_CHANNEL_OBJECT` — the object a channel is aimed at (may be the caster itself), + /// or `None` when not channeling (an absent field or an explicit `0`). + pub fn unit_channel_object(&self) -> Option { + self.get_guid(FIELD_UNIT_CHANNEL_OBJECT).filter(|&g| g != 0) + } + /// `UNIT_CHANNEL_SPELL` — the spell id a unit is channeling; `0` when not channeling or absent. + pub fn unit_channel_spell(&self) -> u32 { + self.get_u32(FIELD_UNIT_CHANNEL_SPELL).unwrap_or(0) + } + /// `UNIT_FIELD_SUMMONEDBY` — the summoner's guid (pet/guardian/totem), or `None` (absent or + /// explicit 0 — an unowned unit). + pub fn unit_summoned_by(&self) -> Option { + self.get_guid(FIELD_UNIT_SUMMONEDBY).filter(|&g| g != 0) + } + /// `UNIT_FIELD_CREATEDBY` — the creator's guid (totems and created objects), or `None`. + pub fn unit_created_by(&self) -> Option { + self.get_guid(FIELD_UNIT_CREATEDBY).filter(|&g| g != 0) + } + /// `UNIT_FIELD_HEALTH` — current hit points (0 ⇒ dead). + pub fn unit_health(&self) -> Option { + self.get_u32(FIELD_UNIT_HEALTH) + } + /// `UNIT_FIELD_MAXHEALTH` — full hit points. + pub fn unit_max_health(&self) -> Option { + self.get_u32(FIELD_UNIT_MAXHEALTH) + } + /// Whether the unit is **dead** (health 0 on a real unit). Absent health reads as **zero** — a + /// create block only carries *non-zero* fields (vmangos `_SetCreateBits`), so an already-dead + /// corpse streams in with `MAXHEALTH` but **no** `HEALTH` at all; the real client's descriptor + /// array is zero-initialized, so absent *is* 0 to it. The `MAXHEALTH > 0` guard keeps a unit + /// whose snapshot hasn't arrived (empty store) from reading as dead. + pub fn unit_is_dead(&self) -> bool { + self.unit_max_health().unwrap_or(0) > 0 && self.unit_health().unwrap_or(0) == 0 + } + /// `UNIT_FIELD_LEVEL` — the unit's level. + pub fn unit_level(&self) -> Option { + self.get_u32(FIELD_UNIT_LEVEL) + } + /// One aura slot (0-based), or `None` when the slot is empty or `slot >= UNIT_AURA_SLOTS`. + /// + /// Occupancy is the **client's own liveness test**: at least one effect-index bit set in the + /// `AURAFLAGS` nibble (`flags & 0x0E`, decision 0257) — *not* "has a spell id". A slot the server + /// clears can keep a stale spell id after its flags nibble goes to zero; gating on the id alone + /// would surface that husk as a phantom aura the real client hides (`flags & 0x0E` is precisely + /// why it doesn't). The `spell_id != 0` guard stays as a belt-and-braces check — for a live aura + /// (an applied effect implies a spell) the two always coincide. + pub fn unit_aura(&self, slot: u8) -> Option { + if slot >= UNIT_AURA_SLOTS { + return None; + } + let flags = self.get_aura_nibble(slot); + if flags & AURA_FLAG_EFF_INDEX_MASK == 0 { + return None; + } + let spell_id = self + .get_u32(FIELD_UNIT_AURA + u16::from(slot)) + .filter(|&id| id != 0)?; + Some(UnitAuraSlot { + slot, + spell_id, + flags, + level: self.get_aura_byte(FIELD_UNIT_AURALEVELS, slot), + // The wire byte is `stack - 1` and saturates at 254, so an occupied slot is always ≥ 1. + stacks: self + .get_aura_byte(FIELD_UNIT_AURAAPPLICATIONS, slot) + .saturating_add(1), + }) + } + /// Every occupied aura slot, **ascending by slot index** — buffs (0–31) before debuffs (32–47). + /// + /// This is the reference client's ordering law for `UnitBuff`/`UnitDebuff` on **another unit**: + /// they read that unit's `UNIT_FIELD_AURA` straight, ascending raw slot within the half (wow-re + /// `0x519500`/`0x5198f0`, VERIFIED — decision 0257). + /// + /// It is **not** the order the player's own buff bar draws. For the local player the client + /// keeps a separate, densely packed cache (`0xbc6040`) in **insertion order**, which repacks on + /// removal — so a dropped buff slides the later ones along, and a new buff always lands at the + /// end rather than in whatever slot the server recycled. That order cannot be recovered from a + /// descriptor snapshot; it has to be *maintained* across updates (decision 0257). + pub fn unit_auras(&self) -> impl Iterator + '_ { + (0..UNIT_AURA_SLOTS).filter_map(|slot| self.unit_aura(slot)) + } + /// The unit's active power type (`UNIT_FIELD_BYTES_0` byte 3): `0` mana, `1` rage, `2` focus, + /// `3` energy, `4` happiness. Absent (no bytes_0 on the wire yet) reads as mana, the descriptor's + /// zero-initialized default. + pub fn unit_power_type(&self) -> u8 { + (self.unit_bytes_0().unwrap_or(0) >> 24) as u8 + } + /// `UNIT_FIELD_BYTES_1` byte 0 — the unit's stand state (VERIFIED vmangos + /// `UNIT_BYTES_1_OFFSET_STAND_STATE = 0`, written by `Unit::SetStandState`): 0 stand · 1 sit + /// (ground) · 2 sit-chair · 3 sleep · 4/5/6 chair low/med/high · 8 kneel. For a player it's + /// the echo of their own `CMSG_STANDSTATECHANGE`. `0` (standing) when the field is absent — + /// the descriptor's zero-initialized default. + pub fn unit_stand_state(&self) -> u8 { + (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) & 0xff) as u8 + } + /// `UNIT_FIELD_BYTES_1` byte 3's `UNIT_VIS_FLAGS_CREEP (0x2)` — the sneaking visual, set by + /// `SPELL_AURA_MOD_STEALTH` (vmangos `SpellAuras.cpp:3610`, + /// `UNIT_BYTES_1_OFFSET_VIS_FLAG = 3`). The usable walk's only-stealthed leg tests exactly + /// this bit (wow-re §2a leg 7: `[+0x110]+0x213 & 2`); the tracking classifier's + /// stealth always-show clause tests it against our own track-stealthed bit + /// (`0x5ed210`, wow-re `track-predicates.md`). + pub fn unit_is_stealthed(&self) -> bool { + (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x2 != 0 + } + /// `UNIT_FIELD_BYTES_1` byte 3's `UNIT_VIS_FLAGS_GHOST (0x1)` — set on ANY unit by the ghost + /// aura (vmangos `HandleAuraGhost`). Byte-VERIFIED (wow-re ghost-death-visuals.md): this flag + /// drives NO body render — its complete client effect is nameplate/marker suppression + /// (tested as `byte3 & 0x03`, ghost|creep, at `0x607101`/`0x60f62e`). The translucent + /// whitened BODY is the ghost aura's own state visual kit (spell 8326 → SpellVisual 886 → + /// kit 989: charproc-14 alpha 0.5 + charproc-1 tint 0xFF8CB9FD + `Spells\Ghost_state.mdx`) — + /// aura-driven, exactly like Stealth's 0.3 translucency. Consumers: the overhead-name and + /// V-plate gates. + pub fn unit_is_ghost_visual(&self) -> bool { + (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 24) & 0x1 != 0 + } + + /// `UNIT_FIELD_AURASTATE` ([`FIELD_UNIT_AURASTATE`]) — the aura-state bit set (defense 1, + /// healthless-20% 2, …); the usable walk tests `1 << (state-1)` against it. + pub fn unit_aura_state(&self) -> u32 { + self.get_u32(FIELD_UNIT_AURASTATE).unwrap_or(0) + } + + /// `UNIT_FIELD_BYTES_1` byte 2 — the shapeshift form (VERIFIED vmangos `UpdateFields_1_12_1.h` + /// 0x8A + `UNIT_BYTES_1_OFFSET_SHAPESHIFT_FORM = 2`): 0 none, 17/18/19 = warrior + /// battle/defensive/berserker stance, druid forms in the low ids. Drives the action bar's + /// bonus-page pick, and the tracking dots' creature-type override (the `0x605570` + /// resolver's first branch — decision 0564). + pub fn unit_shapeshift_form(&self) -> u8 { + (self.get_u32(FIELD_UNIT_BYTES_1).unwrap_or(0) >> 16) as u8 + } + /// `UNIT_FIELD_POWER1..5` — current power for slot `ty` (the [`Self::unit_power_type`] index). + /// Out-of-range slots and absent fields read `None`. + pub fn unit_power(&self, ty: u8) -> Option { + (ty < 5).then(|| self.get_u32(FIELD_UNIT_POWER1 + u16::from(ty)))? + } + /// `UNIT_FIELD_MAXPOWER1..5` — maximum power for slot `ty`. + pub fn unit_max_power(&self, ty: u8) -> Option { + (ty < 5).then(|| self.get_u32(FIELD_UNIT_MAXPOWER1 + u16::from(ty)))? + } + /// `UNIT_FIELD_BASE_MANA` (VERIFIED vmangos `UpdateFields_1_12_1.h` `OBJECT_END + 0x9C` = 162; + /// PRIVATE + OWNER_ONLY, so it rides only our own descriptor) — the base the percent-cost + /// spells (`ManaCostPercentage`) scale from. `None` when absent (a non-self unit). + pub fn unit_base_mana(&self) -> Option { + self.get_u32(FIELD_UNIT_BASE_MANA) + } + /// `UNIT_FIELD_FACTIONTEMPLATE` — the unit's `FactionTemplate.dbc` row id, the key the client's + /// whole reaction/hostility decode starts from (wow-re: descriptor `+0x74`, read by every + /// `UnitReaction`/`Can*` predicate). Flagged PUBLIC server-side, so it's always on the wire. + pub fn unit_faction_template(&self) -> Option { + self.get_u32(FIELD_UNIT_FACTIONTEMPLATE) + } + /// `UNIT_FIELD_FLAGS` — unit state flags; bit `0x0400_0000` = **skinnable** (the cursor's Skin + /// branch reads it — wow-re cursor RE, client `+0xa0`). Absent counts as 0 (create omits zeros). + pub fn unit_flags(&self) -> u32 { + self.get_u32(FIELD_UNIT_FLAGS).unwrap_or(0) + } + /// `UNIT_FIELD_COMBATREACH` — the melee-reach float the client's edge-to-edge attack range gate + /// reads (`rA + rB + 1.333`, cap 5.0 — wow-re cursor RE `0x6e3480`, client `+0x1f0`). Vanilla + /// default 1.5 when absent. + pub fn unit_combat_reach(&self) -> f32 { + self.get_f32(FIELD_UNIT_COMBATREACH).unwrap_or(1.5) + } + /// `UNIT_FIELD_BOUNDINGRADIUS` — the unit's horizontal bounding radius (yd). The water-foam + /// depth gate reads it (`max(2 × radius, 1.0)` — wow-re CWater0Ripple driver `0x5fa760`). + /// Absent counts as 0 — the gate's own 1.0-yd floor covers it, like the reference. + pub fn unit_bounding_radius(&self) -> f32 { + self.get_f32(FIELD_UNIT_BOUNDINGRADIUS).unwrap_or(0.0) + } + /// `UNIT_DYNAMIC_FLAGS` — per-viewer dynamic state; bit `0x1` = **lootable** (the cursor's Loot + /// branch — wow-re cursor RE, client `+0x224`). Absent counts as 0. + pub fn unit_dynamic_flags(&self) -> u32 { + self.get_u32(FIELD_UNIT_DYNAMIC_FLAGS).unwrap_or(0) + } + /// `UNIT_DYNAMIC_FLAGS` bit `0x1` — **lootable by me** (the server strips the bit per viewer + /// before it ships — vmangos's "hide lootable animation for unallowed players"). Gates the + /// loot cursor, the right-click loot route, and the corpse sparkle (wow-re + /// `loot-corpse-effect.md`). + pub fn unit_lootable(&self) -> bool { + self.unit_dynamic_flags() & 0x1 != 0 + } + /// `UNIT_NPC_FLAGS` — the NPC service bits (vanilla values: gossip `0x1`, questgiver `0x2`, + /// vendor `0x4`, flightmaster `0x8`, trainer `0x10`, … repair `0x4000` — vmangos + /// `UnitDefines.h`). The cursor's service ladder switches on these, first bit wins (wow-re + /// cursor RE, client `+0x234`). Absent counts as 0. + pub fn unit_npc_flags(&self) -> u32 { + self.get_u32(FIELD_UNIT_NPC_FLAGS).unwrap_or(0) + } + /// `UNIT_NPC_EMOTESTATE` — the unit's looping state emote (`Emotes.dbc` id: dance, NPC + /// cooking/working flavor; `0`/absent = none). The anim layer loops its `AnimID` while the + /// unit stands; movement suppresses it (the field stays set server-side until cleared). + pub fn unit_emote_state(&self) -> u32 { + self.get_u32(FIELD_UNIT_NPC_EMOTESTATE).unwrap_or(0) + } + /// `UNIT_FIELD_BYTES_0` — packed race (byte 0) / class (byte 1) / gender (byte 2) / power type (3). + pub fn unit_bytes_0(&self) -> Option { + self.get_u32(FIELD_UNIT_BYTES_0) + } + /// `UNIT_VIRTUAL_ITEM_SLOT_DISPLAY + slot` — a creature's virtual-weapon **display id** for + /// `slot` (0 mainhand / 1 offhand / 2 ranged): the item's `DisplayInfoID` directly, no item guid + /// behind it (see the field's doc comment). `slot >= 3` reads `None`. + pub fn unit_virtual_item_display(&self, slot: u8) -> Option { + (slot < 3).then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_SLOT_DISPLAY + u16::from(slot)))? + } + /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot` — `slot`'s flavor bytes: `(class, subclass, material, + /// inventory_type)`. `slot >= 3` reads `None`. + pub fn unit_virtual_item_info(&self, slot: u8) -> Option<(u8, u8, u8, u8)> { + let v = + (slot < 3).then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_INFO + 2 * u16::from(slot)))?; + v.map(|v| { + ( + (v & 0xff) as u8, + ((v >> 8) & 0xff) as u8, + ((v >> 16) & 0xff) as u8, + ((v >> 24) & 0xff) as u8, + ) + }) + } + /// `UNIT_VIRTUAL_ITEM_INFO + 2*slot + 1` byte 0 — `slot`'s virtual item Sheath. `slot >= 3` + /// reads `None`. + pub fn unit_virtual_item_sheath(&self, slot: u8) -> Option { + let v = (slot < 3) + .then(|| self.get_u32(FIELD_UNIT_VIRTUAL_ITEM_INFO + 2 * u16::from(slot) + 1))?; + v.map(|v| (v & 0xff) as u8) + } + /// `UNIT_FIELD_BYTES_2` byte 0 — the unit's sheath state: `0` unarmed/stowed, `1` melee drawn, + /// `2` ranged drawn. For a player this is purely what they last told the server via + /// `CMSG_SETSHEATHED`, not a server-computed fact. + pub fn unit_sheath_state(&self) -> Option { + self.get_u32(FIELD_UNIT_BYTES_2).map(|v| (v & 0xff) as u8) + } + /// `UNIT_FIELD_STAT0..4 + i` — the unit's raw (post-buff) primary stat `i` (0 Str, 1 Agi, + /// 2 Stam, 3 Int, 4 Spirit — the `Stats` enum order). `i >= 5` reads `None`. + pub fn unit_stat(&self, i: u8) -> Option { + (i < 5).then(|| self.get_u32(FIELD_UNIT_STAT0 + u16::from(i)))? + } + /// `UNIT_FIELD_RESISTANCES + i` — resistance `i` (0 = armor, 1..6 the magic schools). `i >= 7` + /// reads `None`. + pub fn unit_resistance(&self, i: u8) -> Option { + (i < 7).then(|| self.get_i32(FIELD_UNIT_RESISTANCES + u16::from(i)))? + } + /// `UNIT_FIELD_ATTACK_POWER` — melee attack power before the split pos/neg mods. + pub fn unit_attack_power(&self) -> Option { + self.get_i32(FIELD_UNIT_ATTACK_POWER) + } + /// `UNIT_FIELD_ATTACK_POWER_MODS` — `(positive, negative)` flat AP mods (the negative half + /// already negative-or-zero; see the field's doc comment for the verified packing/sign). + /// Absent reads `(0, 0)` — no mods applied yet. + pub fn unit_attack_power_mods(&self) -> (i16, i16) { + let (lo, hi) = self + .get_u16_pair(FIELD_UNIT_ATTACK_POWER_MODS) + .unwrap_or((0, 0)); + (lo as i16, hi as i16) + } + /// `UNIT_FIELD_ATTACK_POWER_MULTIPLIER` — the percent AP bonus **minus 1.0** (`0.0` = no + /// bonus; vmangos writes `multiplier − 1.0`, see the field's doc comment). + pub fn unit_attack_power_multiplier(&self) -> Option { + self.get_f32(FIELD_UNIT_ATTACK_POWER_MULTIPLIER) + } + /// `UNIT_FIELD_RANGED_ATTACK_POWER` — ranged attack power before the split pos/neg mods. + pub fn unit_ranged_attack_power(&self) -> Option { + self.get_i32(FIELD_UNIT_RANGED_ATTACK_POWER) + } + /// `UNIT_FIELD_RANGED_ATTACK_POWER_MODS` — the ranged twin of + /// [`Self::unit_attack_power_mods`]. + pub fn unit_ranged_attack_power_mods(&self) -> (i16, i16) { + let (lo, hi) = self + .get_u16_pair(FIELD_UNIT_RANGED_ATTACK_POWER_MODS) + .unwrap_or((0, 0)); + (lo as i16, hi as i16) + } + /// `UNIT_FIELD_RANGED_ATTACK_POWER_MULTIPLIER` — the ranged twin of + /// [`Self::unit_attack_power_multiplier`]. + pub fn unit_ranged_attack_power_multiplier(&self) -> Option { + self.get_f32(FIELD_UNIT_RANGED_ATTACK_POWER_MULTIPLIER) + } + /// `UNIT_FIELD_MINDAMAGE` — the mainhand weapon's minimum damage. + pub fn unit_min_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MINDAMAGE) + } + /// `UNIT_FIELD_MAXDAMAGE` — the mainhand weapon's maximum damage. + pub fn unit_max_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MAXDAMAGE) + } + /// `UNIT_FIELD_MINOFFHANDDAMAGE` — the offhand weapon's minimum damage (present whether or not + /// one's equipped; the paper-doll only shows it when an offhand item is worn). + pub fn unit_min_offhand_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MINOFFHANDDAMAGE) + } + /// `UNIT_FIELD_MAXOFFHANDDAMAGE` — the offhand weapon's maximum damage. + pub fn unit_max_offhand_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MAXOFFHANDDAMAGE) + } + /// `UNIT_FIELD_MINRANGEDDAMAGE` — the ranged weapon's minimum damage. + pub fn unit_min_ranged_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MINRANGEDDAMAGE) + } + /// `UNIT_FIELD_MAXRANGEDDAMAGE` — the ranged weapon's maximum damage. + pub fn unit_max_ranged_damage(&self) -> Option { + self.get_f32(FIELD_UNIT_MAXRANGEDDAMAGE) + } + /// `UNIT_FIELD_BASEATTACKTIME + i` — attack speed in ms (`i` 0 mainhand, 1 offhand). `i >= 2` + /// reads `None`. + pub fn unit_base_attack_time(&self, i: u8) -> Option { + (i < 2).then(|| self.get_u32(FIELD_UNIT_BASEATTACKTIME + u16::from(i)))? + } + /// `UNIT_FIELD_RANGEDATTACKTIME` — ranged weapon attack speed in ms. + pub fn unit_ranged_attack_time(&self) -> Option { + self.get_u32(FIELD_UNIT_RANGEDATTACKTIME) + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/mod.rs b/crates/benilla-protocol/src/messages/update_object/mod.rs new file mode 100644 index 00000000..e32e6eb9 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/mod.rs @@ -0,0 +1,142 @@ +//! `SMSG_UPDATE_OBJECT` body decode: the object list, each entry's [`MovementBlock`] pose and sparse +//! [`ObjectFields`] of descriptor fields. The complex, *growing* core of the message layer (decision +//! 0021) — split out because the descriptor-field decode expands as the object layer does. The parent +//! module ([`super`]) owns packet framing/dispatch and the client builders, and the shared +//! `MOVEMENT_FLAG_*` constants (also used by its movement-info parse), which this module reaches via +//! `super::`. + +use std::io::{self, Read}; + +use crate::wire::{read_packed_guid, read_u32_le, read_u8}; + +mod fields; +mod movement; + +pub use fields::{ + quest_slot_state, ObjectFields, PlayerSkillSlot, QuestLogSlot, UnitAuraSlot, + AURA_FLAG_CANCELABLE, AURA_FLAG_EFF_INDEX_MASK, PLAYER_EXPLORED_ZONES_SLOTS, + PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS, +}; +pub use movement::{MovementBlock, ObjectType}; + +/// One entry in an `SMSG_UPDATE_OBJECT` object list. +pub enum Object { + Values { + guid: u64, + mask: ObjectFields, + }, + Movement { + guid: u64, + movement: MovementBlock, + }, + /// `CREATE_OBJECT` / `CREATE_OBJECT2` (identical wire shape; the distinction is irrelevant here). + Create { + guid: u64, + object_type: ObjectType, + movement: MovementBlock, + mask: ObjectFields, + }, + OutOfRange { + guids: Vec, + }, + Near { + guids: Vec, + }, +} + +impl Object { + fn read(r: &mut impl Read) -> io::Result { + let update_type = read_u8(r)?; + Ok(match update_type { + 0 => Object::Values { + guid: read_packed_guid(r)?, + mask: ObjectFields::read(r)?, + }, + 1 => Object::Movement { + guid: read_packed_guid(r)?, + movement: MovementBlock::read(r)?, + }, + 2 | 3 => { + let guid = read_packed_guid(r)?; + let object_type = ObjectType::from_u8(read_u8(r)?); + let movement = MovementBlock::read(r)?; + // A create's mask is the COMPLETE descriptor — the server omits zero-valued + // fields (vmangos `_SetCreateBits`), so absent must read 0, not unknown + // (`ObjectFields`'s created semantics). + let mask = ObjectFields::read(r)?.into_created(); + Object::Create { + guid, + object_type, + movement, + mask, + } + } + 4 => Object::OutOfRange { + guids: read_guid_list(r)?, + }, + 5 => Object::Near { + guids: read_guid_list(r)?, + }, + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown object update_type {other}"), + )) + } + }) + } +} + +fn read_guid_list(r: &mut impl Read) -> io::Result> { + let count = read_u32_le(r)?; + let mut guids = Vec::with_capacity(count.min(0xFFFF) as usize); + for _ in 0..count { + guids.push(read_packed_guid(r)?); + } + Ok(guids) +} + +/// Parse an `SMSG_UPDATE_OBJECT` body's object list (the count + has-transport byte + each `Object`). +pub(super) fn read_update_object(r: &mut impl Read) -> io::Result> { + let amount_of_objects = read_u32_le(r)?; + let _has_transport = read_u8(r)?; + let mut objects = Vec::with_capacity(amount_of_objects.min(0xFFFF) as usize); + for _ in 0..amount_of_objects { + objects.push(Object::read(r)?); + } + Ok(objects) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quest_log_slot_unpacks_counters_and_state() { + // Slot 2 (base 198 + 6 = 204): counters 5/10/63/0 packed at 6-bit strides, state byte + // COMPLETE, a timer value. Counter packing per vmangos Player.h:1100-1106. + let count_state = + 5u32 | (10 << 6) | (63 << 12) | ((quest_slot_state::COMPLETE as u32) << 24); + let f = ObjectFields::from_pairs(&[(204, 783), (205, count_state), (206, 4242)]); + assert_eq!( + f.player_quest_log(2), + Some(QuestLogSlot { + quest_id: 783, + counters: [5, 10, 63, 0], + state: quest_slot_state::COMPLETE, + timer: 4242, + }) + ); + // The neighbouring slots never streamed → None; out of range → None. + assert_eq!(f.player_quest_log(1), None); + assert_eq!(f.player_quest_log(3), None); + assert_eq!(f.player_quest_log(PLAYER_QUEST_LOG_SLOTS), None); + } + + #[test] + fn quest_log_cleared_slot_reads_zero_id() { + // An abandoned slot: the server zeroes the id (and pair) — still `Some`, id 0. + let f = ObjectFields::from_pairs(&[(198, 0), (199, 0), (200, 0)]); + assert_eq!(f.player_quest_log(0).map(|s| s.quest_id), Some(0)); + } +} diff --git a/crates/benilla-protocol/src/messages/update_object/movement.rs b/crates/benilla-protocol/src/messages/update_object/movement.rs new file mode 100644 index 00000000..de004916 --- /dev/null +++ b/crates/benilla-protocol/src/messages/update_object/movement.rs @@ -0,0 +1,166 @@ +use std::io::{self, Read}; + +use crate::messages::movement::{ + TransportPose, MOVEMENT_FLAG_JUMPING, MOVEMENT_FLAG_ON_TRANSPORT, + MOVEMENT_FLAG_SPLINE_ELEVATION, MOVEMENT_FLAG_SPLINE_ENABLED, MOVEMENT_FLAG_SWIMMING, +}; +use crate::wire::{read_f32_le, read_packed_guid, read_u32_le, read_u64_le, read_u8, Vector3d}; + +/// Object class (the `TypeId` on a create packet). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectType { + Object, + Item, + Container, + Unit, + Player, + GameObject, + DynamicObject, + Corpse, +} + +impl ObjectType { + pub(super) fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Item, + 2 => Self::Container, + 3 => Self::Unit, + 4 => Self::Player, + 5 => Self::GameObject, + 6 => Self::DynamicObject, + 7 => Self::Corpse, + _ => Self::Object, + } + } +} + +// MovementBlock update_flag bits (UpdateObject-only; the shared MOVEMENT_FLAG_* live in `super`). +const UPDATE_FLAG_TRANSPORT: u8 = 0x02; +const UPDATE_FLAG_MELEE_ATTACKING: u8 = 0x04; +const UPDATE_FLAG_HIGH_GUID: u8 = 0x08; +const UPDATE_FLAG_ALL: u8 = 0x10; +const UPDATE_FLAG_LIVING: u8 = 0x20; +const UPDATE_FLAG_HAS_POSITION: u8 = 0x40; +// SplineFlag final-destination bits (checked angle → target → point). +const SPLINE_FLAG_FINAL_POINT: u32 = 0x1_0000; +const SPLINE_FLAG_FINAL_TARGET: u32 = 0x2_0000; +const SPLINE_FLAG_FINAL_ANGLE: u32 = 0x4_0000; + +/// The decoded fields a movement block carries: the pose (from its `LIVING`/`HAS_POSITION` flag), +/// for a `LIVING` block the unit's 6 movement speeds and (if `ON_TRANSPORT`) its rider pose, and (if +/// `UPDATE_FLAG_TRANSPORT`) a transport GameObject's path progress. The rest of the block is parsed and +/// discarded to stay aligned. +pub struct MovementBlock { + pub position: Option<(Vector3d, f32)>, + /// A `LIVING` block's 6 movement speeds (yd/s) in wire order `[walk, run, run_back, swim, swim_back, + /// turn_rate]`; `None` for a `HAS_POSITION`-only block (GameObjects, which don't move under their + /// own power). The animation selector keys walk-vs-run on `walk` (boundary 2× it — RF-0057); the + /// net bridge extrapolates a remote mover between packets at `run`/`run_back`/`swim` + `turn_rate`. + pub speeds: Option<[f32; 6]>, + /// A transport GameObject's (`UPDATE_FLAG_TRANSPORT`, 0x02) path progress: the server's ms clock in + /// the path domain (vmangos `Object.cpp:590-605` — `GenericTransport::GetPathProgress()`, the same + /// `m_pathProgress` `ShipTransport::Update`/`CalculateSegmentPos` key off, `Transports/Transport.cpp: + /// 285-301`). `None` for every non-transport object: vmangos raises `UPDATEFLAG_TRANSPORT` only in + /// the transport GO paths — `ShipTransport`'s ctor (`Transport.cpp:39`) and the type-11 elevator + /// branch of `GameObject::Create` (`GameObject.cpp:246`); units/players never set it + /// (`Unit.cpp:88`). This is decision 0438's + /// **cycle anchor**: `anchor = this value`, `t₀ = Instant::now()` on create, `progress = anchor + + /// elapsed_ms` thereafter. + pub transport_progress: Option, + /// A `LIVING` block's rider pose on a transport (`MOVEFLAG_ON_TRANSPORT`, 0x0200_0000) — `Some` when this + /// unit/player is standing on a boat/zeppelin/elevator, its position local to that transport's frame + /// (decision 0438 "Riding is the mover's platform frame" — an observed rider carried this way + /// re-anchors through the transport's live matrix each frame, never treating `pos` as world-space). + pub transport: Option, +} + +impl MovementBlock { + pub(super) fn read(r: &mut impl Read) -> io::Result { + let update_flag = read_u8(r)?; + let mut position = None; + let mut speeds = None; + let mut transport = None; + let mut transport_progress = None; + + if update_flag & UPDATE_FLAG_LIVING != 0 { + let flags = read_u32_le(r)?; + let _timestamp = read_u32_le(r)?; + let living_position = Vector3d::read(r)?; + let living_orientation = read_f32_le(r)?; + position = Some((living_position, living_orientation)); + + if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { + // A FULL u64, not packed: the LIVING block serializes through the same vmangos + // `MovementInfo::Write` as the MSG_MOVE relays (`Object.cpp:524` `*data << m`), + // whose `data << t_guid` is the plain-u64 `ObjectGuid` operator. + transport = Some(TransportPose { + guid: read_u64_le(r)?, + pos: Vector3d::read(r)?, + orientation: read_f32_le(r)?, + }); + } + if flags & MOVEMENT_FLAG_SWIMMING != 0 { + let _pitch = read_f32_le(r)?; + } + let _fall_time = read_f32_le(r)?; + if flags & MOVEMENT_FLAG_JUMPING != 0 { + for _ in 0..4 { + let _ = read_f32_le(r)?; // z_speed, cos_angle, sin_angle, xy_speed + } + } + if flags & MOVEMENT_FLAG_SPLINE_ELEVATION != 0 { + let _ = read_f32_le(r)?; + } + // The 6 movement speeds (RF-0058 order): [0]=walk, [1]=run, [2]=run-back, [3]=swim, + // [4]=swim-back, [5]=turn-rate. The animation selector keys walk-vs-run on the walk speed + // (boundary = 2× it — RF-0057); the net bridge extrapolates a remote mover with the rest. + let mut s = [0.0f32; 6]; + for slot in &mut s { + *slot = read_f32_le(r)?; + } + speeds = Some(s); + if flags & MOVEMENT_FLAG_SPLINE_ENABLED != 0 { + let spline_flags = read_u32_le(r)?; + if spline_flags & SPLINE_FLAG_FINAL_ANGLE != 0 { + let _ = read_f32_le(r)?; + } else if spline_flags & SPLINE_FLAG_FINAL_TARGET != 0 { + let _ = read_u64_le(r)?; + } else if spline_flags & SPLINE_FLAG_FINAL_POINT != 0 { + let _ = Vector3d::read(r)?; + } + let _time_passed = read_u32_le(r)?; + let _duration = read_u32_le(r)?; + let _id = read_u32_le(r)?; + let amount_of_nodes = read_u32_le(r)?; + for _ in 0..amount_of_nodes { + let _ = Vector3d::read(r)?; + } + let _final_node = Vector3d::read(r)?; + } + } else if update_flag & UPDATE_FLAG_HAS_POSITION != 0 { + let pos = Vector3d::read(r)?; + let orientation = read_f32_le(r)?; + position = Some((pos, orientation)); + } + + if update_flag & UPDATE_FLAG_HIGH_GUID != 0 { + let _ = read_u32_le(r)?; + } + if update_flag & UPDATE_FLAG_ALL != 0 { + let _ = read_u32_le(r)?; + } + if update_flag & UPDATE_FLAG_MELEE_ATTACKING != 0 { + let _ = read_packed_guid(r)?; + } + if update_flag & UPDATE_FLAG_TRANSPORT != 0 { + transport_progress = Some(read_u32_le(r)?); + } + + Ok(Self { + position, + speeds, + transport_progress, + transport, + }) + } +} diff --git a/crates/benilla-protocol/src/messages/vendor.rs b/crates/benilla-protocol/src/messages/vendor.rs new file mode 100644 index 00000000..4345f0f5 --- /dev/null +++ b/crates/benilla-protocol/src/messages/vendor.rs @@ -0,0 +1,153 @@ +//! Vendor messages — `CMSG_LIST_INVENTORY`/buy/sell (opcodes 414-421, vmangos `Opcodes_1_12_1.h`, +//! VERIFIED). Bodies from vmangos `Item.{h,cpp}` + the hand-serialized `ItemHandler.cpp`. Lives +//! beside, not inside, [`super::items`] (the item-*template* query pair): vendor rows carry their +//! own wire shape and the buy/sell result vocabulary, unrelated to +//! `SMSG_ITEM_QUERY_SINGLE_RESPONSE`. + +use std::io; + +use crate::wire::{read_u32_le, read_u64_le, read_u8}; + +/// One vendor row (`SMSG_LIST_INVENTORY`, vmangos `ItemHandler.cpp:741-810`). `slot` (the wire's +/// `muid`) is the row's 1-based position in the vendor's list — **not** what buying uses; buying +/// addresses the item by [`VendorItem::entry`] (see [`buy_item`]). `current_count` of +/// `0xFFFF_FFFF` means unlimited stock; `price` is already reputation-discounted server-side. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VendorItem { + pub slot: u32, + pub entry: u32, + /// `item_template.display_id` → icon via `ItemDisplayInfo.dbc`. + pub display_id: u32, + pub current_count: u32, + /// Buy price in copper. + pub price: u32, + pub max_durability: u32, + /// Stack size delivered per purchase (`item_template.buy_count`). + pub buy_count: u32, +} + +/// `BuyResult` (vmangos `ItemDefines.h:120-141`) — the `u8` reason on `SMSG_BUY_FAILED`. +pub mod buy_result { + pub const CANT_FIND_ITEM: u8 = 0; + pub const ITEM_ALREADY_SOLD: u8 = 1; + pub const NOT_ENOUGH_MONEY: u8 = 2; + pub const SELLER_DONT_LIKE_YOU: u8 = 4; + pub const DISTANCE_TOO_FAR: u8 = 5; + pub const ITEM_SOLD_OUT: u8 = 7; + pub const CANT_CARRY_MORE: u8 = 8; + pub const RANK_REQUIRE: u8 = 11; + pub const REPUTATION_REQUIRE: u8 = 12; +} + +/// `SellResult` (vmangos `ItemDefines.h:120-141`) — the `u8` reason on `SMSG_SELL_ITEM`. A +/// *successful* sell sends no packet at all (the item vanishes + coinage rises via +/// `UPDATE_OBJECT`); only the error path (`SendSellError`, `Player.cpp:11723`) uses this shape. +pub mod sell_result { + pub const CANT_FIND_ITEM: u8 = 1; + pub const CANT_SELL_ITEM: u8 = 2; + pub const CANT_FIND_VENDOR: u8 = 3; + pub const YOU_DONT_OWN_THAT_ITEM: u8 = 4; + pub const UNK: u8 = 5; + pub const ONLY_EMPTY_BAG: u8 = 6; +} + +/// Body of `CMSG_LIST_INVENTORY` (vmangos `Item.cpp:94`): one full 8-byte vendor guid. The server +/// gates on `UNIT_NPC_FLAG_VENDOR` + the player being alive (`ItemHandler.cpp:693`). +pub fn list_inventory(vendor_guid: u64) -> Vec { + vendor_guid.to_le_bytes().to_vec() +} + +/// Body of `CMSG_BUY_ITEM` (vmangos `Item.cpp:104-110`): `u64 vendorGuid, u32 item` (the template +/// **entry**, not the vendor row's `muid`), `u8 count` (stacks to buy), `u8 unk1` (always 0). +/// Auto-places into the first free bag slot — the specific-bag-slot variant +/// (`CMSG_BUY_ITEM_IN_SLOT`, [`super::opcode::CMSG_BUY_ITEM_IN_SLOT`]) is out of scope for v1 +/// (decision 0081). Handler → `BuyItemFromVendor` (`ItemHandler.cpp:688`). +pub fn buy_item(vendor_guid: u64, entry: u32, count: u8) -> Vec { + let mut body = Vec::with_capacity(14); + body.extend_from_slice(&vendor_guid.to_le_bytes()); + body.extend_from_slice(&entry.to_le_bytes()); + body.push(count); + body.push(0); // unk1 + body +} + +/// Body of `CMSG_SELL_ITEM` (vmangos `Item.cpp:87-92`): `u64 vendorGuid, u64 itemGuid, u8 count` +/// (0 = sell the whole stack). Handler `ItemHandler.cpp:442`. +pub fn sell_item(vendor_guid: u64, item_guid: u64, count: u8) -> Vec { + let mut body = Vec::with_capacity(17); + body.extend_from_slice(&vendor_guid.to_le_bytes()); + body.extend_from_slice(&item_guid.to_le_bytes()); + body.push(count); + body +} + +/// Body of `CMSG_BUYBACK_ITEM` (VERIFIED wow-re ui/scratch/buyback-data-path.md, `0x4fb950`: +/// `u64 vendorGuid, u32 slot` — the slot is the **absolute** player-array buyback slot 69–80 +/// (`BUYBACK_SLOT_START` + index), sent raw with no re-base; vmangos `HandleBuybackItem` reads it +/// the same way). +pub fn buyback_item(vendor_guid: u64, slot: u32) -> Vec { + let mut body = Vec::with_capacity(12); + body.extend_from_slice(&vendor_guid.to_le_bytes()); + body.extend_from_slice(&slot.to_le_bytes()); + body +} + +/// Body of `CMSG_REPAIR_ITEM` (VERIFIED wow-re ui/scratch/repair-machinery.md, all 4 client send +/// sites: `u64 vendorGuid, u64 itemGuid`; `itemGuid == 0` = repair everything. vmangos +/// `HandleRepairItemOpcode` corroborates). +pub fn repair_item(vendor_guid: u64, item_guid: u64) -> Vec { + let mut body = Vec::with_capacity(16); + body.extend_from_slice(&vendor_guid.to_le_bytes()); + body.extend_from_slice(&item_guid.to_le_bytes()); + body +} + +/// Read `SMSG_LIST_INVENTORY` (vmangos `ItemHandler.cpp:741-810`): `u64 vendorGuid, u8 count` +/// (≤ `MAX_VENDOR_ITEMS` 128) then `count` rows of [`VendorItem`]. The empty-vendor case sends +/// `count = 0` followed by a trailing `u8` error byte (`ItemHandler.cpp:728-733,806-809`); this +/// parser tolerates it by construction — the row loop simply doesn't run, so the byte is left +/// harmlessly unconsumed rather than misread as a row. +pub(super) fn read_list_inventory(r: &mut &[u8]) -> io::Result<(u64, Vec)> { + let vendor_guid = read_u64_le(r)?; + let count = read_u8(r)?; + let mut items = Vec::with_capacity(count as usize); + for _ in 0..count { + items.push(VendorItem { + slot: read_u32_le(r)?, + entry: read_u32_le(r)?, + display_id: read_u32_le(r)?, + current_count: read_u32_le(r)?, + price: read_u32_le(r)?, + max_durability: read_u32_le(r)?, + buy_count: read_u32_le(r)?, + }); + } + Ok((vendor_guid, items)) +} + +/// Read `SMSG_BUY_ITEM` (vmangos `Item.cpp:190-196`, sent from `Player.cpp:18637`): `u64 +/// vendorGuid, u32 vendorSlot` (1-based), `u32 newCount` (new stock; `0xFFFF_FFFF` unlimited), +/// `u32 purchaseCount`. Only updates the vendor stock display — the purchased item itself arrives +/// through the normal `SMSG_ITEM_PUSH_RESULT` + `UPDATE_OBJECT` path (`SendNewItem`, +/// `Player.cpp:18644`), which benilla's inventory already handles. +pub(super) fn read_buy_item(r: &mut &[u8]) -> io::Result<(u64, u32, u32, u32)> { + Ok(( + read_u64_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + read_u32_le(r)?, + )) +} + +/// Read `SMSG_SELL_ITEM` (vmangos `Item.cpp:183-188`): `u64 vendorGuid, u64 itemGuid, u8 reason` +/// (a [`sell_result`] code). Only the error path sends this packet at all — a successful sell is +/// silent, visible only as the item's removal + coinage rising via `UPDATE_OBJECT`. +pub(super) fn read_sell_item(r: &mut &[u8]) -> io::Result<(u64, u64, u8)> { + Ok((read_u64_le(r)?, read_u64_le(r)?, read_u8(r)?)) +} + +/// Read `SMSG_BUY_FAILED` (vmangos `Item.h:277`): `u64 vendorGuid, u32 itemEntry, u8 reason` +/// (a [`buy_result`] code). +pub(super) fn read_buy_failed(r: &mut &[u8]) -> io::Result<(u64, u32, u8)> { + Ok((read_u64_le(r)?, read_u32_le(r)?, read_u8(r)?)) +} diff --git a/crates/benilla-protocol/src/wire.rs b/crates/benilla-protocol/src/wire.rs new file mode 100644 index 00000000..38c6e0e4 --- /dev/null +++ b/crates/benilla-protocol/src/wire.rs @@ -0,0 +1,163 @@ +//! Low-level wire read/write helpers shared by the auth ([`crate::auth`]) and world +//! ([`crate::world`]) protocols. Everything is little endian unless noted; these mirror the exact +//! field encodings the 1.12 client uses. + +use std::io::{self, Read, Write}; + +/// WoW packs an 8-byte GUID as a one-byte mask (which of the 8 bytes are non-zero) followed by only +/// those non-zero bytes, low to high — used throughout the world protocol for object references. +pub fn read_packed_guid(r: &mut impl Read) -> io::Result { + let mask = read_u8(r)?; + let mut guid = 0u64; + for i in 0..8 { + if mask & (1 << i) != 0 { + guid |= (u64::from(read_u8(r)?)) << (i * 8); + } + } + Ok(guid) +} + +/// Write a packed GUID (see [`read_packed_guid`]). +pub fn write_packed_guid(guid: u64, w: &mut impl Write) -> io::Result<()> { + let bytes = guid.to_le_bytes(); + let mut mask = 0u8; + let mut out = [0u8; 9]; + let mut idx = 1; + for (i, &b) in bytes.iter().enumerate() { + if b != 0 { + mask |= 1 << i; + out[idx] = b; + idx += 1; + } + } + out[0] = mask; + w.write_all(&out[..idx]) +} + +pub fn read_u8(r: &mut impl Read) -> io::Result { + let mut b = [0u8; 1]; + r.read_exact(&mut b)?; + Ok(b[0]) +} + +pub fn read_u16_le(r: &mut impl Read) -> io::Result { + let mut b = [0u8; 2]; + r.read_exact(&mut b)?; + Ok(u16::from_le_bytes(b)) +} + +pub fn read_u32_le(r: &mut impl Read) -> io::Result { + let mut b = [0u8; 4]; + r.read_exact(&mut b)?; + Ok(u32::from_le_bytes(b)) +} + +pub fn read_u32_be(r: &mut impl Read) -> io::Result { + let mut b = [0u8; 4]; + r.read_exact(&mut b)?; + Ok(u32::from_be_bytes(b)) +} + +pub fn read_i32_le(r: &mut impl Read) -> io::Result { + Ok(read_u32_le(r)? as i32) +} + +pub fn read_u64_le(r: &mut impl Read) -> io::Result { + let mut b = [0u8; 8]; + r.read_exact(&mut b)?; + Ok(u64::from_le_bytes(b)) +} + +pub fn read_f32_le(r: &mut impl Read) -> io::Result { + Ok(f32::from_bits(read_u32_le(r)?)) +} + +/// Read a fixed-length byte array. +pub fn read_array(r: &mut impl Read) -> io::Result<[u8; N]> { + let mut b = [0u8; N]; + r.read_exact(&mut b)?; + Ok(b) +} + +/// Read a NUL-terminated string (the NUL is consumed). Invalid UTF-8 is lossily replaced. +pub fn read_cstring(r: &mut impl Read) -> io::Result { + let mut bytes = Vec::new(); + loop { + let b = read_u8(r)?; + if b == 0 { + break; + } + bytes.push(b); + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +/// A 3-float position/vector in raw WoW coordinates. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Vector3d { + pub x: f32, + pub y: f32, + pub z: f32, +} + +impl Vector3d { + pub fn read(r: &mut impl Read) -> io::Result { + Ok(Self { + x: read_f32_le(r)?, + y: read_f32_le(r)?, + z: read_f32_le(r)?, + }) + } + + pub fn write(&self, w: &mut impl Write) -> io::Result<()> { + w.write_all(&self.x.to_le_bytes())?; + w.write_all(&self.y.to_le_bytes())?; + w.write_all(&self.z.to_le_bytes()) + } +} + +/// Decode a movement-spline packed point (`SMSG_MONSTER_MOVE` packs every point after the first this +/// way): **signed** two's-complement fields — 11 bits x, 11 bits y, 10 bits z — in quarter-yard +/// units (the server packs `round(v * 4)` per axis; vmangos `ByteBuffer::appendPackXYZ`). The +/// decoded vector is the offset from the spline's **destination** back to this waypoint +/// (`destination - waypoint`; vmangos `PacketBuilder::WriteLinearPath`), not an absolute position. +pub fn packed_to_vector3d(p: i32) -> Vector3d { + // Sign-extend each field: shift it to the top of the i32, then arithmetic-shift back down. + Vector3d { + x: ((p << 21) >> 21) as f32 * 0.25, + y: ((p << 10) >> 21) as f32 * 0.25, + z: (p >> 22) as f32 * 0.25, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The producer's encoder, transcribed from vmangos `ByteBuffer::appendPackXYZ`: + /// `packed |= ((int)lroundf(v * 4.0f) & mask) << shift` with masks 0x7FF/0x7FF/0x3FF. + fn pack_xyz(x: f32, y: f32, z: f32) -> i32 { + let mut packed = 0u32; + packed |= ((x * 4.0).round() as i32 & 0x7FF) as u32; + packed |= (((y * 4.0).round() as i32 & 0x7FF) as u32) << 11; + packed |= (((z * 4.0).round() as i32 & 0x3FF) as u32) << 22; + packed as i32 + } + + #[test] + fn packed_point_roundtrips_signed_quarter_yards() { + // Field ranges: x/y are 11-bit signed (−256 .. 255.75 yd), z 10-bit signed (−128 .. 127.75 yd). + // Quarter-yard multiples are exact in f32, so the round-trip must be exact — including the + // negative offsets the old unsigned decode destroyed. + for &(x, y, z) in &[ + (0.0f32, 0.0f32, 0.0f32), + (1.25, -2.5, 3.75), + (-100.25, 100.0, -50.5), + (255.75, -256.0, 127.75), + (-0.25, 0.25, -0.25), + ] { + let v = packed_to_vector3d(pack_xyz(x, y, z)); + assert_eq!((v.x, v.y, v.z), (x, y, z)); + } + } +} diff --git a/crates/benilla-protocol/src/world/mod.rs b/crates/benilla-protocol/src/world/mod.rs new file mode 100644 index 00000000..6d7316c5 --- /dev/null +++ b/crates/benilla-protocol/src/world/mod.rs @@ -0,0 +1,86 @@ +//! Phase 4: world server (`mangosd`) connection. +//! +//! After the realmd logon ([`crate::logon`]) hands us the SRP6 session key, we connect to the world +//! server, prove we know that key, then stream object updates. The handshake: +//! +//! 1. Read `SMSG_AUTH_CHALLENGE` (unencrypted) → server seed. +//! 2. Send `CMSG_AUTH_SESSION` (unencrypted): build, uppercased account name, a client seed, and a +//! SHA1 digest binding the session key to both seeds (computed by `benilla-srp`). +//! 3. **1.12 header obfuscation turns on right after** `CMSG_AUTH_SESSION` — every subsequent packet +//! header (4-byte server / 6-byte client) is encrypted; bodies stay plaintext. The first encrypted +//! packet we read is `SMSG_AUTH_RESPONSE`. +//! 4. `CMSG_CHAR_ENUM` → `SMSG_CHAR_ENUM`, then `CMSG_PLAYER_LOGIN(guid)`, then the world streams +//! `SMSG_UPDATE_OBJECT`. +//! +//! Headers are (de)crypted by [`benilla_srp::vanilla_header`]; bodies by [`crate::messages`]. + +use std::io::{Read, Write}; +use std::net::TcpStream; + +use anyhow::{anyhow, Result}; +use benilla_srp::vanilla_header::{DecrypterHalf, EncrypterHalf}; + +use crate::messages::{self, ServerPacket}; + +mod movement; +mod reader; +mod session; +mod writer; + +pub use reader::WorldReader; +pub use session::{WardenRequired, WorldSession}; +pub use writer::WorldWriter; + +/// Default `mangosd` world-server port. Our vmangos deploy maps mangosd to host port 18085 — +/// moved off the classic 8085, which the WOA dev server owns on this machine. Normally the realm +/// list reply carries the port (the deploy's `VMANGOS_REALMLIST_PORT=18085` matches); this +/// constant is the fallback for probes/examples that dial the world server directly. +pub const WORLD_PORT: u16 = 18085; + +/// Read one server packet from `stream`: decrypt the 4-byte header, read the body, parse by opcode. +/// `decrypter` is `None` for the (single) unencrypted `SMSG_AUTH_CHALLENGE`. +pub(super) fn recv_packet( + stream: &mut TcpStream, + decrypter: Option<&mut DecrypterHalf>, +) -> Result { + let mut header = [0u8; 4]; + stream + .read_exact(&mut header) + .map_err(|e| anyhow!("reading world header: {e}"))?; + if let Some(d) = decrypter { + d.decrypt(&mut header); + } + // size is big-endian and counts the opcode (2) but not the size field; opcode is little-endian. + let size = u16::from_be_bytes([header[0], header[1]]); + let opcode = u16::from_le_bytes([header[2], header[3]]); + let body_len = size.saturating_sub(2) as usize; + let mut body = vec![0u8; body_len]; + stream + .read_exact(&mut body) + .map_err(|e| anyhow!("reading world body (opcode {opcode:#x}, {body_len} bytes): {e}"))?; + messages::parse_server(opcode, &body).map_err(|e| anyhow!("parsing opcode {opcode:#x}: {e}")) +} + +/// Write one client packet: an (optionally encrypted) 6-byte header + plaintext body. The header size +/// field counts the 4-byte opcode plus the body, but not the size field itself. +pub(super) fn send_packet( + stream: &mut TcpStream, + encrypter: Option<&mut EncrypterHalf>, + opcode: u16, + body: &[u8], +) -> Result<()> { + let size = (body.len() + 4) as u16; + let mut header = { + let s = size.to_be_bytes(); + // The client header's opcode field is 4 bytes wide; widen the 16-bit opcode to fill it. + let o = u32::from(opcode).to_le_bytes(); + [s[0], s[1], o[0], o[1], o[2], o[3]] + }; + if let Some(e) = encrypter { + e.encrypt(&mut header); + } + stream + .write_all(&header) + .and_then(|()| stream.write_all(body)) + .map_err(|e| anyhow!("sending opcode {opcode:#x}: {e}")) +} diff --git a/crates/benilla-protocol/src/world/movement.rs b/crates/benilla-protocol/src/world/movement.rs new file mode 100644 index 00000000..b708209f --- /dev/null +++ b/crates/benilla-protocol/src/world/movement.rs @@ -0,0 +1,68 @@ +use crate::messages::MovementInfo; +use crate::wire::Vector3d; + +/// `MovementFlags::FORWARD` — the only movement flag benilla sets. +pub(super) const MOVEMENT_FLAG_FORWARD: u32 = 0x1; + +/// A monotonic **client-uptime** millisecond stamp for the `MovementInfo` time field — the kind the real +/// 1.12 client sends (`GetTickCount()`, ms since the client started), **not** wall-clock. vmangos reads it +/// into `MovementInfo::ctime` and uses it only two ways (VERIFIED, decision 0057): a non-zero guard +/// (`ctime == 0` reads as "not a client packet" and *pauses* its movement extrapolation) and *deltas* +/// (`getMSTimeDiff`, so the epoch cancels). It never compares the value to its own clock, and it +/// overwrites the field with its server clock before relaying to nearby players — so this value never +/// reaches observers. Hence: match the real client's clock kind, wrap cleanly as a full `u32` (~49.7 days, +/// which `getMSTimeDiff` handles), and keep it non-zero. (The old value was wall-clock masked to 31 bits, +/// which folded backwards every ~24.85 days — a delta glitch the clean `u32` wrap avoids.) +pub(super) fn client_uptime_ms() -> u32 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + (START.get_or_init(Instant::now).elapsed().as_millis() as u32).max(1) +} + +/// Build a `MovementInfo` stamped with the client-uptime time ([`client_uptime_ms`]). +pub(super) fn movement_info(pos: [f32; 3], orientation: f32, flags: u32) -> MovementInfo { + MovementInfo { + flags, + timestamp: client_uptime_ms(), + position: Vector3d { + x: pos[0], + y: pos[1], + z: pos[2], + }, + orientation, + // benilla never sets ON_TRANSPORT outbound yet (boarding/riding is decision 0438 phase 2) — the + // writer only emits the transport tail while the flag is set, so this is always dropped. + transport: None, + // Level swim (0) by default; the writer only emits the pitch tail while SWIMMING is set, and the + // controller supplies the live swim pitch through `send_movement` when it does. + pitch: 0.0, + fall_time: 0, + jump: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_uptime_ms_is_nonzero_and_monotonic() { + // The load-bearing property: vmangos treats `ctime == 0` as "not a client packet" and pauses its + // extrapolation, so our stamp must never be zero (the bug the old wall-clock value worked around). + // It must also be non-decreasing so `getMSTimeDiff` deltas stay sane. + let a = client_uptime_ms(); + let b = client_uptime_ms(); + assert!(a >= 1, "stamp is non-zero: {a}"); + assert!(b >= a, "stamp is monotonic non-decreasing: {a} -> {b}"); + } + + #[test] + fn movement_info_is_stamped_nonzero() { + let info = movement_info([1.0, 2.0, 3.0], 0.5, MOVEMENT_FLAG_FORWARD); + assert_ne!( + info.timestamp, 0, + "a movement packet must carry a non-zero time" + ); + } +} diff --git a/crates/benilla-protocol/src/world/reader.rs b/crates/benilla-protocol/src/world/reader.rs new file mode 100644 index 00000000..a396dcda --- /dev/null +++ b/crates/benilla-protocol/src/world/reader.rs @@ -0,0 +1,73 @@ +use std::io::Read; +use std::net::TcpStream; + +use anyhow::{anyhow, Result}; +use benilla_srp::vanilla_header::DecrypterHalf; + +use crate::messages::{self, ServerPacket}; + +use super::recv_packet; + +/// Read half of a split [`WorldSession`](super::WorldSession) — owns a cloned socket + the decrypter. Lives on the network +/// thread, streaming decoded [`crate::SessionEvent`]s (via [`Self::poll`]). +pub struct WorldReader { + pub(super) stream: TcpStream, + pub(super) decrypter: DecrypterHalf, +} + +impl WorldReader { + /// Read + decrypt one server packet (blocking). + pub fn recv(&mut self) -> Result { + recv_packet(&mut self.stream, Some(&mut self.decrypter)) + } + + /// Read one packet and decode it into a [`crate::Poll`]: the typed [`crate::SessionEvent`]s it + /// produced, or [`crate::Poll::Skipped`] for an unparseable one. Errors only on a real socket + /// failure (disconnect). The running world model lives in the ECS — the reader is stateless, + /// turning bytes into events and nothing more. + /// + /// `recv_packet` reads the whole body into a buffer before parsing, so a parse error leaves the + /// stream aligned — we skip that packet rather than tear down the session. + pub fn poll(&mut self) -> Result { + let mut header = [0u8; 4]; + if let Err(e) = self.stream.read_exact(&mut header) { + return Err(anyhow!("world stream closed: {e}")); + } + self.decrypter.decrypt(&mut header); + let size = u16::from_be_bytes([header[0], header[1]]); + let opcode = u16::from_le_bytes([header[2], header[3]]); + let body_len = size.saturating_sub(2) as usize; + let mut body = vec![0u8; body_len]; + if let Err(e) = self.stream.read_exact(&mut body) { + return Err(anyhow!("world stream closed: {e}")); + } + match messages::parse_server(opcode, &body) { + Ok(packet) => Ok(crate::Poll::Events(crate::decode(packet))), + // Include the raw body (capped) so an unparseable packet can be decoded by hand — a parse + // bug is otherwise invisible past "failed to fill whole buffer". The opcode rides + // separately so the net thread can feed the app's dropped-packet tally. + Err(e) => Ok(crate::Poll::Skipped { + opcode, + reason: format!( + "opcode {opcode:#06x} ({}): {e} [{body_len}B: {}]", + messages::opcode_name(opcode).unwrap_or("?"), + hex_preview(&body, 64) + ), + }), + } + } +} + +/// Space-separated hex of the first `max` bytes of `body` (with a `…` when truncated) — the diagnostic +/// tail on a [`crate::Poll::Skipped`] reason so an unparseable packet's layout can be decoded by hand. +fn hex_preview(body: &[u8], max: usize) -> String { + use std::fmt::Write; + let mut s = String::new(); + for b in body.iter().take(max) { + let _ = write!(s, "{b:02x} "); + } + if body.len() > max { + s.push('…'); + } + s.trim_end().to_string() +} diff --git a/crates/benilla-protocol/src/world/session.rs b/crates/benilla-protocol/src/world/session.rs new file mode 100644 index 00000000..9f6e9c38 --- /dev/null +++ b/crates/benilla-protocol/src/world/session.rs @@ -0,0 +1,721 @@ +use std::net::{TcpStream, ToSocketAddrs}; + +use anyhow::{anyhow, bail, Context, Result}; +use benilla_srp::vanilla_header::{HeaderCrypto, ProofSeed}; +use benilla_srp::{NormalizedString, SESSION_KEY_LENGTH}; + +use crate::messages::{self, opcode, Character, ServerPacket}; + +use super::movement::{client_uptime_ms, movement_info, MOVEMENT_FLAG_FORWARD}; +use super::reader::WorldReader; +use super::writer::WorldWriter; +use super::{recv_packet, send_packet}; + +/// Socket read timeout for the handshake phase only (connect → `player_login`), where each step +/// waits for one specific server reply and silence means the session is dead (decision 0065). +/// Generous — a local vmangos answers in milliseconds; this only bounds the pathological stall. +const HANDSHAKE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The server requires Warden, which benilla does not implement — raised by +/// [`WorldSession::connect`] and recoverable through the `anyhow` chain with +/// `err.downcast_ref::()`, so the login screen can say so plainly. +/// +/// VERIFIED (vmangos `src/game/Anticheat/WardenAnticheat/Warden.cpp`): every Warden request arms a +/// response clock (`BeginTimeoutClock`, `Warden.ClientResponseDelay` — default 30 s in +/// `World.cpp`), and `Warden::Update` kicks unconditionally when it expires — not subject to the +/// penalty config, and regardless of module/maiev mode. So a client that cannot answer cannot stay +/// in the world: refusing at the handshake is the honest outcome, not a policy choice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WardenRequired; + +impl std::fmt::Display for WardenRequired { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "this server requires the Warden anticheat, which benilla does not implement" + ) + } +} + +impl std::error::Error for WardenRequired {} + +/// An authenticated world-server session with 1.12 header obfuscation active. +pub struct WorldSession { + stream: TcpStream, + crypto: HeaderCrypto, + /// The roster's guid → race, remembered from [`Self::char_enum`] so [`Self::player_login`] + /// can derive the character's faction tongue without the caller's help. + roster_races: std::collections::HashMap, + /// The language chat sends speak — the logged-in character's faction tongue + /// ([`messages::faction_language`]), set automatically by [`Self::player_login`]. Load-bearing: + /// vmangos drops any chat whose language the character doesn't know (dot-commands included) — + /// hardcoded Common silently ate every Horde character's chat and commands once. + chat_language: u32, +} + +impl WorldSession { + /// Connect to the world server and complete the auth handshake, leaving header obfuscation + /// enabled. `username` must be the same account used at logon; `session_key` is what + /// [`crate::logon`] returned. + pub fn connect( + addr: impl ToSocketAddrs, + username: &str, + session_key: [u8; SESSION_KEY_LENGTH], + ) -> Result { + let mut stream = TcpStream::connect(addr).context("connecting to world server")?; + // Handshake reads wait for one specific reply each — silence is failure, so bound them + // (decision 0065). A server that stays connected but never answers must not wedge the net + // thread forever. Cleared in `into_split` for the streaming phase (a quiet world is legal). + stream + .set_read_timeout(Some(HANDSHAKE_READ_TIMEOUT)) + .context("setting handshake read timeout")?; + + // 1. SMSG_AUTH_CHALLENGE (unencrypted) carries the server seed. + let server_seed = match recv_packet(&mut stream, None)? { + ServerPacket::AuthChallenge { server_seed } => server_seed, + other => bail!("expected SMSG_AUTH_CHALLENGE, got {}", other.name()), + }; + + // 2. Bind the session key to (server_seed, our client_seed) → proof + header crypto. + let username_n = + NormalizedString::new(username).map_err(|e| anyhow!("invalid username: {e}"))?; + let seed = ProofSeed::new(); + let client_seed = seed.seed(); + let (client_proof, crypto) = + seed.into_client_header_crypto(&username_n, session_key, server_seed); + + // 3. CMSG_AUTH_SESSION goes out unencrypted; obfuscation begins immediately after. + let body = messages::auth_session( + u32::from(crate::CLIENT_BUILD), + &username.to_uppercase(), + client_seed, + &client_proof, + ); + send_packet(&mut stream, None, opcode::CMSG_AUTH_SESSION, &body) + .context("sending CMSG_AUTH_SESSION")?; + + let mut session = WorldSession { + stream, + crypto, + roster_races: Default::default(), + chat_language: messages::LANGUAGE_COMMON, + }; + + // 4. Wait for SMSG_AUTH_RESPONSE. Usually the first encrypted packet, but not always first + // on the wire, so skip interleaved packets (as `char_enum` does) rather than demanding + // AUTH_RESPONSE lead. Each read is still bounded by the handshake timeout, so a server that + // never answers still fails per decision 0065. + // + // SMSG_WARDEN_DATA among them ends the connection here: it means the server runs Warden, + // whose response clock kicks us ~30 s later no matter what else we do ([`WardenRequired`]). + // Refusing at the handshake trades an unplayable 30-second kick/reconnect cycle for one + // honest message at the login screen. + loop { + match session.recv()? { + ServerPacket::AuthResponse { result } if result == messages::AUTH_OK => break, + ServerPacket::AuthResponse { result } => { + bail!("world auth rejected: result {result:#x}") + } + ServerPacket::Other { + opcode: opcode::SMSG_WARDEN_DATA, + } => return Err(WardenRequired.into()), + _ => continue, + } + } + + Ok(session) + } + + /// Set a read timeout on the underlying socket (e.g. so a debug read-loop can stop when the + /// world goes quiet). `None` clears it (blocking reads). + pub fn set_read_timeout(&self, timeout: Option) -> Result<()> { + self.stream + .set_read_timeout(timeout) + .context("setting world socket read timeout") + } + + /// Read + decrypt + parse one server packet. + pub fn recv(&mut self) -> Result { + recv_packet(&mut self.stream, Some(self.crypto.decrypter())) + } + + /// Send a client packet (encrypted header + plaintext body). + fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> { + send_packet( + &mut self.stream, + Some(self.crypto.encrypter()), + opcode, + body, + ) + } + + /// Request the character list and return it (remembering each character's race, so + /// [`Self::player_login`] can pick the right chat tongue). + pub fn char_enum(&mut self) -> Result> { + self.send(opcode::CMSG_CHAR_ENUM, &[])?; + loop { + match self.recv()? { + ServerPacket::CharEnum { characters } => { + self.roster_races = characters.iter().map(|c| (c.guid, c.race)).collect(); + return Ok(characters); + } + // Warden can land either side of SMSG_AUTH_RESPONSE depending on when the server + // arms it, so the roster step refuses it too — same reason as `connect`. + ServerPacket::Other { + opcode: opcode::SMSG_WARDEN_DATA, + } => return Err(WardenRequired.into()), + // The server interleaves account-data / tutorial / cache packets here; skip them. + _ => continue, + } + } + } + + /// Create a character (the create screen's request, or the create-if-empty starter that unblocks + /// `PLAYER_LOGIN` on a fresh account). Returns the `SMSG_CHAR_CREATE` result byte (`WorldResult`) + /// for the caller to inspect ([`messages::CHAR_CREATE_SUCCESS`], a `CHAR_NAME_*` code, …). + pub fn create_character(&mut self, req: &messages::CharCreateReq) -> Result { + self.send(opcode::CMSG_CHAR_CREATE, &messages::char_create(req))?; + loop { + match self.recv()? { + ServerPacket::CharCreate { result } => return Ok(result), + _ => continue, + } + } + } + + /// Delete a character (`CMSG_CHAR_DELETE`, full u64 guid — only valid at character select, + /// never while in-world). Returns the `SMSG_CHAR_DELETE` result byte + /// ([`messages::CHAR_DELETE_SUCCESS`] on success) for the caller to inspect. + pub fn delete_character(&mut self, guid: u64) -> Result { + self.send(opcode::CMSG_CHAR_DELETE, &messages::full_guid(guid))?; + loop { + match self.recv()? { + ServerPacket::CharDelete { result } => return Ok(result), + _ => continue, + } + } + } + + /// Send `CMSG_PLAYER_LOGIN` to enter the world as the given character. Also adopts the + /// character's faction tongue for every subsequent chat send (session and split writer alike): + /// vmangos drops chat — dot-commands included — spoken in a language the character doesn't + /// know, so the tongue must follow the pick. Falls back to Common when the guid wasn't in the + /// enumerated roster (no [`Self::char_enum`] this connection). + pub fn player_login(&mut self, guid: u64) -> Result<()> { + self.chat_language = self + .roster_races + .get(&guid) + .map_or(messages::LANGUAGE_COMMON, |&race| { + messages::faction_language(race) + }); + self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(guid)) + } + + /// Declare `guid` as the unit this client controls. vmangos drops all `MSG_MOVE_*` until the + /// client is a *confirmed mover*, which this establishes (the real client sends it at login). + pub fn set_active_mover(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(guid)) + } + + /// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the + /// unsplit twin of [`WorldWriter::complete_cinematic`], for the CLI/probe path. + pub fn complete_cinematic(&mut self) -> Result<()> { + self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[]) + } + + /// Walk our player forward `info`-style: begin moving (`MSG_MOVE_START_FORWARD`). + pub fn start_forward(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> { + self.send( + opcode::MSG_MOVE_START_FORWARD, + &messages::movement(&movement_info(pos, orientation, MOVEMENT_FLAG_FORWARD)), + ) + } + + /// Continue moving (`MSG_MOVE_HEARTBEAT`). + pub fn heartbeat(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> { + self.send( + opcode::MSG_MOVE_HEARTBEAT, + &messages::movement(&movement_info(pos, orientation, MOVEMENT_FLAG_FORWARD)), + ) + } + + /// Stop (`MSG_MOVE_STOP`, no movement flags). + pub fn stop(&mut self, pos: [f32; 3], orientation: f32) -> Result<()> { + self.send( + opcode::MSG_MOVE_STOP, + &messages::movement(&movement_info(pos, orientation, 0)), + ) + } + + /// Answer a `SMSG_FORCE_*_SPEED_CHANGE` at rest — the unsplit twin of + /// [`WorldWriter::force_speed_change_ack`], for the `--speed` probe: echo the mover guid + + /// counter + exact speed with a stationary `MovementInfo`. A wrong shape (packed guid, missing + /// counter, off-by-0.01 speed) is rejected by the server's pending-change matcher — the probe's + /// pass is the server logging no mismatch and the next change arriving with counter+1. + pub fn force_speed_ack( + &mut self, + kind: messages::SpeedKind, + guid: u64, + counter: u32, + speed: f32, + pos: [f32; 3], + orientation: f32, + ) -> Result<()> { + self.send( + kind.ack_opcode(), + &messages::force_speed_ack(guid, counter, &movement_info(pos, orientation, 0), speed), + ) + } + + /// Acknowledge a finished server spline (`CMSG_MOVE_SPLINE_DONE`) — the unsplit twin of + /// [`WorldWriter::move_spline_done`], for the `--charge` probe: after the server drives us with a + /// self `SMSG_MONSTER_MOVE`, echo its `spline_id` at the endpoint (at rest) so the server clears + /// its spline-pending state and relocates us. A malformed body throws in the server's parser and + /// drops the session — so a surviving stream is the live proof the wire is right. + pub fn move_spline_done( + &mut self, + pos: [f32; 3], + orientation: f32, + spline_id: u32, + ) -> Result<()> { + self.send( + opcode::CMSG_MOVE_SPLINE_DONE, + &messages::move_spline_done(&movement_info(pos, orientation, 0), spline_id), + ) + } + + /// Ask a player's name (`CMSG_NAME_QUERY`) — the unsplit twin of [`WorldWriter::name_query`], + /// for the CLI/probe path. The answer arrives in the stream as `SMSG_NAME_QUERY_RESPONSE`. + pub fn name_query(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_NAME_QUERY, &messages::full_guid(guid)) + } + + /// Ask a creature template's name (`CMSG_CREATURE_QUERY`) — the unsplit twin of + /// [`WorldWriter::creature_query`]. + pub fn creature_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_CREATURE_QUERY, + &messages::creature_query(entry, guid), + ) + } + + /// Cast a spell (`CMSG_CAST_SPELL`) — the unsplit twin of [`WorldWriter::cast_spell`], for the + /// CLI/probe path. `target: None` = self/implicit; the verdict arrives as `SMSG_CAST_RESULT`. + pub fn cast_spell(&mut self, spell_id: u32, target: Option) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell(spell_id, target), + ) + } + + /// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`) — the unsplit twin of + /// [`WorldWriter::cast_spell_gameobject`], for a probe's live chest-loot verification (decision + /// 0239): cast e.g. 3365 "Opening" / 2575 "Mining" at a lockable GO and expect + /// `SMSG_LOOT_RESPONSE`. + pub fn cast_spell_gameobject(&mut self, spell_id: u32, go_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell_gameobject(spell_id, go_guid), + ) + } + + /// Ask an item template (`CMSG_ITEM_QUERY_SINGLE`) — the unsplit twin of + /// [`WorldWriter::item_query`]. Answered by `SMSG_ITEM_QUERY_SINGLE_RESPONSE`. + pub fn item_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_ITEM_QUERY_SINGLE, + &messages::item_query(entry, guid), + ) + } + + /// Use an item by bag position (`CMSG_USE_ITEM`) — the unsplit twin of + /// [`WorldWriter::use_item`], for the probe's live use-item verification. + pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_USE_ITEM, + &messages::use_item(bag_index, slot, spell_slot), + ) + } + + /// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`) — the unsplit twin of + /// [`WorldWriter::auto_equip_item`], for the probe's live equip verification. + pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOEQUIP_ITEM, + &messages::auto_equip_item(bag_index, slot), + ) + } + + /// Swap two player-array slots (`CMSG_SWAP_INV_ITEM`) — the unsplit twin of + /// [`WorldWriter::swap_inv_item`], for the probe's live backpack-move verification. + pub fn swap_inv_item(&mut self, src_slot: u8, dst_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_SWAP_INV_ITEM, + &messages::swap_inv_item(src_slot, dst_slot), + ) + } + + /// Ask a vendor's stock (`CMSG_LIST_INVENTORY`) — the unsplit twin of + /// [`WorldWriter::list_inventory`], for the probe's live vendor verification. Answered by + /// `SMSG_LIST_INVENTORY` (a `VendorInventory` event). + pub fn list_inventory(&mut self, vendor_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_LIST_INVENTORY, + &messages::list_inventory(vendor_guid), + ) + } + + /// Buy from a vendor (`CMSG_BUY_ITEM`) — the unsplit twin of [`WorldWriter::buy_item`]; `entry` + /// is the item template id, not the vendor row's `muid`. + pub fn buy_item(&mut self, vendor_guid: u64, entry: u32, count: u8) -> Result<()> { + self.send( + opcode::CMSG_BUY_ITEM, + &messages::buy_item(vendor_guid, entry, count), + ) + } + + /// Sell an item to a vendor (`CMSG_SELL_ITEM`) — the unsplit twin of [`WorldWriter::sell_item`]; + /// `count` 0 = the whole stack. + pub fn sell_item(&mut self, vendor_guid: u64, item_guid: u64, count: u8) -> Result<()> { + self.send( + opcode::CMSG_SELL_ITEM, + &messages::sell_item(vendor_guid, item_guid, count), + ) + } + + /// Buy a sold item back (`CMSG_BUYBACK_ITEM`) — the unsplit twin of + /// [`WorldWriter::buyback_item`]; `slot` is the absolute buyback slot 69–80. + pub fn buyback_item(&mut self, vendor_guid: u64, slot: u32) -> Result<()> { + self.send( + opcode::CMSG_BUYBACK_ITEM, + &messages::buyback_item(vendor_guid, slot), + ) + } + + /// Repair at a vendor (`CMSG_REPAIR_ITEM`) — the unsplit twin of [`WorldWriter::repair_item`]; + /// `item_guid` 0 = repair everything. + pub fn repair_item(&mut self, vendor_guid: u64, item_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_REPAIR_ITEM, + &messages::repair_item(vendor_guid, item_guid), + ) + } + + /// Right-click a pure banker (`CMSG_BANKER_ACTIVATE`) — opens the bank window, answered by + /// `SMSG_SHOW_BANK` (a `ShowBank` event). Decision 0604. + pub fn banker_activate(&mut self, banker_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_BANKER_ACTIVATE, + &messages::banker_activate(banker_guid), + ) + } + + /// Buy the next bank bag slot (`CMSG_BUY_BANK_SLOT`) — the server buys slot + /// `purchased_count + 1` and debits the price itself; success is silent (the + /// `PLAYER_BYTES_2` bank-bag-count byte advances via `UPDATE_OBJECT`), failure answers + /// `SMSG_BUY_BANK_SLOT_RESULT` (a `BuyBankSlotResult` event). Decision 0604. + pub fn buy_bank_slot(&mut self, banker_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_BUY_BANK_SLOT, + &messages::buy_bank_slot(banker_guid), + ) + } + + /// Deposit a bag item into the bank (`CMSG_AUTOBANK_ITEM`) — moves the item at player-array + /// position `(bag, slot)` into the first free bank slot. Decision 0604. + pub fn autobank_item(&mut self, bag: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOBANK_ITEM, + &messages::autobank_item(bag, slot), + ) + } + + /// Auto-move at the bank (`CMSG_AUTOSTORE_BANK_ITEM`) — withdraws `(bag, slot)` to inventory + /// when it names a bank position, else deposits it (vmangos `HandleAutoStoreBankItemOpcode`, + /// `ItemHandler.cpp:971`). Decision 0604. + pub fn autostore_bank_item(&mut self, bag: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOSTORE_BANK_ITEM, + &messages::autostore_bank_item(bag, slot), + ) + } + + /// Ask an NPC's questgiver dialog status (`CMSG_QUESTGIVER_STATUS_QUERY`) — answered by + /// `SMSG_QUESTGIVER_STATUS`, the overhead `!`/`?` marker's value. + pub fn questgiver_status_query(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_STATUS_QUERY, + &messages::questgiver_status_query(npc), + ) + } + + /// Open a questgiver dialog (`CMSG_QUESTGIVER_HELLO`) — the unsplit twin for the probe's live + /// quest verification. The same `SendPreparedGossip` server path as gossip-hello. + pub fn questgiver_hello(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_HELLO, + &messages::questgiver_hello(npc), + ) + } + + /// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`) — answered by + /// `SMSG_QUESTGIVER_QUEST_DETAILS`. + pub fn questgiver_query_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_QUERY_QUEST, + &messages::questgiver_query_quest(npc, quest), + ) + } + + /// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`) — adds it to the log; the server closes the + /// gossip window. + pub fn questgiver_accept_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_ACCEPT_QUEST, + &messages::questgiver_accept_quest(npc, quest), + ) + } + + /// Ask for a quest's turn-in progress panel (`CMSG_QUESTGIVER_COMPLETE_QUEST`) — answered by + /// `SMSG_QUESTGIVER_REQUEST_ITEMS` (or OFFER_REWARD when there are no required items). + pub fn questgiver_complete_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_COMPLETE_QUEST, + &messages::questgiver_complete_quest(npc, quest), + ) + } + + /// Advance from progress to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`) — answered by + /// `SMSG_QUESTGIVER_OFFER_REWARD`. + pub fn questgiver_request_reward(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_REQUEST_REWARD, + &messages::questgiver_request_reward(npc, quest), + ) + } + + /// Choose a reward and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`, `reward` = choice + /// index) — answered by `SMSG_QUESTGIVER_QUEST_COMPLETE` + the XP/money/item grants. + pub fn questgiver_choose_reward(&mut self, npc: u64, quest: u32, reward: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_CHOOSE_REWARD, + &messages::questgiver_choose_reward(npc, quest, reward), + ) + } + + /// Ask a quest's full template (`CMSG_QUEST_QUERY`) — the unsplit twin of + /// [`WorldWriter::quest_query`]; the quest-log detail pane's ask-once source, distinct from + /// [`Self::questgiver_query_quest`] (which needs an NPC guid, not just the quest id). Answered + /// by `SMSG_QUEST_QUERY_RESPONSE` (a `QuestTemplate` event). + pub fn quest_query(&mut self, quest_id: u32) -> Result<()> { + self.send(opcode::CMSG_QUEST_QUERY, &messages::quest_query(quest_id)) + } + + /// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`) — the unsplit twin of + /// [`WorldWriter::questlog_remove_quest`]; no ack SMSG, the server clears the + /// `PLAYER_QUEST_LOG` slot fields directly. + pub fn questlog_remove_quest(&mut self, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_QUESTLOG_REMOVE_QUEST, + &messages::questlog_remove_quest(slot), + ) + } + + /// Send a `/say` line — the unsplit twin of [`WorldWriter::send_chat`]; the probe CLI drives GM + /// dot-commands (`.go xyz …`) through it. Same body rules as the writer: spoken in the + /// character's own tongue (set by [`Self::player_login`]) — the server rejects `Universal` + /// from clients, and rejects a tongue the character doesn't know. + pub fn send_chat(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_SAY, self.chat_language, message), + ) + } + + /// Echo a same-map teleport ack — the unsplit twin of [`WorldWriter::teleport_ack`] (without it + /// the server freezes our movement after a `.go`). + pub fn teleport_ack(&mut self, guid: u64, counter: u32) -> Result<()> { + self.send( + opcode::MSG_MOVE_TELEPORT_ACK, + &messages::teleport_ack(guid, counter, client_uptime_ms()), + ) + } + + /// Acknowledge a server root/unroot on our mover (`SMSG_FORCE_MOVE_[UN]ROOT` → the matching + /// `CMSG_FORCE_MOVE_[UN]ROOT_ACK`) — the unsplit twin of [`WorldWriter::move_root_ack`], for the + /// `--death` probe: the server roots us at death and unroots us at release, and observers never + /// see the change until we echo the counter (+ our current pose) back. + pub fn move_root_ack( + &mut self, + guid: u64, + counter: u32, + rooted: bool, + flags: u32, + pos: [f32; 3], + orientation: f32, + ) -> Result<()> { + let info = movement_info(pos, orientation, flags); + self.send( + if rooted { + opcode::CMSG_FORCE_MOVE_ROOT_ACK + } else { + opcode::CMSG_FORCE_MOVE_UNROOT_ACK + }, + &messages::move_flag_ack(guid, counter, &info, None), + ) + } + + /// Acknowledge a water-walk grant/removal on our mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` → + /// `CMSG_MOVE_WATER_WALK_ACK`) — the unsplit twin of [`WorldWriter::water_walk_ack`], for the + /// `--death` probe: the ghost form grants walk-on-water at release and removes it at revive. + pub fn water_walk_ack( + &mut self, + guid: u64, + counter: u32, + on: bool, + flags: u32, + pos: [f32; 3], + orientation: f32, + ) -> Result<()> { + let info = movement_info(pos, orientation, flags); + self.send( + opcode::CMSG_MOVE_WATER_WALK_ACK, + &messages::move_flag_ack(guid, counter, &info, Some(on)), + ) + } + + /// Release the spirit (`CMSG_REPOP_REQUEST`, empty body) — the unsplit twin of + /// [`WorldWriter::repop_request`], for the `--death` probe's live release verification. + pub fn repop_request(&mut self) -> Result<()> { + self.send(opcode::CMSG_REPOP_REQUEST, &[]) + } + + /// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request) — the unsplit twin of + /// [`WorldWriter::corpse_query`], for the `--death` probe. Answered by the same opcode (a + /// [`SessionEvent::CorpseQuery`](crate::SessionEvent::CorpseQuery)). + pub fn corpse_query(&mut self) -> Result<()> { + self.send(opcode::MSG_CORPSE_QUERY, &[]) + } + + /// Ask the graveyard's Spirit Healer for the immediate res (`CMSG_SPIRIT_HEALER_ACTIVATE`, + /// full guid) — the unsplit twin of [`WorldWriter::spirit_healer_activate`], for the + /// `--spirit` probe's live durability-loss verification. The server resurrects at 50% health + /// and pushes the 25% durability loss as item values-deltas. + pub fn spirit_healer_activate(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_SPIRIT_HEALER_ACTIVATE, + &messages::spirit_healer_activate(npc), + ) + } + + /// Start melee auto-attack (`CMSG_ATTACKSWING`, full guid) — the unsplit twin of + /// [`WorldWriter::attack_swing`]. Echoed as `SMSG_ATTACKSTART`; each completed swing arrives as + /// `SMSG_ATTACKERSTATEUPDATE`. + pub fn attack_swing(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(guid)) + } + + /// Set our target (`CMSG_SET_SELECTION`, full guid) — the unsplit twin of + /// [`WorldWriter::set_selection`], for probe fidelity (the client selects before it casts). + pub fn set_selection(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_SET_SELECTION, &messages::full_guid(guid)) + } + + /// Open a loot window (`CMSG_LOOT`) — the unsplit twin of [`WorldWriter::loot`], for the + /// probe's live loot verification. Answered by `SMSG_LOOT_RESPONSE` (either shape). + pub fn loot(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_LOOT, &messages::loot(guid)) + } + + /// Use a world GameObject (`CMSG_GAMEOBJ_USE`) — the unsplit twin of [`WorldWriter::gameobj_use`], + /// for a probe's live GO-interaction verification (decision 0236 phase 3's chest-loot capture). + /// The server answers on the type's own system (`SMSG_LOOT_RESPONSE` for a chest, gossip/quest for + /// a questgiver GO) or not at all. + pub fn gameobj_use(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_GAMEOBJ_USE, &messages::gameobj_use(guid)) + } + + /// Ask for a GameObject template's head (`CMSG_GAMEOBJECT_QUERY`) — the unsplit twin of + /// [`WorldWriter::gameobject_query`], for the CLI/probe path. Answered by + /// `SMSG_GAMEOBJECT_QUERY_RESPONSE`. + pub fn gameobject_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_GAMEOBJECT_QUERY, + &messages::gameobject_query(entry, guid), + ) + } + + /// Take one loot-window row (`CMSG_AUTOSTORE_LOOT_ITEM`) — the unsplit twin of + /// [`WorldWriter::autostore_loot_item`]; `loot_slot` is the wire's 0-based row index from the + /// `SMSG_LOOT_RESPONSE` this answers. + pub fn autostore_loot_item(&mut self, loot_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOSTORE_LOOT_ITEM, + &messages::autostore_loot_item(loot_slot), + ) + } + + /// Take the loot's coin pile (`CMSG_LOOT_MONEY`, empty body) — the unsplit twin of + /// [`WorldWriter::loot_money`]. Answered by `SMSG_LOOT_MONEY_NOTIFY` + `SMSG_LOOT_CLEAR_MONEY`. + pub fn loot_money(&mut self) -> Result<()> { + self.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money()) + } + + /// Close the loot window (`CMSG_LOOT_RELEASE`) — the unsplit twin of + /// [`WorldWriter::loot_release`]; the server ignores `guid` and releases its own stored loot + /// target. Answered by `SMSG_LOOT_RELEASE_RESPONSE`. + pub fn loot_release(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(guid)) + } + + /// Split into independent read/write halves (separate cloned sockets + crypto halves) so a reader + /// thread can stream updates while another path sends our movement. Call after [`Self::player_login`]. + pub fn into_split(self) -> Result<(WorldReader, WorldWriter)> { + // The handshake is over — clear its read timeout (decision 0065): the streaming phase reads + // block indefinitely (a quiet world is legal; connection liveness is the caller's loop). + self.stream + .set_read_timeout(None) + .context("clearing handshake read timeout")?; + let read_stream = self + .stream + .try_clone() + .context("cloning world socket for split")?; + let (encrypter, decrypter) = self.crypto.split(); + Ok(( + WorldReader { + stream: read_stream, + decrypter, + }, + WorldWriter { + stream: self.stream, + encrypter, + chat_language: self.chat_language, + }, + )) + } + + /// Cleanly leave the world back to character-select: send `CMSG_LOGOUT_REQUEST` and wait for + /// `SMSG_LOGOUT_COMPLETE` (instant for GM / rested). The server persists the character on logout, + /// so a follow-up [`Self::char_enum`] reflects the saved position. Requires a read timeout (so the + /// poll loop can give up); returns once logged out or `timeout` elapses. + pub fn logout(&mut self, timeout: std::time::Duration) -> Result<()> { + self.send(opcode::CMSG_LOGOUT_REQUEST, &[])?; + let deadline = std::time::Instant::now() + timeout; + let mut denied = false; + while std::time::Instant::now() < deadline { + match self.recv() { + Ok(ServerPacket::LogoutComplete) => return Ok(()), + Ok(ServerPacket::LogoutResponse { result }) => { + // A non-success result means the server refused (combat, etc.). + denied = result != messages::LOGOUT_SUCCESS; + } + Ok(_) => {} + Err(_) => {} // read-timeout tick — keep polling until the deadline + } + } + if denied { + bail!("logout refused by server (in combat / not allowed)") + } + bail!("timed out waiting for SMSG_LOGOUT_COMPLETE") + } +} diff --git a/crates/benilla-protocol/src/world/writer/channel.rs b/crates/benilla-protocol/src/world/writer/channel.rs new file mode 100644 index 00000000..dba039d1 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/channel.rs @@ -0,0 +1,149 @@ +//! The channel family's `WorldWriter` sends — join/leave/list/moderation (bodies in +//! [`crate::messages`]'s `join_channel`/`leave_channel`/`channel_*` builders, all cstring-only +//! shapes; layout VERIFIED against vmangos `Server/Packets/Channel.h`/`.cpp`). Split out of +//! `writer/mod.rs` (decision 0288 phase 1) — the channel administration commands are one clearly +//! separable concern among the writer's many domains. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Join a channel (`CMSG_JOIN_CHANNEL`, layout in [`messages::join_channel`]): `password` is an + /// empty string for a channel that has none. Zone channels (General/Trade/LocalDefense) are + /// joined by the CLIENT too — in 1.12 the client walks ChatChannels.dbc on login/zone-in and + /// sends this same packet with the composed name ("General - Elwynn Forest"); the server only + /// tracks membership by name (decision 0288 phase 6 drives that walk). Answered by + /// `SMSG_CHANNEL_NOTIFY`'s `YOU_JOINED`/`WRONG_PASSWORD`/`BANNED`/… notice. + pub fn join_channel(&mut self, name: &str, password: &str) -> Result<()> { + self.send( + opcode::CMSG_JOIN_CHANNEL, + &messages::join_channel(name, password), + ) + } + + /// Leave a channel (`CMSG_LEAVE_CHANNEL`, layout in [`messages::leave_channel`]). + pub fn leave_channel(&mut self, name: &str) -> Result<()> { + self.send(opcode::CMSG_LEAVE_CHANNEL, &messages::leave_channel(name)) + } + + /// Ask a channel's member roster (`CMSG_CHANNEL_LIST`, layout in [`messages::channel_list`]), + /// the `/chatlist` / `/list` command. Answered by `SMSG_CHANNEL_LIST`. + pub fn channel_list(&mut self, name: &str) -> Result<()> { + self.send(opcode::CMSG_CHANNEL_LIST, &messages::channel_list(name)) + } + + /// Set a channel's password (`CMSG_CHANNEL_PASSWORD`, layout in + /// [`messages::channel_password`]) — owner-only server-side. + pub fn channel_password(&mut self, name: &str, password: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_PASSWORD, + &messages::channel_password(name, password), + ) + } + + /// Transfer channel ownership (`CMSG_CHANNEL_SET_OWNER`, layout in + /// [`messages::channel_set_owner`]) — owner-only server-side. + pub fn channel_set_owner(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_SET_OWNER, + &messages::channel_set_owner(name, player), + ) + } + + /// Ask who owns a channel (`CMSG_CHANNEL_OWNER`, layout in [`messages::channel_owner`]) — the + /// `/console` "who owns" query. Answered by `SMSG_CHANNEL_NOTIFY`'s `CHANNEL_OWNER` notice. + pub fn channel_owner(&mut self, name: &str) -> Result<()> { + self.send(opcode::CMSG_CHANNEL_OWNER, &messages::channel_owner(name)) + } + + /// Grant moderator (`CMSG_CHANNEL_MODERATOR`, layout in [`messages::channel_moderator`]) — + /// owner-only server-side. + pub fn channel_moderator(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_MODERATOR, + &messages::channel_moderator(name, player), + ) + } + + /// Revoke moderator (`CMSG_CHANNEL_UNMODERATOR`, layout in [`messages::channel_unmoderator`]). + pub fn channel_unmoderator(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_UNMODERATOR, + &messages::channel_unmoderator(name, player), + ) + } + + /// Mute a player on the channel (`CMSG_CHANNEL_MUTE`, layout in [`messages::channel_mute`]) — + /// moderator-only server-side. + pub fn channel_mute(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_MUTE, + &messages::channel_mute(name, player), + ) + } + + /// Unmute a player (`CMSG_CHANNEL_UNMUTE`, layout in [`messages::channel_unmute`]). + pub fn channel_unmute(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_UNMUTE, + &messages::channel_unmute(name, player), + ) + } + + /// Invite a player to a moderated/private channel (`CMSG_CHANNEL_INVITE`, layout in + /// [`messages::channel_invite`]); level-gated server-side + /// (`CONFIG_UINT32_CHANNEL_INVITE_MIN_LEVEL`). + pub fn channel_invite(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_INVITE, + &messages::channel_invite(name, player), + ) + } + + /// Kick a player off the channel (`CMSG_CHANNEL_KICK`, layout in [`messages::channel_kick`]) — + /// moderator-only server-side. + pub fn channel_kick(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_KICK, + &messages::channel_kick(name, player), + ) + } + + /// Ban a player from the channel (`CMSG_CHANNEL_BAN`, layout in [`messages::channel_ban`]) — + /// moderator-only server-side. + pub fn channel_ban(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_BAN, + &messages::channel_ban(name, player), + ) + } + + /// Unban a player (`CMSG_CHANNEL_UNBAN`, layout in [`messages::channel_unban`]). + pub fn channel_unban(&mut self, name: &str, player: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_UNBAN, + &messages::channel_unban(name, player), + ) + } + + /// Toggle join/leave announcements (`CMSG_CHANNEL_ANNOUNCEMENTS`, layout in + /// [`messages::channel_announcements`]) — owner/moderator-only server-side. + pub fn channel_announcements(&mut self, name: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_ANNOUNCEMENTS, + &messages::channel_announcements(name), + ) + } + + /// Toggle moderation (speak-restricted-to-moderators) mode (`CMSG_CHANNEL_MODERATE`, layout in + /// [`messages::channel_moderate`]) — owner-only server-side. + pub fn channel_moderate(&mut self, name: &str) -> Result<()> { + self.send( + opcode::CMSG_CHANNEL_MODERATE, + &messages::channel_moderate(name), + ) + } +} diff --git a/crates/benilla-protocol/src/world/writer/group.rs b/crates/benilla-protocol/src/world/writer/group.rs new file mode 100644 index 00000000..37eff65e --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/group.rs @@ -0,0 +1,157 @@ +//! The group/party family's `WorldWriter` sends — invite/accept/decline/kick/leader/disband, loot +//! method, party-stats asks, raid subgroup management, minimap pings, raid-target icons, and ready +//! checks (bodies in [`crate::messages`]'s `group_*`/`loot_method`/`minimap_ping`/`raid_target_*`/ +//! `ready_check_*` builders; layout VERIFIED against vmangos `Server/Packets/Group.{h,cpp}`, +//! `Handlers/GroupHandler.cpp`). Split out of `writer/mod.rs`, mirroring [`super::channel`] — the +//! group commands are one clearly separable concern among the writer's many domains. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Invite a player to group (`CMSG_GROUP_INVITE`, layout in [`messages::group_invite`]). + /// Answered by `SMSG_PARTY_COMMAND_RESULT`; the invited player sees `SMSG_GROUP_INVITE`. + pub fn group_invite(&mut self, member_name: &str) -> Result<()> { + self.send( + opcode::CMSG_GROUP_INVITE, + &messages::group_invite(member_name), + ) + } + + /// Accept a pending group invite (`CMSG_GROUP_ACCEPT`, empty body). + pub fn group_accept(&mut self) -> Result<()> { + self.send(opcode::CMSG_GROUP_ACCEPT, &messages::group_accept()) + } + + /// Decline a pending group invite (`CMSG_GROUP_DECLINE`, empty body); the inviter sees + /// `SMSG_GROUP_DECLINE`. + pub fn group_decline(&mut self) -> Result<()> { + self.send(opcode::CMSG_GROUP_DECLINE, &messages::group_decline()) + } + + /// Kick a group member by name (`CMSG_GROUP_UNINVITE`, layout in [`messages::group_uninvite`]). + pub fn group_uninvite(&mut self, member_name: &str) -> Result<()> { + self.send( + opcode::CMSG_GROUP_UNINVITE, + &messages::group_uninvite(member_name), + ) + } + + /// Kick a group member by guid (`CMSG_GROUP_UNINVITE_GUID`) — the raid-frame right-click kick. + pub fn group_uninvite_guid(&mut self, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_GROUP_UNINVITE_GUID, + &messages::group_uninvite_guid(guid), + ) + } + + /// Hand off group leadership (`CMSG_GROUP_SET_LEADER`, a full guid — the 1.12 wire form). + pub fn group_set_leader(&mut self, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_GROUP_SET_LEADER, + &messages::group_set_leader(guid), + ) + } + + /// Set the group's loot method (`CMSG_LOOT_METHOD`, layout in [`messages::loot_method`]): + /// `method` is `0` free-for-all / `1` round-robin / `2` master / `3` group / `4` + /// need-before-greed; `loot_master` is the master looter's guid (ignored unless `method == + /// 2`); `threshold` is an `ItemQualities` value. + pub fn loot_method(&mut self, method: u32, loot_master: u64, threshold: u32) -> Result<()> { + self.send( + opcode::CMSG_LOOT_METHOD, + &messages::loot_method(method, loot_master, threshold), + ) + } + + /// Disband the group (`CMSG_GROUP_DISBAND`, empty body). + pub fn group_disband(&mut self) -> Result<()> { + self.send(opcode::CMSG_GROUP_DISBAND, &messages::group_disband()) + } + + /// Ask a party/raid member's full live stats (`CMSG_REQUEST_PARTY_MEMBER_STATS`, a full guid). + /// Answered by `SMSG_PARTY_MEMBER_STATS_FULL` (or its offline-miss shape). + pub fn request_party_member_stats(&mut self, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_REQUEST_PARTY_MEMBER_STATS, + &messages::request_party_member_stats(guid), + ) + } + + /// Move a raid member to a different subgroup (`CMSG_GROUP_CHANGE_SUB_GROUP`, layout in + /// [`messages::group_change_sub_group`]) — raid-only drag-drop between subgroup columns. + pub fn group_change_sub_group(&mut self, name: &str, group_nr: u8) -> Result<()> { + self.send( + opcode::CMSG_GROUP_CHANGE_SUB_GROUP, + &messages::group_change_sub_group(name, group_nr), + ) + } + + /// Swap two raid members' subgroups (`CMSG_GROUP_SWAP_SUB_GROUP`, layout in + /// [`messages::group_swap_sub_group`]). + pub fn group_swap_sub_group(&mut self, name: &str, swap_with: &str) -> Result<()> { + self.send( + opcode::CMSG_GROUP_SWAP_SUB_GROUP, + &messages::group_swap_sub_group(name, swap_with), + ) + } + + /// Convert the party into a raid (`CMSG_GROUP_RAID_CONVERT`, empty body) — leader-only, + /// one-way. + pub fn group_raid_convert(&mut self) -> Result<()> { + self.send( + opcode::CMSG_GROUP_RAID_CONVERT, + &messages::group_raid_convert(), + ) + } + + /// Grant or revoke raid-assistant on a member (`CMSG_GROUP_ASSISTANT_LEADER`, layout in + /// [`messages::group_assistant_leader`]). + pub fn group_assistant_leader(&mut self, guid: u64, grant: bool) -> Result<()> { + self.send( + opcode::CMSG_GROUP_ASSISTANT_LEADER, + &messages::group_assistant_leader(guid, grant), + ) + } + + /// Ping the minimap for the group (`MSG_MINIMAP_PING`, layout in [`messages::minimap_ping`]) — + /// the server stamps our guid and relays it to the rest of the group. + pub fn minimap_ping(&mut self, x: f32, y: f32) -> Result<()> { + self.send(opcode::MSG_MINIMAP_PING, &messages::minimap_ping(x, y)) + } + + /// Set (or clear, `guid == 0`) one raid-target icon (`MSG_RAID_TARGET_UPDATE`, layout in + /// [`messages::raid_target_set`]). + pub fn raid_target_set(&mut self, icon: u8, guid: u64) -> Result<()> { + self.send( + opcode::MSG_RAID_TARGET_UPDATE, + &messages::raid_target_set(icon, guid), + ) + } + + /// Ask the current raid-target icon set (`MSG_RAID_TARGET_UPDATE`, layout in + /// [`messages::raid_target_request`]). Answered by the server's full-list shape. + pub fn raid_target_request(&mut self) -> Result<()> { + self.send( + opcode::MSG_RAID_TARGET_UPDATE, + &messages::raid_target_request(), + ) + } + + /// Start a raid ready check (`MSG_RAID_READY_CHECK`, empty body) — leader-only. + pub fn ready_check_start(&mut self) -> Result<()> { + self.send(opcode::MSG_RAID_READY_CHECK, &messages::ready_check_start()) + } + + /// Answer a raid ready check (`MSG_RAID_READY_CHECK`, layout in + /// [`messages::ready_check_answer`]). + pub fn ready_check_answer(&mut self, ready: bool) -> Result<()> { + self.send( + opcode::MSG_RAID_READY_CHECK, + &messages::ready_check_answer(ready), + ) + } +} diff --git a/crates/benilla-protocol/src/world/writer/mail.rs b/crates/benilla-protocol/src/world/writer/mail.rs new file mode 100644 index 00000000..f9a632f6 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/mail.rs @@ -0,0 +1,126 @@ +//! The mail arc's `WorldWriter` sends (decision 0544 P0): the inbox open/refresh, send, take-money/ +//! take-item/mark-read/return/delete/copy-letter verbs, the letter-body ask-once fetch, and the login +//! unread-mail query. Bodies in [`crate::messages`]'s `mail` builders (layout VERIFIED against +//! vmangos `Handlers/MailHandler.cpp` + `Server/Packets/Mail.cpp`). Split out of `writer/mod.rs` +//! the same way `channel`/`group` are — one clearly separable concern among the writer's domains. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Ask the mailbox's inbox page (`CMSG_GET_MAIL_LIST`, layout in [`messages::get_mail_list`]) — + /// the window's open verb and its refresh: one mailbox guid. Answered by + /// `SMSG_MAIL_LIST_RESULT` (a `MailList` event). + pub fn get_mail_list(&mut self, mailbox: u64) -> Result<()> { + self.send( + opcode::CMSG_GET_MAIL_LIST, + &messages::get_mail_list(mailbox), + ) + } + + /// Send a mail (`CMSG_SEND_MAIL`, layout in [`messages::send_mail`]) — the Send tab: + /// recipient/subject/body, an optional item attachment (`item_guid == 0` = none), money, and + /// COD. `stationery`/`package` ride the wire but are discarded server-side (player mail is + /// always stored `MAIL_STATIONERY_DEFAULT`). Answered by `SMSG_SEND_MAIL_RESULT` (a + /// `SendMailResult` event). + #[allow(clippy::too_many_arguments)] + pub fn send_mail( + &mut self, + mailbox: u64, + receiver: &str, + subject: &str, + body: &str, + stationery: u32, + package: u32, + item_guid: u64, + money: u32, + cod: u32, + ) -> Result<()> { + self.send( + opcode::CMSG_SEND_MAIL, + &messages::send_mail( + mailbox, receiver, subject, body, stationery, package, item_guid, money, cod, + ), + ) + } + + /// Take a mail's attached money (`CMSG_MAIL_TAKE_MONEY`, layout in + /// [`messages::mail_take_money`]). Answered by `SMSG_SEND_MAIL_RESULT` + /// (action [`messages::mail_action::MONEY_TAKEN`]). + pub fn mail_take_money(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_TAKE_MONEY, + &messages::mail_take_money(mailbox, mail_id), + ) + } + + /// Take a mail's attached item (`CMSG_MAIL_TAKE_ITEM`, layout in + /// [`messages::mail_take_item`]). Answered by `SMSG_SEND_MAIL_RESULT` + /// (action [`messages::mail_action::ITEM_TAKEN`]). + pub fn mail_take_item(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_TAKE_ITEM, + &messages::mail_take_item(mailbox, mail_id), + ) + } + + /// Mark a mail read (`CMSG_MAIL_MARK_AS_READ`, layout in [`messages::mail_mark_as_read`]) — + /// sent when a letter opens. **No response packet**: the app flips the local `checked` READ + /// bit and repaints. + pub fn mail_mark_as_read(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_MARK_AS_READ, + &messages::mail_mark_as_read(mailbox, mail_id), + ) + } + + /// Return a mail to its sender (`CMSG_MAIL_RETURN_TO_SENDER`, layout in + /// [`messages::mail_return_to_sender`]). Answered by `SMSG_SEND_MAIL_RESULT` + /// (action [`messages::mail_action::RETURNED`]). + pub fn mail_return_to_sender(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_RETURN_TO_SENDER, + &messages::mail_return_to_sender(mailbox, mail_id), + ) + } + + /// Delete a mail (`CMSG_MAIL_DELETE`, layout in [`messages::mail_delete`]) — the taken-letter + /// close and the explicit Delete button alike. Answered by `SMSG_SEND_MAIL_RESULT` + /// (action [`messages::mail_action::DELETED`]). + pub fn mail_delete(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_DELETE, + &messages::mail_delete(mailbox, mail_id), + ) + } + + /// Make a permanent copy of a letter's body (`CMSG_MAIL_CREATE_TEXT_ITEM`, layout in + /// [`messages::mail_create_text_item`]) — the open letter's "Take Attachments" letter button. + /// Answered by `SMSG_SEND_MAIL_RESULT` (action [`messages::mail_action::MADE_PERMANENT`]). + pub fn mail_create_text_item(&mut self, mailbox: u64, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_MAIL_CREATE_TEXT_ITEM, + &messages::mail_create_text_item(mailbox, mail_id), + ) + } + + /// Fetch a letter's body text (`CMSG_ITEM_TEXT_QUERY`, layout in [`messages::item_text_query`]) + /// — sent once per mail whose `item_text_id != 0` (ask-once cacheable). Answered by + /// `SMSG_ITEM_TEXT_QUERY_RESPONSE` (a `MailItemText` event). + pub fn item_text_query(&mut self, text_id: u32, mail_id: u32) -> Result<()> { + self.send( + opcode::CMSG_ITEM_TEXT_QUERY, + &messages::item_text_query(text_id, mail_id), + ) + } + + /// Ask whether unread mail is waiting (`MSG_QUERY_NEXT_MAIL_TIME`, EMPTY body — same opcode + /// answers with one `f32`: `0.0` unread waiting, `-86400.0` none). Sent once at login to seed + /// `HasNewMail()`/the minimap letter icon. + pub fn query_next_mail_time(&mut self) -> Result<()> { + self.send(opcode::MSG_QUERY_NEXT_MAIL_TIME, &[]) + } +} diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs new file mode 100644 index 00000000..a2531d33 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -0,0 +1,1042 @@ +use std::net::TcpStream; + +use anyhow::Result; +use benilla_srp::vanilla_header::EncrypterHalf; + +use crate::messages::{self, opcode, JumpInfo, TransportPose}; + +use super::movement::{client_uptime_ms, movement_info}; +use super::send_packet; + +mod channel; +mod group; +mod mail; +mod trade; + +/// Write half of a split [`WorldSession`](super::WorldSession) — owns a cloned socket + the encrypter. Used to send our own +/// movement (`MSG_MOVE_*`). The active player must already be the confirmed mover (see +/// [`WorldSession::set_active_mover`](super::WorldSession::set_active_mover)). Coordinates are raw WoW yards; `orientation` is radians. +pub struct WorldWriter { + pub(super) stream: TcpStream, + pub(super) encrypter: EncrypterHalf, + /// The language every chat send carries — the logged-in character's faction tongue, + /// inherited from the session at split (set by `WorldSession::player_login` off the roster's + /// race). Load-bearing: vmangos drops the whole message (dot-commands included) when the + /// character doesn't know the language — a Horde character sending Common gets only an + /// `SMSG_NOTIFICATION` back, never the say / the command. + pub(super) chat_language: u32, +} + +impl WorldWriter { + fn send(&mut self, opcode: u16, body: &[u8]) -> Result<()> { + send_packet(&mut self.stream, Some(&mut self.encrypter), opcode, body) + } + + /// Send one self-movement packet: a `MSG_MOVE_*` `opcode` carrying a `MovementInfo` with the given + /// `flags` + pose. The caller (the controller) chooses the opcode per movement-axis transition + /// (start/stop forward/back/strafe/turn), the periodic heartbeat, and the facing update — exactly as + /// the real client does — and the `flags` it passes are the live CMovement bits the server relays to + /// nearby players. `flags` must only set bits whose `MovementInfo` tail we serialize — the base + /// directional/turn/walk bits, `JUMPING` (with its `jump` tail), `SWIMMING` (with its `pitch` + /// tail), and `ON_TRANSPORT` (with its `transport` local-frame tail — decision 0438 phase 2). + #[allow(clippy::too_many_arguments)] + pub fn send_movement( + &mut self, + opcode: u16, + flags: u32, + pos: [f32; 3], + orientation: f32, + pitch: f32, + fall_time: u32, + jump: Option, + transport: Option, + ) -> Result<()> { + let mut info = movement_info(pos, orientation, flags); + // Each conditional tail is gated on its flag by the serializer, so the flag and the value must + // agree (they share `flags`): `SWIMMING` ⇒ the swim pitch, `JUMPING` ⇒ the ballistic launch + // tail, `ON_TRANSPORT` ⇒ the rider's local pose. + info.pitch = pitch; + info.fall_time = fall_time; + info.jump = jump; + info.transport = transport; + self.send(opcode, &messages::movement(&info)) + } + + /// Acknowledge that a server-authored spline (Charge/knockback/taxi — an `SMSG_MONSTER_MOVE` + /// addressed to our own guid) finished: `CMSG_MOVE_SPLINE_DONE` with a `MovementInfo` at the + /// ride's endpoint and the `spline_id` we were driven by. The server sets `SplineDonePending` for + /// a player mover and validates this against its newest spline id, then relocates us and + /// re-broadcasts a stop/heartbeat to observers — so it must be sent, at rest, once the ride ends. + pub fn move_spline_done( + &mut self, + flags: u32, + pos: [f32; 3], + orientation: f32, + spline_id: u32, + ) -> Result<()> { + let info = movement_info(pos, orientation, flags); + self.send( + opcode::CMSG_MOVE_SPLINE_DONE, + &messages::move_spline_done(&info, spline_id), + ) + } + + /// Echo a cross-map worldport ack: confirms `SMSG_NEW_WORLD` so the server resumes its object + /// stream on the new continent (`MSG_MOVE_WORLDPORT_ACK` has an empty body). + pub fn worldport_ack(&mut self) -> Result<()> { + self.send(opcode::MSG_MOVE_WORLDPORT_ACK, &[]) + } + + /// Answer a `SMSG_FORCE_*_SPEED_CHANGE` (`CMSG_FORCE_*_SPEED_CHANGE_ACK`, picked by `kind`): + /// echo the mover `guid` + `counter` + the exact `speed` the server sent, carrying our live + /// `MovementInfo` (same field set as [`Self::send_movement`] — the server relocates us to it). + /// Mandatory: unacked, the server force-resolves the change after ~4 s and flags its anticheat. + #[allow(clippy::too_many_arguments)] + pub fn force_speed_change_ack( + &mut self, + kind: messages::SpeedKind, + guid: u64, + counter: u32, + speed: f32, + flags: u32, + pos: [f32; 3], + orientation: f32, + pitch: f32, + fall_time: u32, + jump: Option, + transport: Option, + ) -> Result<()> { + let mut info = movement_info(pos, orientation, flags); + info.pitch = pitch; + info.fall_time = fall_time; + info.jump = jump; + info.transport = transport; + self.send( + kind.ack_opcode(), + &messages::force_speed_ack(guid, counter, &info, speed), + ) + } + + /// Send the ~30 s keepalive (`CMSG_PING`): `sequence` is the ++counter the server echoes back + /// as `SMSG_PONG`, `last_rtt_ms` the previous round-trip measurement (the real client's + /// lastRtt; the server stores it as our reported latency). Cadence discipline is the caller's: + /// vmangos kicks a socket whose pings repeat faster than 27 s apart (`_HandlePing`'s + /// overspeed count), so this is a timer send, never a retry. + pub fn ping(&mut self, sequence: u32, last_rtt_ms: u32) -> Result<()> { + self.send(opcode::CMSG_PING, &messages::ping(sequence, last_rtt_ms)) + } + + /// Ask to leave the world back to character select (`CMSG_LOGOUT_REQUEST`, empty body). The + /// server answers `SMSG_LOGOUT_RESPONSE` (a refusal while in combat) and, once the logout + /// completes (instant for a resting/GM character), `SMSG_LOGOUT_COMPLETE` — which the stream + /// surfaces as [`SessionEvent::LoggedOut`](crate::SessionEvent::LoggedOut). + pub fn logout_request(&mut self) -> Result<()> { + self.send(opcode::CMSG_LOGOUT_REQUEST, &[]) + } + + /// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the + /// packet the real client sends when the cinematic ends or the player ESCs out. Must answer + /// every `SMSG_TRIGGER_CINEMATIC` ([`SessionEvent::CinematicTriggered`] + /// (crate::SessionEvent::CinematicTriggered)): while one runs unacked, vmangos anchors object + /// visibility to the flying cinematic camera and the world around the body despawns. + pub fn complete_cinematic(&mut self) -> Result<()> { + self.send(opcode::CMSG_COMPLETE_CINEMATIC, &[]) + } + + /// Echo a same-map teleport ack so the server completes the near-teleport **immediately** + /// (relocates us + streams the surrounding objects). Without a valid ack the teleport only + /// finishes on a ~20s server-side fallback, so the destination's objects appear ~20s late. + /// + /// vanilla 1.12 / vmangos expect a **full 8-byte guid** for this opcode (the lone movement opcode + /// that does — the `MovementInfo` ones are correctly packed). With a packed guid the server's + /// `ByteBuffer` overruns and drops the packet (confirmed by a vmangos capture: opcode `0xC7` → + /// `ByteBufferException`, then the teleport completing 20s later). + pub fn teleport_ack(&mut self, guid: u64, counter: u32) -> Result<()> { + self.send( + opcode::MSG_MOVE_TELEPORT_ACK, + &messages::teleport_ack(guid, counter, client_uptime_ms()), + ) + } + + /// Acknowledge a server root/unroot on our mover (`SMSG_FORCE_MOVE_[UN]ROOT` → the matching + /// `CMSG_FORCE_MOVE_[UN]ROOT_ACK`): full guid + the echoed counter + our current + /// `MovementInfo`. Un-acked, the change never reaches observers; a wrong/zero counter trips + /// the server's cheat log (`HandleMoveRootAck`). While rooted, `flags` must not carry moving + /// bits (vmangos `MovementInfo.h`: moving flags with `MOVEFLAG_ROOT` freeze the real client). + pub fn move_root_ack( + &mut self, + guid: u64, + counter: u32, + rooted: bool, + flags: u32, + pos: [f32; 3], + orientation: f32, + ) -> Result<()> { + let info = movement_info(pos, orientation, flags); + self.send( + if rooted { + opcode::CMSG_FORCE_MOVE_ROOT_ACK + } else { + opcode::CMSG_FORCE_MOVE_UNROOT_ACK + }, + &messages::move_flag_ack(guid, counter, &info, None), + ) + } + + /// Acknowledge a water-walk grant/removal on our mover (`SMSG_MOVE_WATER_WALK`/`LAND_WALK` → + /// `CMSG_MOVE_WATER_WALK_ACK`, one ack opcode for both directions): the root ack's shape plus + /// the trailing `u32 apply` the server's `MoveFlagChangeAck` reader expects. + pub fn water_walk_ack( + &mut self, + guid: u64, + counter: u32, + on: bool, + flags: u32, + pos: [f32; 3], + orientation: f32, + ) -> Result<()> { + let info = movement_info(pos, orientation, flags); + self.send( + opcode::CMSG_MOVE_WATER_WALK_ACK, + &messages::move_flag_ack(guid, counter, &info, Some(on)), + ) + } + + /// Release the spirit (`CMSG_REPOP_REQUEST`, empty body — decision 0308 slice 1): valid only + /// while dead and unreleased (the server refuses it alive or already-ghost). The server + /// answers with the ghost form (aura 8326 → the ghost flags), the corpse object, unroot, + /// water-walk, `SMSG_CORPSE_RECLAIM_DELAY`, and the graveyard teleport. + pub fn repop_request(&mut self) -> Result<()> { + self.send(opcode::CMSG_REPOP_REQUEST, &[]) + } + + /// Ask where our corpse is (`MSG_CORPSE_QUERY`, empty request): answered by the same opcode + /// (the [`SessionEvent::CorpseQuery`](crate::SessionEvent::CorpseQuery) feed for the map + /// markers + the corpse-run range gate). + pub fn corpse_query(&mut self) -> Result<()> { + self.send(opcode::MSG_CORPSE_QUERY, &[]) + } + + /// Reclaim our corpse (`CMSG_RECLAIM_CORPSE` — the RECOVER_CORPSE popup's Accept): the corpse's + /// guid. Server gates: ghost, the reclaim delay elapsed, within 39 yd. Success comes back as + /// ordinary descriptor deltas (alive, ghost flags clear) + the corpse-to-bones swap. + pub fn reclaim_corpse(&mut self, corpse_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_RECLAIM_CORPSE, + &messages::reclaim_corpse(corpse_guid), + ) + } + + /// Take the spirit healer's resurrection (`CMSG_SPIRIT_HEALER_ACTIVATE` — the XP_LOSS + /// confirm's final Accept): res at 50%, 25% durability loss, resurrection sickness at + /// level ≥ 11. `npc` is the spirit healer's guid (from `SMSG_SPIRIT_HEALER_CONFIRM`). + pub fn spirit_healer_activate(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_SPIRIT_HEALER_ACTIVATE, + &messages::spirit_healer_activate(npc), + ) + } + + /// Answer a resurrection offer (`CMSG_RESURRECT_RESPONSE` — the RESURRECT popup's + /// Accept/Decline): the offerer's guid + the accept byte. + pub fn resurrect_response(&mut self, caster: u64, accept: bool) -> Result<()> { + self.send( + opcode::CMSG_RESURRECT_RESPONSE, + &messages::resurrect_response(caster, accept), + ) + } + + /// Set (or clear) our current target: `CMSG_SET_SELECTION` carrying a **full 8-byte GUID** (verified + /// vmangos `SetSelection::ReadFromWorldPacket` — `recv_data >> guid` reads a raw `uint64`, not a + /// packed guid). `guid == 0` clears the selection. The real client sends this the moment the local + /// player picks a unit; the server records it in the player's `UNIT_FIELD_TARGET` and relays it to + /// nearby observers. + pub fn set_selection(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_SET_SELECTION, &messages::full_guid(guid)) + } + + /// Send a chat line as `/say`. Used to issue server **dot commands** (`.tele Westfall`, …) — + /// vmangos parses anything beginning with `.` as a GM command on the way in, but only *after* + /// the language gate: the send must speak [`Self::chat_language`], the character's own tongue + /// (**vmangos rejects `Universal` from clients**, and rejects a tongue the character doesn't + /// know — which silently ate every Horde character's commands while this was hardcoded Common). + pub fn send_chat(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_SAY, self.chat_language, message), + ) + } + + /// Send a `/yell` line (`CHAT_MSG_YELL`) — same body shape as [`Self::send_chat`], a different + /// wire chat type (VERIFIED vmangos `SharedDefines.h:1199`). + pub fn send_yell(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_YELL, self.chat_language, message), + ) + } + + /// Send a custom `/emote ` line (`CHAT_MSG_EMOTE`, VERIFIED vmangos + /// `SharedDefines.h:1202`) — distinct from [`Self::text_emote`]'s DBC-indexed `/wave`: the + /// server renders it verbatim as `"PlayerName "`. + pub fn send_emote_chat(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_EMOTE, self.chat_language, message), + ) + } + + /// Send a `/whisper ` line (`CHAT_MSG_WHISPER`): body in + /// [`messages::messagechat_whisper`], the one `CMSG_MESSAGECHAT` shape that carries a name + /// ahead of the message (VERIFIED vmangos `Server/Packets/Chat.cpp:3-12`). A bad `target` + /// answers `SMSG_CHAT_PLAYER_NOT_FOUND`, unmodelled here — silent from the client's own POV. + pub fn send_whisper(&mut self, target: &str, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat_whisper(self.chat_language, target, message), + ) + } + + /// Send a `/p` party line (`CHAT_MSG_PARTY`) — same body shape as [`Self::send_chat`] (VERIFIED + /// vmangos `Handlers/ChatHandler.cpp:472-493`: the server rebroadcasts to the group, no group + /// membership needed on the wire — the server enforces it and silently drops the send if we're + /// not grouped). + pub fn send_party(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_PARTY, self.chat_language, message), + ) + } + + /// Send a `/ra` raid line (`CHAT_MSG_RAID`) — requires a raid group server-side + /// (`Handlers/ChatHandler.cpp:514-536`). + pub fn send_raid(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_RAID, self.chat_language, message), + ) + } + + /// Send a `/g` guild line (`CHAT_MSG_GUILD`) — requires guild membership server-side + /// (`Handlers/ChatHandler.cpp:494-503`). + pub fn send_guild(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_GUILD, self.chat_language, message), + ) + } + + /// Send a `/o` guild-officer line (`CHAT_MSG_OFFICER`) — requires guild membership server-side + /// (`Handlers/ChatHandler.cpp:504-513`). + pub fn send_officer(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_OFFICER, self.chat_language, message), + ) + } + + /// Send a `/rl` raid-leader line (`CHAT_MSG_RAID_LEADER`) — leader-only server-side + /// (`Handlers/ChatHandler.cpp:538-559`; VERIFIED active for 5875, `> CLIENT_BUILD_1_10_2`). + pub fn send_raid_leader(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_RAID_LEADER, self.chat_language, message), + ) + } + + /// Send a `/rw` raid-warning line (`CHAT_MSG_RAID_WARNING`) — leader/assistant-only server-side + /// (`Handlers/ChatHandler.cpp:561-576`). + pub fn send_raid_warning(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat( + messages::CHAT_TYPE_RAID_WARNING, + self.chat_language, + message, + ), + ) + } + + /// Send a battleground raid line (`CHAT_MSG_BATTLEGROUND`) — requires a BG group server-side + /// (`Handlers/ChatHandler.cpp:579-593`; VERIFIED active for 5875, `> CLIENT_BUILD_1_11_2`). + pub fn send_battleground(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat( + messages::CHAT_TYPE_BATTLEGROUND, + self.chat_language, + message, + ), + ) + } + + /// Send a battleground-leader line (`CHAT_MSG_BATTLEGROUND_LEADER`) — BG-group-leader-only + /// server-side (`Handlers/ChatHandler.cpp:595-609`). + pub fn send_battleground_leader(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat( + messages::CHAT_TYPE_BATTLEGROUND_LEADER, + self.chat_language, + message, + ), + ) + } + + /// Toggle AFK (`CHAT_MSG_AFK`, `/afk [message]`) — `message` may be empty (a bare toggle); when + /// non-empty it becomes the AFK auto-reply text (vmangos `Handlers/ChatHandler.cpp:611-630`: + /// `masterPlr->afkMsg`). Setting AFK clears DND server-side (mutually exclusive). + pub fn send_afk(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_AFK, self.chat_language, message), + ) + } + + /// Toggle DND (`CHAT_MSG_DND`, `/dnd [message]`) — same shape as [`Self::send_afk`] + /// (`Handlers/ChatHandler.cpp:632-648`); mutually exclusive with AFK server-side. + pub fn send_dnd(&mut self, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat(messages::CHAT_TYPE_DND, self.chat_language, message), + ) + } + + /// Send a `/1`-style channel line (`CHAT_MSG_CHANNEL`) — `channel` is the channel **name** + /// (`Handlers/ChatHandler.cpp:255-327`; body in [`messages::messagechat_channel`]). Requires + /// membership; the server silently drops it if we're not on the channel. + pub fn send_channel(&mut self, channel: &str, message: &str) -> Result<()> { + self.send( + opcode::CMSG_MESSAGECHAT, + &messages::messagechat_channel(self.chat_language, channel, message), + ) + } + + /// Tell the server we've added `guid` to our ignore list (`CMSG_CHAT_IGNORED`, a raw 8-byte + /// guid — VERIFIED vmangos `WorldPackets::Misc::ChatIgnored::ReadFromWorldPacket`, + /// `Server/Packets/Misc.cpp:127-130`). The server whispers that player a `CHAT_MSG_IGNORED` + /// self-notice ("So-and-so is now ignoring you") — `Handlers/ChatHandler.cpp:755-763`. + pub fn chat_ignored(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_CHAT_IGNORED, &messages::full_guid(guid)) + } + + /// Ask our played time (`CMSG_PLAYED_TIME`, empty body, layout in [`messages::played_time`]) — + /// the `/played` command. Answered by `SMSG_PLAYED_TIME` (total + since-last-level-up seconds). + pub fn played_time(&mut self) -> Result<()> { + self.send(opcode::CMSG_PLAYED_TIME, &messages::played_time()) + } + + /// Roll `/random [min] [max]` (`MSG_RANDOM_ROLL`, layout in [`messages::random_roll`]): the + /// server validates `min <= max <= 10000` and broadcasts the result (to our group if we're in + /// one, else just to us) as the same opcode's server→client shape — `min, max, roll, guid` + /// (decoded by the codec into a `RandomRoll` event). + pub fn random_roll(&mut self, min: u32, max: u32) -> Result<()> { + self.send(opcode::MSG_RANDOM_ROLL, &messages::random_roll(min, max)) + } + + /// Ask for a player character's name/race/gender/class (`CMSG_NAME_QUERY`, a full 8-byte guid — + /// vmangos `QueryPlayerName::ReadFromWorldPacket`). Answered by `SMSG_NAME_QUERY_RESPONSE`. + pub fn name_query(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_NAME_QUERY, &messages::full_guid(guid)) + } + + /// Perform a chat emote (`CMSG_TEXT_EMOTE`: EmotesText id + target guid, 0 = untargeted). + /// The server echoes `SMSG_TEXT_EMOTE` to everyone in range **including us**, so our own + /// emote's sound/anim arrive through the same receive path as everyone else's. + pub fn text_emote(&mut self, text_id: u32, target: u64) -> Result<()> { + self.send( + opcode::CMSG_TEXT_EMOTE, + &messages::text_emote(text_id, target), + ) + } + + /// Ask for a creature template's name/subname (`CMSG_CREATURE_QUERY`: entry + guid). The `entry` + /// is the one embedded in the creature's guid bits 24–47 ([`crate::guid::entry`]). Answered by + /// `SMSG_CREATURE_QUERY_RESPONSE`. + pub fn creature_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_CREATURE_QUERY, + &messages::creature_query(entry, guid), + ) + } + + /// Cast a spell (`CMSG_CAST_SPELL`): `target: None` = a self/implicit cast, `Some(guid)` = an + /// explicit unit target (body in [`messages::cast_spell`]). The server answers + /// `SMSG_CAST_RESULT` (and `SMSG_SPELL_START`/`GO` on success, unmodelled yet). + pub fn cast_spell(&mut self, spell_id: u32, target: Option) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell(spell_id, target), + ) + } + + /// Cancel one of our own auras (`CMSG_CANCEL_AURA`, body in [`messages::cancel_aura`]) — the + /// right-click-a-buff wire. Carries the **spell id**, not a slot; the server refuses passives, + /// no-cancel spells and debuffs (decision 0257). No answer packet — the removal arrives as a + /// `UNIT_FIELD_AURA` delta zeroing the slot. + pub fn cancel_aura(&mut self, spell_id: u32) -> Result<()> { + self.send(opcode::CMSG_CANCEL_AURA, &messages::cancel_aura(spell_id)) + } + + /// Cast an OPEN_LOCK spell at a **GameObject** (`CMSG_CAST_SPELL`, body in + /// [`messages::cast_spell_gameobject`]) — the right-click on a locked chest / mining vein / herb + /// node (decision 0239). The server runs `EffectOpenLock` → for a chest, opens the loot + /// (`SMSG_LOOT_RESPONSE`); the profession/skill gate is the server's. Answered by + /// `SMSG_CAST_RESULT` on refusal. + pub fn cast_spell_gameobject(&mut self, spell_id: u32, go_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell_gameobject(spell_id, go_guid), + ) + } + + /// Cast an item-targeted spell (`CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` + the item's packed + /// guid — [`messages::cast_spell_item`]): the enchant cast the CraftFrame's item pick + /// completes (decision 0437 phase 3). The server resolves the item, checks reagents, applies + /// the enchant; refusal answers `SMSG_CAST_RESULT`. + pub fn cast_spell_item(&mut self, spell_id: u32, item_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell_item(spell_id, item_guid), + ) + } + + /// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`, a full 8-byte guid — vmangos + /// `AttackSwing::ReadFromWorldPacket`). Echoed back as `SMSG_ATTACKSTART`. + pub fn attack_swing(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(guid)) + } + + /// Stop melee auto-attack (`CMSG_ATTACKSTOP`, empty body). Echoed as `SMSG_ATTACKSTOP`. + pub fn attack_stop(&mut self) -> Result<()> { + self.send(opcode::CMSG_ATTACKSTOP, &[]) + } + + /// Stop our ranged auto-repeat (`CMSG_CANCEL_AUTO_REPEAT_SPELL`, empty body) — the ack every + /// local cancel sends (the client's one send site `0x6ea0c6`, inside the cancel `0x6ea080`). + pub fn cancel_auto_repeat(&mut self) -> Result<()> { + self.send(opcode::CMSG_CANCEL_AUTO_REPEAT_SPELL, &[]) + } + + /// Cancel a named in-flight cast (`CMSG_CANCEL_CAST`: one `u32` spell id — vmangos + /// `HandleCancelCastOpcode`). Sent by the wand-only auto-repeat handoff (`0x6095b8`) and by + /// the cast bar's local self-cancel (movement/Esc mid-cast, `benilla::ui_cast`). + pub fn cancel_cast(&mut self, spell_id: u32) -> Result<()> { + self.send(opcode::CMSG_CANCEL_CAST, &spell_id.to_le_bytes()) + } + + /// End our own running channel (`CMSG_CANCEL_CHANNELLING`: one `u32` spell id, which vmangos + /// reads and ignores — the interrupt is unconditional; the real client still writes it). The + /// channel half of the local self-cancel (`benilla::ui_cast`). + pub fn cancel_channelling(&mut self, spell_id: u32) -> Result<()> { + self.send(opcode::CMSG_CANCEL_CHANNELLING, &spell_id.to_le_bytes()) + } + + /// Set (or clear, `packed == 0`) one action-bar slot (`CMSG_SET_ACTION_BUTTON`, layout in + /// [`messages::set_action_button`]) — decision 0216 §7/0218 §4: the bar is + /// client-authoritative, so this is the ONLY wire traffic a local pickup/place/hop generates, + /// one send per slot mutation (a drag-swap is two sends, never atomic). No dedicated answer + /// packet — `SMSG_ACTION_BUTTONS` only ever re-arrives on a server-side edit (a GM command, a + /// macro-menu save), never as our own edit's echo. + pub fn set_action_button(&mut self, button: u8, packed: u32) -> Result<()> { + self.send( + opcode::CMSG_SET_ACTION_BUTTON, + &messages::set_action_button(button, packed), + ) + } + + /// Tell the server our sheath state (`CMSG_SETSHEATHED`): `state` 0 unarmed/stowed, 1 melee + /// drawn, 2 ranged drawn. Purely client-volunteered (body in [`messages::set_sheathed`]) — it + /// becomes our own `UNIT_FIELD_BYTES_2` sheath byte, which other clients read. Sent by the + /// manual toggle, the attack-start auto-draw, and every reconcile force (byte-verified: the + /// setter `0x611cf0` sends it whenever `bFireEvent` — wow-re `sheath-policy.md`, + /// decision 0080). + pub fn set_sheathed(&mut self, state: u32) -> Result<()> { + self.send(opcode::CMSG_SETSHEATHED, &messages::set_sheathed(state)) + } + + /// Tell the server our stand state (`CMSG_STANDSTATECHANGE`): 0 stand · 1 sit · 3 sleep · + /// 8 kneel (the only values vmangos accepts). Client-volunteered like sheath — the echo into + /// `UNIT_FIELD_BYTES_1` byte 0 drives every observer's sit/stand pose (decision 0080c). + pub fn stand_state_change(&mut self, state: u32) -> Result<()> { + self.send( + opcode::CMSG_STANDSTATECHANGE, + &messages::stand_state_change(state), + ) + } + + /// The mounted space-bar flourish (`CMSG_MOUNTSPECIAL_ANIM`, EMPTY body — VERIFIED vmangos + /// `HandleMountSpecialAnimOpcode`). The sender plays its own MountSpecial(94) locally at + /// send time and self-suppresses the broadcast echo (decision 0441 P2 — whether the echo + /// arrives is a server-config detail). + pub fn mount_special(&mut self) -> Result<()> { + self.send(opcode::CMSG_MOUNTSPECIAL_ANIM, &[]) + } + + /// Ask an item template's display head (`CMSG_ITEM_QUERY_SINGLE`: entry + item guid, 0 for + /// template-only asks). Answered by `SMSG_ITEM_QUERY_SINGLE_RESPONSE` (an `ItemTemplate` + /// event) — the T2 container groundwork. + pub fn item_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_ITEM_QUERY_SINGLE, + &messages::item_query(entry, guid), + ) + } + + /// Use an item by bag position (`CMSG_USE_ITEM`, layout in [`messages::use_item`]) — eat the + /// food, drink the potion, hearthstone home. The server answers with the effect (values + /// deltas, a stack decrement/destroy) or `SMSG_CAST_RESULT` on refusal. + pub fn use_item(&mut self, bag_index: u8, slot: u8, spell_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_USE_ITEM, + &messages::use_item(bag_index, slot, spell_slot), + ) + } + + /// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, layout in [`messages::auto_equip_item`]) — the + /// server picks the destination slot. Success arrives as inventory-slot values deltas (and the + /// visible-item change everyone renders); refusal as `SMSG_INVENTORY_CHANGE_FAILURE`. + pub fn auto_equip_item(&mut self, bag_index: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOEQUIP_ITEM, + &messages::auto_equip_item(bag_index, slot), + ) + } + + /// Load ammo into the ammo slot (`CMSG_SET_AMMO`, layout in [`messages::set_ammo`]) — the + /// client's own auto-equip fork for ammo-class items (wow-re `cursor-dragdrop-slots.md`). + /// Addressed by item *entry*, not a bag slot; the stack stays in the bag and `PLAYER_AMMO_ID` + /// starts referencing it. A wrong/absent ranged weapon refuses via + /// `SMSG_INVENTORY_CHANGE_FAILURE`. Decision 0526. + pub fn set_ammo(&mut self, entry: u32) -> Result<()> { + self.send(opcode::CMSG_SET_AMMO, &messages::set_ammo(entry)) + } + + /// Swap two of the player's own inventory slots (`CMSG_SWAP_INV_ITEM`, layout in + /// [`messages::swap_inv_item`]) — the wire for a backpack-internal pick/place/swap (both slots + /// are `INVENTORY_SLOT_ITEM_START`+i). An empty destination is a move; the server settles both + /// slots with values deltas, or refuses via `SMSG_INVENTORY_CHANGE_FAILURE`. + pub fn swap_inv_item(&mut self, src_slot: u8, dst_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_SWAP_INV_ITEM, + &messages::swap_inv_item(src_slot, dst_slot), + ) + } + + /// The general bag↔bag move (`CMSG_SWAP_ITEM`, layout in [`messages::swap_item`]): either + /// endpoint may be an equipped bag (unlike [`Self::swap_inv_item`], which only ever addresses + /// the player's own grid) — the wire for a whole-space bag-window pick/place/swap (decision + /// 0216 §6, slice 2). An empty destination is a move, same as `swap_inv_item`; refusal answers + /// `SMSG_INVENTORY_CHANGE_FAILURE`. + pub fn swap_item( + &mut self, + dst_bag: u8, + dst_slot: u8, + src_bag: u8, + src_slot: u8, + ) -> Result<()> { + self.send( + opcode::CMSG_SWAP_ITEM, + &messages::swap_item(dst_bag, dst_slot, src_bag, src_slot), + ) + } + + /// Split a stack (`CMSG_SPLIT_ITEM`, layout in [`messages::split_item`]): carry `count` off + /// `(src_bag, src_slot)` onto `(dst_bag, dst_slot)` — either endpoint may be an equipped bag + /// (unlike [`Self::swap_inv_item`]). Success settles both slots via values deltas; refusal + /// answers `SMSG_INVENTORY_CHANGE_FAILURE`. + pub fn split_item( + &mut self, + src_bag: u8, + src_slot: u8, + dst_bag: u8, + dst_slot: u8, + count: u8, + ) -> Result<()> { + self.send( + opcode::CMSG_SPLIT_ITEM, + &messages::split_item(src_bag, src_slot, dst_bag, dst_slot, count), + ) + } + + /// Destroy a bag item (`CMSG_DESTROYITEM`, layout in [`messages::destroy_item`]): `count` 0 = + /// the whole stack. The delete-confirm popup's `OnAccept` (decision 0216 §3) — no dedicated + /// answer packet; the item's disappearance is the ordinary field-update stream. + pub fn destroy_item(&mut self, bag: u8, slot: u8, count: u8) -> Result<()> { + self.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(bag, slot, count), + ) + } + + /// Open a gossip menu on an NPC (`CMSG_GOSSIP_HELLO`, layout in [`messages::gossip_hello`]) — + /// works on any interactable creature, not only gossip-flagged ones (vmangos + /// `CanInteractWithNPC`, `Player.cpp:347`, passes `UNIT_NPC_FLAG_NONE` for this opcode). + /// Answered by `SMSG_GOSSIP_MESSAGE` (a `GossipMenu` event). + pub fn gossip_hello(&mut self, npc_guid: u64) -> Result<()> { + self.send(opcode::CMSG_GOSSIP_HELLO, &messages::gossip_hello(npc_guid)) + } + + /// Choose a gossip option (`CMSG_GOSSIP_SELECT_OPTION`, layout in + /// [`messages::gossip_select_option`]): `gossip_list_id` is the option's echoed `index`; `code` + /// carries a password only for a `coded` option, omitted entirely otherwise. The server answers + /// either a fresh `SMSG_GOSSIP_MESSAGE` (a sub-menu) or `SMSG_GOSSIP_COMPLETE`. + pub fn gossip_select_option( + &mut self, + npc_guid: u64, + gossip_list_id: u32, + code: Option<&str>, + ) -> Result<()> { + self.send( + opcode::CMSG_GOSSIP_SELECT_OPTION, + &messages::gossip_select_option(npc_guid, gossip_list_id, code), + ) + } + + /// Ask for a gossip menu's greeting text (`CMSG_NPC_TEXT_QUERY`, layout in + /// [`messages::npc_text_query`]) — sent on receiving a gossip menu's `text_id`. Answered by + /// `SMSG_NPC_TEXT_UPDATE` (an `NpcGreeting` event); ask-once cacheable like an item template. + pub fn npc_text_query(&mut self, text_id: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_NPC_TEXT_QUERY, + &messages::npc_text_query(text_id, guid), + ) + } + + /// Ask a vendor's stock (`CMSG_LIST_INVENTORY`, layout in [`messages::list_inventory`]) — the + /// server requires `UNIT_NPC_FLAG_VENDOR` + the player alive (`ItemHandler.cpp:693`). Answered + /// by `SMSG_LIST_INVENTORY` (a `VendorInventory` event). + pub fn list_inventory(&mut self, vendor_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_LIST_INVENTORY, + &messages::list_inventory(vendor_guid), + ) + } + + /// Buy from a vendor (`CMSG_BUY_ITEM`, layout in [`messages::buy_item`]): `entry` is the item + /// **template** id (not the vendor row's `muid`), `count` the number of stacks. Auto-places + /// into the first free bag slot. Success updates the vendor stock (`SMSG_BUY_ITEM`) and + /// delivers the item via the normal item-create path; refusal answers `SMSG_BUY_FAILED`. + pub fn buy_item(&mut self, vendor_guid: u64, entry: u32, count: u8) -> Result<()> { + self.send( + opcode::CMSG_BUY_ITEM, + &messages::buy_item(vendor_guid, entry, count), + ) + } + + /// Sell an item to a vendor (`CMSG_SELL_ITEM`, layout in [`messages::sell_item`]): `count` 0 = + /// sell the whole stack. Success is silent (the item vanishes + coinage rises via + /// `UPDATE_OBJECT`); refusal answers `SMSG_SELL_ITEM`'s error shape (a `VendorSellFailed` + /// event). + pub fn sell_item(&mut self, vendor_guid: u64, item_guid: u64, count: u8) -> Result<()> { + self.send( + opcode::CMSG_SELL_ITEM, + &messages::sell_item(vendor_guid, item_guid, count), + ) + } + + /// Buy a sold item back (`CMSG_BUYBACK_ITEM`, layout in [`messages::buyback_item`]): `slot` is + /// the absolute player-array buyback slot 69–80. Success is the item re-creating + coinage + /// falling via `UPDATE_OBJECT`; refusal answers `SMSG_BUY_FAILED`. + pub fn buyback_item(&mut self, vendor_guid: u64, slot: u32) -> Result<()> { + self.send( + opcode::CMSG_BUYBACK_ITEM, + &messages::buyback_item(vendor_guid, slot), + ) + } + + /// Repair at a repair-capable vendor (`CMSG_REPAIR_ITEM`, layout in + /// [`messages::repair_item`]): `item_guid` 0 = repair everything. No dedicated answer packet — + /// durability rises + coinage falls via `UPDATE_OBJECT`. + pub fn repair_item(&mut self, vendor_guid: u64, item_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_REPAIR_ITEM, + &messages::repair_item(vendor_guid, item_guid), + ) + } + + /// Open the bank (`CMSG_BANKER_ACTIVATE`, layout in [`messages::banker_activate`]) — one + /// 8-byte banker guid. Answered by `SMSG_SHOW_BANK` (decision 0604). + pub fn banker_activate(&mut self, banker_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_BANKER_ACTIVATE, + &messages::banker_activate(banker_guid), + ) + } + + /// Buy the next bank-bag slot (`CMSG_BUY_BANK_SLOT`, layout in [`messages::buy_bank_slot`]). + /// No packet on success (the PLAYER_BYTES_2 count + coinage deltas are the confirmation); + /// refusal answers `SMSG_BUY_BANK_SLOT_RESULT`. + pub fn buy_bank_slot(&mut self, banker_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_BUY_BANK_SLOT, + &messages::buy_bank_slot(banker_guid), + ) + } + + /// Deposit an item into the bank (`CMSG_AUTOBANK_ITEM`, layout in + /// [`messages::autobank_item`]): the wire `(bag, slot)` of the source item. + pub fn autobank_item(&mut self, bag: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOBANK_ITEM, + &messages::autobank_item(bag, slot), + ) + } + + /// Withdraw a bank item into the bags (`CMSG_AUTOSTORE_BANK_ITEM`, layout in + /// [`messages::autostore_bank_item`]): the wire `(bag, slot)` of the bank item (vmangos + /// routes by whether the source is a bank position, so it tolerates either direction). + pub fn autostore_bank_item(&mut self, bag: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOSTORE_BANK_ITEM, + &messages::autostore_bank_item(bag, slot), + ) + } + + /// Ask (or re-ask) a trainer's service list (`CMSG_TRAINER_LIST`, layout in + /// [`messages::trainer_list`]) — one 8-byte trainer guid. The window first *opens* off the + /// gossip trainer option's `SMSG_TRAINER_LIST`; this is the *refresh* verb, re-requested after a + /// purchase to repaint the bought row green→gray (vmangos `HandleTrainerListOpcode` honors a + /// standalone re-request while the player can still interact — VERIFIED `NPCHandler.cpp:92-95`, + /// the server does not auto-resend on a buy). Answered by `SMSG_TRAINER_LIST` (a `TrainerList` + /// event). + pub fn trainer_list(&mut self, trainer_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_TRAINER_LIST, + &messages::trainer_list(trainer_guid), + ) + } + + /// Buy (learn) a trainer service (`CMSG_TRAINER_BUY_SPELL`, layout in + /// [`messages::trainer_buy_spell`]): the trainer guid + the service's spell id. Success answers + /// `SMSG_TRAINER_BUY_SUCCEEDED` and delivers the spell via the learn effect's `SMSG_LEARNED_SPELL` + /// (the green→gray repaint then needs a `CMSG_TRAINER_LIST` re-request); refusal answers + /// `SMSG_TRAINER_BUY_FAILED` with a [`messages::train_fail`] code. + pub fn trainer_buy_spell(&mut self, trainer_guid: u64, spell_id: u32) -> Result<()> { + self.send( + opcode::CMSG_TRAINER_BUY_SPELL, + &messages::trainer_buy_spell(trainer_guid, spell_id), + ) + } + + /// Ask a nearby flight master's known status (`CMSG_TAXINODE_STATUS_QUERY`, layout in + /// [`messages::taxi_node_status_query`]): the flight master's guid, not ours. Answered by + /// `SMSG_TAXINODE_STATUS` (a `TaxiNodeStatus` event, decision 0484). + pub fn taxi_node_status_query(&mut self, flightmaster_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_TAXINODE_STATUS_QUERY, + &messages::taxi_node_status_query(flightmaster_guid), + ) + } + + /// Open a flight master's taxi map (`CMSG_TAXIQUERYAVAILABLENODES`, layout in + /// [`messages::taxi_query_available_nodes`], decision 0496 I4 — CONFIRMED as built: the + /// interact ladder is first-match-wins low→high over `UNIT_NPC_FLAGS`, so only a pure + /// flightmaster reaches here). A known node answers `SMSG_SHOWTAXINODES` (a `TaxiNodesShown` + /// event); a never-visited node instead answers the first-visit learn pair (`NewTaxiPath` + + /// `TaxiNodeStatus`) and opens no menu on this click. + pub fn taxi_query_available_nodes(&mut self, flightmaster_guid: u64) -> Result<()> { + self.send( + opcode::CMSG_TAXIQUERYAVAILABLENODES, + &messages::taxi_query_available_nodes(flightmaster_guid), + ) + } + + /// Fly a single hop (`CMSG_ACTIVATETAXI`, layout in [`messages::activate_taxi`]): the + /// flight-master guid, the source node, the destination node. Answered by + /// `SMSG_ACTIVATETAXIREPLY`; success continues into the mount + `SMSG_MONSTER_MOVE` flight. + pub fn activate_taxi( + &mut self, + flightmaster_guid: u64, + source_node: u32, + dest_node: u32, + ) -> Result<()> { + self.send( + opcode::CMSG_ACTIVATETAXI, + &messages::activate_taxi(flightmaster_guid, source_node, dest_node), + ) + } + + /// Fly a multi-hop chain in one send (`CMSG_ACTIVATETAXIEXPRESS`, layout in + /// [`messages::activate_taxi_express`], decision 0496 §TU-3 — sent when no direct `TaxiPath` + /// edge exists current→target, the real discriminator the verdict corrected from a hop-count + /// guess): the flight-master guid, the route's combined fare, and the full node chain in + /// order. Answered by `SMSG_ACTIVATETAXIREPLY`, same as [`Self::activate_taxi`]. + pub fn activate_taxi_express( + &mut self, + flightmaster_guid: u64, + total_cost: u32, + nodes: &[u32], + ) -> Result<()> { + self.send( + opcode::CMSG_ACTIVATETAXIEXPRESS, + &messages::activate_taxi_express(flightmaster_guid, total_cost, nodes), + ) + } + + /// Unlearn a whole skill line (`CMSG_UNLEARN_SKILL`, layout in [`messages::unlearn_skill`]) — + /// the skills pane's abandon. No ack: the server's `SetSkill(id, 0, 0)` comes back as a + /// `PLAYER_SKILL_INFO` field update. + pub fn unlearn_skill(&mut self, skill_id: u32) -> Result<()> { + self.send( + opcode::CMSG_UNLEARN_SKILL, + &messages::unlearn_skill(skill_id), + ) + } + + /// Spend talent points (`CMSG_LEARN_TALENT`, layout in [`messages::learn_talent`]): the + /// `Talent.dbc` row id + the requested rank (0-based, learn-up-to). No dedicated reply — the + /// server validates silently; success arrives as the rank spell's learn effects plus the + /// refreshed `PLAYER_CHARACTER_POINTS1` (decision 0304). + pub fn learn_talent(&mut self, talent_id: u32, requested_rank: u32) -> Result<()> { + self.send( + opcode::CMSG_LEARN_TALENT, + &messages::learn_talent(talent_id, requested_rank), + ) + } + + /// Open a loot window (`CMSG_LOOT`, layout in [`messages::loot`]) — a full guid naming the + /// corpse/creature/player to loot. Answered by `SMSG_LOOT_RESPONSE` (a `LootResponse` event + /// on success, `LootError` on refusal). + pub fn loot(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_LOOT, &messages::loot(guid)) + } + + /// Use a world GameObject (`CMSG_GAMEOBJ_USE`, layout in [`messages::gameobj_use`]) — the single + /// player-facing verb for any usable GO (decision 0236): a full guid naming the chest/door/quest + /// object/lever. The server fans it out by GO type — a chest answers with `SMSG_LOOT_RESPONSE` + /// (the loot window), a questgiver GO with the gossip/quest packets, a door with a + /// `GAMEOBJECT_STATE` flip via `UPDATE_OBJECT` — or refuses silently (out of range, locked, no + /// quest). There is no dedicated success reply. + pub fn gameobj_use(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_GAMEOBJ_USE, &messages::gameobj_use(guid)) + } + + /// Ask for a GameObject template's type/display/name/`data[24]` head (`CMSG_GAMEOBJECT_QUERY`: + /// entry + guid — the ask-once template lookup, decision 0236). The `entry` is the one embedded + /// in the GameObject's guid bits 24–47 ([`crate::guid::entry`]), the same convention as + /// [`WorldWriter::creature_query`]. Answered by `SMSG_GAMEOBJECT_QUERY_RESPONSE`. + pub fn gameobject_query(&mut self, entry: u32, guid: u64) -> Result<()> { + self.send( + opcode::CMSG_GAMEOBJECT_QUERY, + &messages::gameobject_query(entry, guid), + ) + } + + /// Take one loot-window row (`CMSG_AUTOSTORE_LOOT_ITEM`, layout in + /// [`messages::autostore_loot_item`]): `loot_slot` is the wire's 0-based row index. The server + /// auto-places the item into the first free bag slot; success arrives via the normal + /// item-create/values path plus `SMSG_LOOT_REMOVED` clearing the row and + /// `SMSG_ITEM_PUSH_RESULT` naming what landed. + pub fn autostore_loot_item(&mut self, loot_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_AUTOSTORE_LOOT_ITEM, + &messages::autostore_loot_item(loot_slot), + ) + } + + /// Take the loot's coin pile (`CMSG_LOOT_MONEY`, empty body). Answered by + /// `SMSG_LOOT_MONEY_NOTIFY` (our share) then `SMSG_LOOT_CLEAR_MONEY` (the coin line clears for + /// every looter) plus the coinage rising via `UPDATE_OBJECT`. + pub fn loot_money(&mut self) -> Result<()> { + self.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money()) + } + + /// Close the loot window (`CMSG_LOOT_RELEASE`, layout in [`messages::loot_release`]); the + /// server ignores `guid` and releases whatever loot guid it has stored for us instead. + /// Answered by `SMSG_LOOT_RELEASE_RESPONSE` (a `LootReleaseResponse` event). + pub fn loot_release(&mut self, guid: u64) -> Result<()> { + self.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(guid)) + } + + /// Cast a group-loot vote (`CMSG_LOOT_ROLL`, layout in [`messages::loot_roll`]) — the roll is + /// addressed by the `(looted_target, item_slot)` pair `SMSG_LOOT_START_ROLL` opened it with, + /// never by the client-internal `rollID`. `roll_type` is a [`messages::roll_vote`] value; the + /// server drops anything `>= 3` without a reply. Answered by an `SMSG_LOOT_ROLL` broadcast of + /// our vote, then the resolution (`SMSG_LOOT_ROLL_WON` / `SMSG_LOOT_ALL_PASSED`). + pub fn loot_roll(&mut self, looted_target: u64, item_slot: u32, roll_type: u8) -> Result<()> { + self.send( + opcode::CMSG_LOOT_ROLL, + &messages::loot_roll(looted_target, item_slot, roll_type), + ) + } + + /// Ask a quest's detail panel (`CMSG_QUESTGIVER_QUERY_QUEST`, layout in + /// [`messages::questgiver_query_quest`]) — the click a greeting/gossip quest row makes. + /// Answered by `SMSG_QUESTGIVER_QUEST_DETAILS` (a `QuestDetail` event). + pub fn questgiver_query_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_QUERY_QUEST, + &messages::questgiver_query_quest(npc, quest), + ) + } + + /// Accept a quest (`CMSG_QUESTGIVER_ACCEPT_QUEST`, layout in + /// [`messages::questgiver_accept_quest`]) — the detail panel's Accept button. Adds it to the log; + /// the server closes the gossip window (`SMSG_GOSSIP_COMPLETE`). + pub fn questgiver_accept_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_ACCEPT_QUEST, + &messages::questgiver_accept_quest(npc, quest), + ) + } + + /// Ask a quest's turn-in progress panel (`CMSG_QUESTGIVER_COMPLETE_QUEST`, layout in + /// [`messages::questgiver_complete_quest`]) — answered by `SMSG_QUESTGIVER_REQUEST_ITEMS` + /// (a `QuestProgress` event), or OFFER_REWARD when there are no required items. + pub fn questgiver_complete_quest(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_COMPLETE_QUEST, + &messages::questgiver_complete_quest(npc, quest), + ) + } + + /// Advance from the progress panel to the reward panel (`CMSG_QUESTGIVER_REQUEST_REWARD`, layout + /// in [`messages::questgiver_request_reward`]) — the progress panel's Continue button. Answered + /// by `SMSG_QUESTGIVER_OFFER_REWARD` (a `QuestOffer` event). + pub fn questgiver_request_reward(&mut self, npc: u64, quest: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_REQUEST_REWARD, + &messages::questgiver_request_reward(npc, quest), + ) + } + + /// Choose a reward and finish the quest (`CMSG_QUESTGIVER_CHOOSE_REWARD`, layout in + /// [`messages::questgiver_choose_reward`]; `reward` = choice index) — the reward panel's Complete + /// button. Answered by `SMSG_QUESTGIVER_QUEST_COMPLETE` (a `QuestComplete` event) + the + /// XP/money/item grants via `UPDATE_OBJECT`. + pub fn questgiver_choose_reward(&mut self, npc: u64, quest: u32, reward: u32) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_CHOOSE_REWARD, + &messages::questgiver_choose_reward(npc, quest, reward), + ) + } + + /// Ask a quest's full template (`CMSG_QUEST_QUERY`, layout in [`messages::quest_query`]) — the + /// quest-log detail pane's ask-once source, distinct from [`Self::questgiver_query_quest`] + /// (which needs an NPC guid, not just the quest id). Answered by `SMSG_QUEST_QUERY_RESPONSE` + /// (a `QuestTemplate` event). + pub fn quest_query(&mut self, quest_id: u32) -> Result<()> { + self.send(opcode::CMSG_QUEST_QUERY, &messages::quest_query(quest_id)) + } + + /// Ask an NPC's questgiver dialog status (`CMSG_QUESTGIVER_STATUS_QUERY`, layout in + /// [`messages::questgiver_status_query`]) — the overhead `!`/`?` marker's value, answered by + /// `SMSG_QUESTGIVER_STATUS`. + pub fn questgiver_status_query(&mut self, npc: u64) -> Result<()> { + self.send( + opcode::CMSG_QUESTGIVER_STATUS_QUERY, + &messages::questgiver_status_query(npc), + ) + } + + /// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`, layout in + /// [`messages::questlog_remove_quest`]) — no ack SMSG; the server clears the `PLAYER_QUEST_LOG` + /// slot fields directly. + pub fn questlog_remove_quest(&mut self, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_QUESTLOG_REMOVE_QUEST, + &messages::questlog_remove_quest(slot), + ) + } +} diff --git a/crates/benilla-protocol/src/world/writer/trade.rs b/crates/benilla-protocol/src/world/writer/trade.rs new file mode 100644 index 00000000..1062c61b --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/trade.rs @@ -0,0 +1,87 @@ +//! The player-trade arc's `WorldWriter` sends (decision 0592 P0): initiate, the auto-`BEGIN_TRADE` +//! reply, the decline pair (busy/ignore), set/clear an item, set gold, and accept/unaccept/cancel. +//! Bodies in [`crate::messages`]'s `trade` builders (layout VERIFIED against vmangos +//! `Handlers/TradeHandler.cpp` + `Server/Packets/Trade.cpp`). Split out of `writer/mod.rs` the same +//! way `channel`/`group`/`mail` are — one clearly separable concern among the writer's domains. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Open a trade with another player (`CMSG_INITIATE_TRADE`, layout in + /// [`messages::initiate_trade`]): the target's guid. The server answers us on any refusal + /// (`SMSG_TRADE_STATUS`) and, on success, sends the *target* a `BEGIN_TRADE`. + pub fn initiate_trade(&mut self, target: u64) -> Result<()> { + self.send( + opcode::CMSG_INITIATE_TRADE, + &messages::initiate_trade(target), + ) + } + + /// Acknowledge a received `BEGIN_TRADE` (`CMSG_BEGIN_TRADE`, EMPTY body) — the client's + /// automatic reply that makes the server emit `OPEN_WINDOW` to both sides. + pub fn begin_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_BEGIN_TRADE, &[]) + } + + /// Decline a trade request as busy (`CMSG_BUSY_TRADE`, EMPTY body) — the server cancels it with + /// `TRADE_STATUS_BUSY` to the initiator. + pub fn busy_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_BUSY_TRADE, &[]) + } + + /// Decline a trade request as ignored (`CMSG_IGNORE_TRADE`, EMPTY body) — the server cancels it + /// with `TRADE_STATUS_IGNORE_YOU` to the initiator. + pub fn ignore_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_IGNORE_TRADE, &[]) + } + + /// Put a bag item into a trade slot (`CMSG_SET_TRADE_ITEM`, layout in + /// [`messages::set_trade_item`]): the trade slot, and the item's inventory `bag`/`slot`. + /// Clears the partner's accept and re-arms the 200 ms scam-prevention delay server-side. + pub fn set_trade_item(&mut self, trade_slot: u8, bag: u8, slot: u8) -> Result<()> { + self.send( + opcode::CMSG_SET_TRADE_ITEM, + &messages::set_trade_item(trade_slot, bag, slot), + ) + } + + /// Remove an item from a trade slot (`CMSG_CLEAR_TRADE_ITEM`, layout in + /// [`messages::clear_trade_item`]): the trade slot. + pub fn clear_trade_item(&mut self, trade_slot: u8) -> Result<()> { + self.send( + opcode::CMSG_CLEAR_TRADE_ITEM, + &messages::clear_trade_item(trade_slot), + ) + } + + /// Set the gold we are offering (`CMSG_SET_TRADE_GOLD`, layout in [`messages::set_trade_gold`]): + /// copper. Clears the partner's accept and re-arms the scam-prevention delay server-side. + pub fn set_trade_gold(&mut self, copper: u32) -> Result<()> { + self.send( + opcode::CMSG_SET_TRADE_GOLD, + &messages::set_trade_gold(copper), + ) + } + + /// Press Trade (`CMSG_ACCEPT_TRADE`, layout in [`messages::accept_trade`]) — accepting within + /// 200 ms of the last change is bounced by the server as `TRADE_STATUS_BACK_TO_TRADE`; when + /// both sides have accepted and the checks pass, the server swaps and sends both `COMPLETE`. + pub fn accept_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_ACCEPT_TRADE, &messages::accept_trade()) + } + + /// Un-press Trade (`CMSG_UNACCEPT_TRADE`, EMPTY body) — drops our accept back to editing. + pub fn unaccept_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_UNACCEPT_TRADE, &[]) + } + + /// Cancel the trade (`CMSG_CANCEL_TRADE`, EMPTY body) — the Close/Cancel path; the server + /// unwinds both sides with `TRADE_STATUS_TRADE_CANCELED`. + pub fn cancel_trade(&mut self) -> Result<()> { + self.send(opcode::CMSG_CANCEL_TRADE, &[]) + } +} diff --git a/crates/benilla-protocol/tests/auth.rs b/crates/benilla-protocol/tests/auth.rs new file mode 100644 index 00000000..ee1c4126 --- /dev/null +++ b/crates/benilla-protocol/tests/auth.rs @@ -0,0 +1,95 @@ +//! Oracle-free regression tests for the realmd login protocol — specifically **stream alignment**. +//! Login packets are not length-framed, so each reader must consume its packet *exactly*; a reader +//! that stops early leaves trailing bytes that desync the next read. These build a back-to-back +//! challenge → proof → realm-list byte stream (the real logon sequence) and read all three from one +//! cursor, which fails loudly if any reader under- or over-consumes. + +use benilla_protocol::auth; + +const N: [u8; 32] = benilla_srp::LARGE_SAFE_PRIME_LITTLE_ENDIAN; + +/// A successful `CMD_AUTH_LOGON_CHALLENGE_Server` body, including the `crc_salt` + `security_flag` +/// tail that must be consumed. +fn challenge_packet(server_public_key: &[u8; 32], salt: &[u8; 32]) -> Vec { + let mut p = vec![0x00, 0x00, 0x00]; // opcode, protocol_version, result=success + p.extend_from_slice(server_public_key); + p.push(1); // generator length + p.push(7); // generator + p.push(32); // large-safe-prime length + p.extend_from_slice(&N); + p.extend_from_slice(salt); + p.extend_from_slice(&[0u8; 16]); // crc_salt + p.push(0); // security_flag = None + p +} + +fn proof_packet(server_proof: &[u8; 20]) -> Vec { + let mut p = vec![0x01, 0x00]; // opcode, result=success + p.extend_from_slice(server_proof); + p.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id + p +} + +fn realm_list_packet() -> Vec { + let mut realms = Vec::new(); + realms.extend_from_slice(&0u32.to_le_bytes()); // realm_type + realms.push(0); // flag + realms.extend_from_slice(b"Benilla\0"); + realms.extend_from_slice(b"127.0.0.1:8085\0"); + realms.extend_from_slice(&0.0f32.to_le_bytes()); // population + realms.push(3); // number_of_characters + realms.push(0); // category + realms.push(1); // realm_id + + let mut p = vec![0x10]; // opcode + let size = (4 + 1 + realms.len() + 2) as u16; // header_padding + count + realms + footer + p.extend_from_slice(&size.to_le_bytes()); + p.extend_from_slice(&0u32.to_le_bytes()); // header_padding + p.push(1); // number_of_realms + p.extend_from_slice(&realms); + p.extend_from_slice(&0u16.to_le_bytes()); // footer_padding + p +} + +#[test] +fn logon_read_sequence_stays_aligned() { + let server_public_key: [u8; 32] = + std::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(9)); + let salt: [u8; 32] = std::array::from_fn(|i| (i as u8).wrapping_mul(11).wrapping_add(2)); + let server_proof: [u8; 20] = + std::array::from_fn(|i| (i as u8).wrapping_mul(13).wrapping_add(4)); + + // Concatenate the three replies as the server would stream them back-to-back. + let mut buf = challenge_packet(&server_public_key, &salt); + buf.extend_from_slice(&proof_packet(&server_proof)); + buf.extend_from_slice(&realm_list_packet()); + + let mut stream: &[u8] = &buf; + + // 1. Challenge — must consume crc_salt + security_flag so the proof read lands on opcode 0x01. + let reply = auth::read_challenge_reply(&mut stream).expect("challenge reply"); + assert_eq!(reply.server_public_key, server_public_key); + assert_eq!(reply.generator, 7); + assert_eq!(reply.large_safe_prime, N); + assert_eq!(reply.salt, salt); + + // 2. Proof — this is exactly where a challenge-reply under-read used to surface (`got 0xba`). + let proof = + auth::read_proof_reply(&mut stream).expect("proof reply (stream desynced if this fails)"); + assert_eq!(proof, server_proof); + + // 3. Realm list — confirms the proof reader also consumed its packet exactly. + let realms = + auth::read_realm_list(&mut stream).expect("realm list (stream desynced if this fails)"); + assert_eq!(realms.len(), 1); + assert_eq!(realms[0].name, "Benilla"); + assert_eq!(realms[0].address, "127.0.0.1:8085"); + assert_eq!(realms[0].characters, 3); + + // The whole stream should be consumed (no leftover bytes). + assert!( + stream.is_empty(), + "{} trailing bytes — a reader over/under-consumed", + stream.len() + ); +} diff --git a/crates/benilla-protocol/tests/bank.rs b/crates/benilla-protocol/tests/bank.rs new file mode 100644 index 00000000..46f1ce4f --- /dev/null +++ b/crates/benilla-protocol/tests/bank.rs @@ -0,0 +1,146 @@ +//! Oracle-free golden tests for the bank arc's protocol layer (Phase 1 of decision 0604): the +//! six-opcode wire (`CMSG_BANKER_ACTIVATE`/`SMSG_SHOW_BANK`/`CMSG_BUY_BANK_SLOT`/ +//! `SMSG_BUY_BANK_SLOT_RESULT`/`CMSG_AUTOSTORE_BANK_ITEM`/`CMSG_AUTOBANK_ITEM`) plus the two field +//! accessors the vault window needs (`player_bank_bag_slot`, `player_bank_bag_slots_purchased`). +//! Same idioms as `tests/gossip_vendor.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies +//! round-tripped through `parse_server`. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, ObjectFields}; +use benilla_protocol::ServerPacket; + +fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn bank_bodies_golden() { + // CMSG_BANKER_ACTIVATE (vmangos Npc.h:58-66): a full guid. + assert_eq!( + messages::banker_activate(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_BANKER_ACTIVATE body" + ); + + // CMSG_BUY_BANK_SLOT (vmangos Item.h:157-163): a full guid — no slot index, the server buys + // `purchased_count + 1` itself. + assert_eq!( + messages::buy_bank_slot(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_BUY_BANK_SLOT body" + ); + + // CMSG_AUTOBANK_ITEM (vmangos Item.h:108-116): srcbag, srcslot. + assert_eq!( + messages::autobank_item(255, 23), + vec![255u8, 23], + "CMSG_AUTOBANK_ITEM body" + ); + + // CMSG_AUTOSTORE_BANK_ITEM (vmangos Item.h:118-126): srcbag, srcslot — same shape, direction + // is decided server-side by whether the position names a bank slot. + assert_eq!( + messages::autostore_bank_item(255, 39), + vec![255u8, 39], + "CMSG_AUTOSTORE_BANK_ITEM body" + ); +} + +#[test] +fn show_bank_wire() { + // SMSG_SHOW_BANK (vmangos Npc.cpp:94): one full guid. + let body = hx("f0debc9a78563412"); + match messages::parse_server(messages::opcode::SMSG_SHOW_BANK, &body).unwrap() { + ServerPacket::ShowBank { banker } => { + assert_eq!(banker, 0x1234_5678_9abc_def0); + } + other => panic!("show bank, got {}", other.name()), + } + + let packet = messages::parse_server(messages::opcode::SMSG_SHOW_BANK, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::ShowBank { banker } => { + assert_eq!(banker, 0x1234_5678_9abc_def0); + } + other => panic!("show bank event, got {other:?}"), + } +} + +#[test] +fn buy_bank_slot_result_wire() { + use messages::bank_slot_result; + + // Result enum values (vmangos Player.h:91-94) — pinned so a future edit can't drift them. + assert_eq!( + ( + bank_slot_result::FAILED_TOO_MANY, + bank_slot_result::INSUFFICIENT_FUNDS, + bank_slot_result::NOTBANKER, + bank_slot_result::OK, + ), + (0, 1, 2, 3) + ); + + // SMSG_BUY_BANK_SLOT_RESULT (vmangos Item.cpp:137-140): one u32 result. Round-trip every code. + for &code in &[ + bank_slot_result::FAILED_TOO_MANY, + bank_slot_result::INSUFFICIENT_FUNDS, + bank_slot_result::NOTBANKER, + bank_slot_result::OK, + ] { + let body = code.to_le_bytes().to_vec(); + match messages::parse_server(messages::opcode::SMSG_BUY_BANK_SLOT_RESULT, &body).unwrap() { + ServerPacket::BuyBankSlotResult { result } => { + assert_eq!(result, code, "result code {code}"); + } + other => panic!("buy bank slot result, got {}", other.name()), + } + + let packet = + messages::parse_server(messages::opcode::SMSG_BUY_BANK_SLOT_RESULT, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::BuyBankSlotResult { result } => { + assert_eq!(result, code, "event result code {code}"); + } + other => panic!("buy bank slot result event, got {other:?}"), + } + } +} + +#[test] +fn player_bank_bag_slot_field() { + // FIELD_PLAYER_BANK_BAG_SLOT_1 = 612 (564 + 24×2): bank bag 0's guid lives at fields 612/613. + let f = ObjectFields::from_pairs(&[(612, 0xEF), (613, 0)]); + assert_eq!(f.player_bank_bag_slot(0), Some(0xEF)); + assert_eq!(f.player_bank_bag_slot(1), None, "unsent slot reads None"); + assert_eq!( + f.player_bank_bag_slot(6), + None, + "out of range (only 6 bag slots)" + ); + + // Bag slot 5 (the 6th, last purchasable slot): fields 622/623. + let f5 = ObjectFields::from_pairs(&[(622, 0x1234), (623, 0)]); + assert_eq!(f5.player_bank_bag_slot(5), Some(0x1234)); +} + +#[test] +fn player_bank_bag_slots_purchased_field() { + // PLAYER_BYTES_2 (field 194): byte 0 facialHair, byte 1 unk, byte 2 bankBagSlots, byte 3 + // restState (vmangos Player.h:347, PLAYER_BYTES_2_OFFSET_BANK_BAG_SLOTS = 2). Packed value + // 0x04_03_02_01 = restState 4, bankBagSlots 3, unk 2, facialHair 1. + let f = ObjectFields::from_pairs(&[(194, 0x0403_0201)]); + assert_eq!(f.player_bank_bag_slots_purchased(), Some(3)); + assert_eq!(f.player_facial_hair(), Some(1), "byte 0 unaffected"); + + // Unsent field: None, not 0 — the caller must not treat "unstreamed" as "zero slots bought". + let empty = ObjectFields::from_pairs(&[]); + assert_eq!(empty.player_bank_bag_slots_purchased(), None); + + // All 6 slots purchased. + let full = ObjectFields::from_pairs(&[(194, 6 << 16)]); + assert_eq!(full.player_bank_bag_slots_purchased(), Some(6)); +} diff --git a/crates/benilla-protocol/tests/char_enum.rs b/crates/benilla-protocol/tests/char_enum.rs new file mode 100644 index 00000000..585f49d9 --- /dev/null +++ b/crates/benilla-protocol/tests/char_enum.rs @@ -0,0 +1,127 @@ +//! `SMSG_CHAR_ENUM` (the character-select roster) + the logout round-trip. The enum body is +//! hand-built byte-exact to the vmangos serializer (`Player::BuildEnumData`, `Player.cpp:1629`: +//! guid u64 · name cstring · race/class/gender/skin/face/hairStyle/hairColor/facialHair u8 · +//! level u8 · zone u32 · map u32 · x/y/z f32 · guild u32 · flags u32 · firstLogin u8 · +//! petDisplay/petLevel/petFamily u32 · 19×(display u32 + invType u8) equipment · first-bag +//! display u32 + invType u8); two characters in one body exercise the alignment of everything +//! the parser skips. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; + +/// One serialized enum entry, vmangos field order. Non-roster fields get distinct junk values so a +/// misaligned parse can't accidentally pass. +#[allow(clippy::too_many_arguments)] +fn enum_entry( + guid: u64, + name: &str, + race: u8, + class: u8, + gender: u8, + level: u8, + map: u32, + pos: [f32; 3], +) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(&guid.to_le_bytes()); + b.extend_from_slice(name.as_bytes()); + b.push(0); + b.extend_from_slice(&[race, class, gender]); + b.extend_from_slice(&[3, 4, 5, 6, 7]); // skin/face/hairStyle/hairColor/facialHair + b.push(level); + b.extend_from_slice(&12u32.to_le_bytes()); // zone + b.extend_from_slice(&map.to_le_bytes()); + for c in pos { + b.extend_from_slice(&c.to_le_bytes()); + } + b.extend_from_slice(&0u32.to_le_bytes()); // guild id + b.extend_from_slice(&0x0200_0000u32.to_le_bytes()); // character flags (DECLINED — junk) + b.push(1); // first login + b.extend_from_slice(&99u32.to_le_bytes()); // pet display id + b.extend_from_slice(&7u32.to_le_bytes()); // pet level + b.extend_from_slice(&1u32.to_le_bytes()); // pet family + for slot in 0..19u32 { + b.extend_from_slice(&(1000 + slot).to_le_bytes()); // equipment display id + b.push(slot as u8); // inventory type + } + b.extend_from_slice(&2000u32.to_le_bytes()); // first bag display id + b.push(18); // first bag inventory type + b +} + +#[test] +fn char_enum_roster_parses_and_decodes() { + let mut body = vec![2u8]; // count + body.extend(enum_entry( + 0x0000_0000_0000_0007, + "Two", + 1, // Human + 1, // Warrior + 0, + 1, + 0, + [-8949.95, -132.493, 83.5312], + )); + body.extend(enum_entry( + 0x0000_0000_0000_0009, + "Twomage", + 1, // Human + 8, // Mage + 1, + 60, + 1, + [1.5, -2.5, 3.5], + )); + + let p = messages::parse_server(messages::opcode::SMSG_CHAR_ENUM, &body).unwrap(); + let ServerPacket::CharEnum { ref characters } = p else { + panic!("char enum") + }; + assert_eq!(characters.len(), 2); + let c = &characters[0]; + assert_eq!( + (c.guid, c.name.as_str(), c.race, c.class, c.gender), + (7, "Two", 1, 1, 0) + ); + // The appearance bytes + zone + flags land verbatim (decision 0465 — the select screen + // renders them; they used to be alignment-skips). + assert_eq!( + (c.skin, c.face, c.hair_style, c.hair_color, c.facial_hair), + (3, 4, 5, 6, 7) + ); + assert_eq!((c.zone, c.flags), (12, 0x0200_0000)); + assert_eq!((c.level, c.map), (1, 0)); + assert_eq!((c.position.x, c.position.y), (-8949.95, -132.493)); + // All 19 equipment pairs, in slot order: display 1000+slot, invType = slot. + for (slot, item) in c.equipment.iter().enumerate() { + assert_eq!( + (item.display_id, item.inventory_type), + (1000 + slot as u32, slot as u8) + ); + } + let c = &characters[1]; + assert_eq!( + (c.guid, c.name.as_str(), c.race, c.class, c.gender), + (9, "Twomage", 1, 8, 1) + ); + assert_eq!((c.level, c.map, c.position.z), (60, 1, 3.5)); + assert_eq!(c.equipment[18].display_id, 1018); + + // Decodes to one CharacterList event carrying the roster through (no realm context on the raw + // decode path — the IO thread's own emit is what carries the auth realm entry). + match &decode(p)[..] { + [SessionEvent::CharacterList { characters, realm }] => { + assert_eq!(characters.len(), 2); + assert!(realm.is_none()); + } + other => panic!("expected CharacterList, got {other:?}"), + } +} + +#[test] +fn logout_complete_decodes_to_logged_out() { + // SMSG_LOGOUT_COMPLETE: empty body. + let p = messages::parse_server(messages::opcode::SMSG_LOGOUT_COMPLETE, &[]).unwrap(); + assert!(matches!(decode(p)[..], [SessionEvent::LoggedOut])); +} diff --git a/crates/benilla-protocol/tests/chat.rs b/crates/benilla-protocol/tests/chat.rs new file mode 100644 index 00000000..98d1b52f --- /dev/null +++ b/crates/benilla-protocol/tests/chat.rs @@ -0,0 +1,376 @@ +//! Chat + channel wire decode tests (mirrors `src/messages/chat.rs` + `src/messages/channel.rs`, +//! decision 0288 phase 1): one golden per `SMSG_MESSAGECHAT` per-type wire shape (the reader was +//! byte-correct before this decision but had zero golden coverage), `SMSG_TEXT_EMOTE`/`SMSG_EMOTE`, +//! every `SMSG_CHANNEL_NOTIFY` tail shape, `SMSG_CHANNEL_LIST`, `SMSG_CHAT_PLAYER_NOT_FOUND`, +//! `SMSG_CHAT_WRONG_FACTION`, `SMSG_NOTIFICATION`, `SMSG_PLAYED_TIME`, and `MSG_RANDOM_ROLL`'s +//! broadcast shape. Bytes +//! hand-computed from the vmangos layout (citations inline), independent of the Rust decoder — see +//! `tests/common` for the shared `hx` fixture helper. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, ChannelNoticeTail}; +use benilla_protocol::ServerPacket; +use common::hx; + +/// `SMSG_MESSAGECHAT`'s five distinct wire shapes (VERIFIED vmangos `ChatHandler::BuildChatPacket`, +/// `Chat/Chat.cpp:2542-2599`): SAY/PARTY/YELL (sender guid **twice**), MONSTER_SAY/MONSTER_YELL +/// (guid + length-prefixed name + target guid), CHANNEL (cstring channel + `u32` rank + guid), +/// MONSTER_WHISPER/RAID_BOSS_WHISPER/RAID_BOSS_EMOTE/MONSTER_EMOTE (length-prefixed name + target +/// guid, no leading sender guid), and the `default:` shape (one sender guid) — SYSTEM, WHISPER, +/// EMOTE, RAID, GUILD, OFFICER, AFK, DND, IGNORED, RAID_LEADER/WARNING, BATTLEGROUND(+LEADER) all +/// fall here. Every shape ends `u32 len (incl NUL) + text + NUL`, then the trailing `u8 chatTag` +/// (`Chat/Chat.h:86-92`) this decision surfaces on [`messages::ChatMessage::chat_tag`]. +#[test] +fn message_chat_decodes_every_wire_shape() { + // SAY: guid twice, chat_tag = AFK (1) — proves the tag rides through on the "two guid" shape. + let body = hx("0007000000887766554433221188776655443322110900000068692074686572650001"); + match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() { + ServerPacket::MessageChat(m) => { + assert_eq!(m.chat_type, messages::CHAT_MSG_SAY); + assert_eq!(m.language, 7); + assert_eq!(m.sender_guid, 0x1122_3344_5566_7788); + assert_eq!(m.sender_name, None); + assert_eq!(m.channel, None); + assert_eq!(m.text, "hi there"); + assert_eq!(m.chat_tag, messages::chat_tag::AFK); + } + _ => panic!("expected MessageChat"), + } + + // MONSTER_SAY: guid + length-prefixed name + target guid, chat_tag = none. Body: type(0b) + + // language(00000000) + sender guid(ddccbbaa…) + namelen(0f000000) + "Timmy the Wolf\0" + + // target guid(66778899…) + msglen(05000000) + "Grr!\0" + chatTag(00). + let body = hx("0b00000000ddccbbaa000000000f00000054696d6d792074686520576f6c6600667788990000000005000000477272210000"); + match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() { + ServerPacket::MessageChat(m) => { + assert_eq!(m.chat_type, messages::CHAT_MSG_MONSTER_SAY); + assert_eq!(m.sender_guid, 0xAABB_CCDD); + assert_eq!(m.sender_name.as_deref(), Some("Timmy the Wolf")); + assert_eq!(m.channel, None); + assert_eq!(m.text, "Grr!"); + assert_eq!(m.chat_tag, messages::chat_tag::NONE); + } + _ => panic!("expected MessageChat"), + } + + // CHANNEL: cstring channel + u32 rank + guid. Body: type(0e) + language(00000000) + + // "General\0" + playerRank(00000000) + sender guid(3412…) + msglen(10000000) + + // "wtb boar livers\0" + chatTag(00). + let body = hx("0e0000000047656e6572616c000000000034120000000000001000000077746220626f6172206c69766572730000"); + match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() { + ServerPacket::MessageChat(m) => { + assert_eq!(m.chat_type, messages::CHAT_MSG_CHANNEL); + assert_eq!(m.channel.as_deref(), Some("General")); + assert_eq!(m.sender_guid, 0x1234); + assert_eq!(m.sender_name, None); + assert_eq!(m.text, "wtb boar livers"); + } + _ => panic!("expected MessageChat"), + } + + // MONSTER_WHISPER: length-prefixed name + target guid (no leading sender guid at all). Body: + // type(1a) + language(00000000) + namelen(0e000000) + "Innkeeper Bob\0" + target guid(55…) + + // msglen(19000000) + "Welcome, weary traveler!\0". + let body = hx("1a000000000e000000496e6e6b656570657220426f620055000000000000001900000057656c636f6d652c2077656172792074726176656c6572210000"); + match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() { + ServerPacket::MessageChat(m) => { + assert_eq!(m.chat_type, messages::CHAT_MSG_MONSTER_WHISPER); + assert_eq!(m.sender_guid, 0, "no leading guid on this shape"); + assert_eq!(m.sender_name.as_deref(), Some("Innkeeper Bob")); + assert_eq!(m.text, "Welcome, weary traveler!"); + } + _ => panic!("expected MessageChat"), + } + + // default shape (SYSTEM): one sender guid (0), chat_tag = GM (3). Body: type(0a) + + // language(00000000) + sender guid(0000000000000000) + msglen(1a000000) + + // "This is a system message.\0" + chatTag(03). + let body = hx( + "0a0000000000000000000000001a0000005468697320697320612073797374656d206d6573736167652e0003", + ); + match messages::parse_server(messages::opcode::SMSG_MESSAGECHAT, &body).unwrap() { + ServerPacket::MessageChat(m) => { + assert_eq!(m.chat_type, messages::CHAT_MSG_SYSTEM); + assert_eq!(m.sender_guid, 0); + assert_eq!(m.text, "This is a system message."); + assert_eq!(m.chat_tag, messages::chat_tag::GM); + } + _ => panic!("expected MessageChat"), + } +} + +/// `SMSG_TEXT_EMOTE` (VERIFIED vmangos `MaNGOS::EmoteChatBuilder::operator()`, +/// `Handlers/ChatHandler.cpp:681-702`): `u64` performer guid (an `ObjectGuid` write, unpacked) + `u32` +/// textEmote + `u32` emoteNum + `u32 namelen` + `namelen` bytes (the target's name + NUL, or a lone +/// NUL when untargeted) — and `SMSG_EMOTE` (VERIFIED vmangos `EmoteNotify::AppendBodyTo`, +/// `Server/Packets/Misc.cpp:670-674`): `u32` emoteId + `u64` guid. +#[test] +fn text_emote_and_emote_decode() { + let body = hx("7700000000000000650000000000000004000000426f6200"); + let p = messages::parse_server(messages::opcode::SMSG_TEXT_EMOTE, &body).unwrap(); + assert!(matches!( + p, + ServerPacket::TextEmote { + guid: 0x77, + text_emote: 101, + } + )); + assert!(matches!( + decode(p)[..], + [SessionEvent::TextEmote { + guid: 0x77, + text_emote: 101, + }] + )); + + let body = hx("060000008800000000000000"); + let p = messages::parse_server(messages::opcode::SMSG_EMOTE, &body).unwrap(); + assert!(matches!( + p, + ServerPacket::Emote { + guid: 0x88, + emote_id: 6, + } + )); + assert!(matches!( + decode(p)[..], + [SessionEvent::Emote { + guid: 0x88, + emote_id: 6, + }] + )); +} + +/// Every `SMSG_CHANNEL_NOTIFY` tail shape (VERIFIED vmangos `Channel::Make*`, +/// `Chat/Channel.cpp:804-1008`), one representative per [`ChannelNoticeTail`] variant, plus the +/// unrecognized-notice error path. +#[test] +fn channel_notify_decodes_each_tail_shape() { + // 0x00 JOINED: guid. + let body = hx("0047656e6572616c00aa00000000000000"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::JOINED); + assert_eq!(n.channel, "General"); + assert_eq!(n.tail, ChannelNoticeTail::Guid(0xAA)); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x02 YOU_JOINED: u32 flags + u32 0 (reserved). + let body = hx("0247656e6572616c001800000000000000"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::YOU_JOINED); + assert_eq!(n.tail, ChannelNoticeTail::YouJoined { flags: 0x18 }); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x05 NOT_MEMBER: empty tail — both the instance (`MakeNotMember`) and static + // (`MakeNotOnPacket`) vmangos producers write this exact shape; no special-casing needed. + let body = hx("0553656372657400"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::NOT_MEMBER); + assert_eq!(n.channel, "Secret"); + assert_eq!(n.tail, ChannelNoticeTail::Empty); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x18 INVITE: a lone actor guid. + let body = hx("1847656e6572616c00bb00000000000000"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::INVITE); + assert_eq!(n.tail, ChannelNoticeTail::Actor(0xBB)); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x09 PLAYER_NOT_FOUND: a bare name. + let body = hx("0947656e6572616c0047686f737400"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::PLAYER_NOT_FOUND); + assert_eq!(n.tail, ChannelNoticeTail::Name("Ghost".into())); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x0B CHANNEL_OWNER: the "Nobody" literal vmangos sends for an ownerless non-constant channel. + let body = hx("0b47656e6572616c004e6f626f647900"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::CHANNEL_OWNER); + assert_eq!(n.tail, ChannelNoticeTail::Name("Nobody".into())); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x0C MODE_CHANGE: guid + old flags + new flags. + let body = hx("0c47656e6572616c00cc000000000000000002"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::MODE_CHANGE); + assert_eq!( + n.tail, + ChannelNoticeTail::ModeChange { + guid: 0xCC, + old_flags: 0x00, + new_flags: 0x02, + } + ); + } + _ => panic!("expected ChannelNotify"), + } + + // 0x12 PLAYER_KICKED: target guid + source (actor) guid. + let body = hx("1247656e6572616c00dd00000000000000ee00000000000000"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap() { + ServerPacket::ChannelNotify(n) => { + assert_eq!(n.notice, messages::channel_notice::PLAYER_KICKED); + assert_eq!( + n.tail, + ChannelNoticeTail::Actors { + target: 0xDD, + source: 0xEE, + } + ); + } + _ => panic!("expected ChannelNotify"), + } + + // Round-trip the decoded ChannelNotify through the event layer once (the mapping is a flat + // passthrough, but it must actually be wired). + let body = hx("0047656e6572616c00aa00000000000000"); + let p = messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).unwrap(); + match &decode(p)[..] { + [SessionEvent::ChannelNotify { + notice, + channel, + tail, + }] => { + assert_eq!(*notice, messages::channel_notice::JOINED); + assert_eq!(channel, "General"); + assert_eq!(*tail, ChannelNoticeTail::Guid(0xAA)); + } + other => panic!("channel notify decode: {} events", other.len()), + } + + // An unrecognized notice byte (outside vmangos's defined 0x00..=0x1F) errors rather than + // guessing a tail shape. + let body = hx("2047656e6572616c00"); + assert!(messages::parse_server(messages::opcode::SMSG_CHANNEL_NOTIFY, &body).is_err()); +} + +/// `SMSG_CHANNEL_LIST` (VERIFIED vmangos `Channel::List`, `Chat/Channel.cpp:513-556`): cstring +/// channel + `u8` flags + `u32` count + that many `(u64 guid, u8 memberFlags)` rows. +#[test] +fn channel_list_decodes_roster() { + let body = hx("47656e6572616c001802000000011000000000000001021000000000000000"); + match messages::parse_server(messages::opcode::SMSG_CHANNEL_LIST, &body).unwrap() { + ServerPacket::ChannelList { + channel, + flags, + members, + } => { + assert_eq!(channel, "General"); + assert_eq!(flags, 0x18); + assert_eq!(members, vec![(0x1001, 0x01), (0x1002, 0x00)]); + } + _ => panic!("expected ChannelList"), + } +} + +/// `SMSG_CHAT_PLAYER_NOT_FOUND` (cstring name, VERIFIED vmangos `Server/Packets/Chat.cpp:26-29`) and +/// `SMSG_CHAT_WRONG_FACTION` (empty body, `Server/Packets/Chat.cpp:16-18`). +#[test] +fn chat_player_not_found_and_wrong_faction_decode() { + let body = hx("47686f73746e616d6500"); + match messages::parse_server(messages::opcode::SMSG_CHAT_PLAYER_NOT_FOUND, &body).unwrap() { + ServerPacket::ChatPlayerNotFound { name } => assert_eq!(name, "Ghostname"), + _ => panic!("expected ChatPlayerNotFound"), + } + assert!(matches!( + decode( + messages::parse_server(messages::opcode::SMSG_CHAT_PLAYER_NOT_FOUND, &body).unwrap() + )[..], + [SessionEvent::ChatPlayerNotFound { .. }] + )); + + let p = messages::parse_server(messages::opcode::SMSG_CHAT_WRONG_FACTION, &[]).unwrap(); + assert!(matches!(p, ServerPacket::ChatWrongFaction)); + assert!(matches!(decode(p)[..], [SessionEvent::ChatWrongFaction])); +} + +/// `SMSG_NOTIFICATION` (VERIFIED vmangos `WorldSession::SendNotification`, +/// `Server/WorldSession.cpp:900-915`: `data << szStr`): one cstring, pre-formatted server-side — +/// the "You do not know that language" reply is how a wrong-tongue send fails (silently, until +/// this packet was rendered). +#[test] +fn notification_decodes() { + let body = hx("596f7520646f206e6f74206b6e6f772074686174206c616e677561676500"); + match messages::parse_server(messages::opcode::SMSG_NOTIFICATION, &body).unwrap() { + ServerPacket::Notification { ref text } => { + assert_eq!(text, "You do not know that language") + } + other => panic!("expected Notification, got {}", other.name()), + } + match &decode(messages::parse_server(messages::opcode::SMSG_NOTIFICATION, &body).unwrap())[..] { + [SessionEvent::Notification { text }] => assert_eq!(text, "You do not know that language"), + other => panic!("notification decode: {} events", other.len()), + } +} + +/// `SMSG_PLAYED_TIME` (VERIFIED vmangos `WorldPackets::Misc::PlayedTime::AppendBodyTo`, +/// `Server/Packets/Misc.cpp:278-282`): `u32` total + `u32` level, both seconds. +#[test] +fn played_time_decodes() { + let body = hx("40e20100100e0000"); // total = 123456, level = 3600 + let p = messages::parse_server(messages::opcode::SMSG_PLAYED_TIME, &body).unwrap(); + assert!(matches!( + p, + ServerPacket::PlayedTime { + total: 123_456, + level: 3600, + } + )); + assert!(matches!( + decode(p)[..], + [SessionEvent::PlayedTime { + total: 123_456, + level: 3600, + }] + )); +} + +/// `MSG_RANDOM_ROLL`'s server→client broadcast shape (VERIFIED vmangos +/// `WorldSession::HandleRandomRollOpcode`, `Handlers/GroupHandler.cpp:394-422`): `u32` min + `u32` +/// max + `u32` roll + a **raw** `u64` guid. +#[test] +fn random_roll_broadcast_decodes() { + let body = hx("01000000640000002a0000009999000000000000"); + let p = messages::parse_server(messages::opcode::MSG_RANDOM_ROLL, &body).unwrap(); + assert!(matches!( + p, + ServerPacket::RandomRoll { + min: 1, + max: 100, + roll: 42, + guid: 0x9999, + } + )); + assert!(matches!( + decode(p)[..], + [SessionEvent::RandomRoll { + min: 1, + max: 100, + roll: 42, + guid: 0x9999, + }] + )); +} diff --git a/crates/benilla-protocol/tests/client.rs b/crates/benilla-protocol/tests/client.rs new file mode 100644 index 00000000..58fca961 --- /dev/null +++ b/crates/benilla-protocol/tests/client.rs @@ -0,0 +1,580 @@ +//! Client-body + query wire tests (mirrors `src/messages/client.rs`): the golden client-encoded +//! bodies (auth/char-create/chat/emote/stand-state/movement/teleport-ack) and the creature/name +//! query request+response roundtrip. Split out of the former `tests/messages.rs` (decision-adjacent +//! mechanical split — see `tests/common` for the shared fixtures and methodology note). + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, MovementInfo}; +use benilla_protocol::wire::Vector3d; +use benilla_protocol::ServerPacket; +use common::hx; + +/// The faction tongue every chat send must speak ([`messages::faction_language`]): race → tongue +/// VERIFIED against the live world DB (`playercreateinfo_spell` — Alliance races 1/3/4/7 learn +/// spell 668 Language Common, Horde races 2/5/6/8 learn 669 Language Orcish); wire ids VERIFIED +/// vmangos `SharedDefines.h:256-261` (`LANG_ORCISH = 1`, `LANG_COMMON = 7`). +#[test] +fn faction_language_per_race() { + for race in [1u8, 3, 4, 7] { + assert_eq!( + messages::faction_language(race), + messages::LANGUAGE_COMMON, + "alliance race {race}" + ); + } + for race in [2u8, 5, 6, 8] { + assert_eq!( + messages::faction_language(race), + messages::LANGUAGE_ORCISH, + "horde race {race}" + ); + } +} + +#[test] +fn client_bodies_golden() { + let proof: [u8; 20] = std::array::from_fn(|i| (i as u8).wrapping_mul(3).wrapping_add(7)); + assert_eq!( + messages::auth_session(5875, "TESTUSER", 0x1122_3344, &proof), + hx("f31600000000000054455354555345520044332211070a0d101316191c1f2225282b2e3134373a3d4000000000789c030000000001"), + "CMSG_AUTH_SESSION body" + ); + // Zero-appearance body (the create-if-empty starter): name + [race,class,gender] + 5 zeros + 0. + assert_eq!( + messages::char_create(&messages::CharCreateReq { + name: "Benilla".into(), + race: 1, + class: 1, + gender: 0, + skin: 0, + face: 0, + hair_style: 0, + hair_color: 0, + facial_hair: 0, + }), + hx("42656e696c6c6100010100000000000000"), + "CMSG_CHAR_CREATE body (zero appearance)" + ); + // Distinct appearance dials (3/4/5/6/7) — a misplaced byte can't pass. Body is name + nul + + // race(01) class(01) gender(00) skin(03) face(04) hairStyle(05) hairColor(06) facialHair(07) + // outfit(00), matching vmangos's read order (`Packets/Character.cpp:4-19`). + assert_eq!( + messages::char_create(&messages::CharCreateReq { + name: "Benilla".into(), + race: 1, + class: 1, + gender: 0, + skin: 3, + face: 4, + hair_style: 5, + hair_color: 6, + facial_hair: 7, + }), + hx("42656e696c6c6100010100030405060700"), + "CMSG_CHAR_CREATE body (distinct appearance)" + ); + assert_eq!( + messages::messagechat(0, 7, ".tele Westfall"), + hx("00000000070000002e74656c65205765737466616c6c00"), + "CMSG_MESSAGECHAT body" + ); + // The three-field vmangos shape (`Misc.cpp:60-65`): textEmote, emoteNum(0), full target + // guid. The original two-field body was silently discarded server-side (the guid read ran + // off the packet) — /wave did nothing on any client until the director caught it on the ref. + assert_eq!( + messages::text_emote(101, 0x2A), + hx("65000000000000002a00000000000000"), + "CMSG_TEXT_EMOTE body" + ); + assert_eq!( + messages::stand_state_change(1), + hx("01000000"), + "CMSG_STANDSTATECHANGE body: one u32 animState (Misc.cpp:35-38)" + ); + // The chat-type consts feeding CMSG_MESSAGECHAT's type field (VERIFIED vmangos + // `SharedDefines.h:1194..1202`, the 5875 band). + assert_eq!(messages::CHAT_TYPE_SAY, 0x0, "ChatMsg::CHAT_MSG_SAY"); + assert_eq!(messages::CHAT_TYPE_YELL, 0x5, "ChatMsg::CHAT_MSG_YELL"); + assert_eq!( + messages::CHAT_TYPE_WHISPER, + 0x6, + "ChatMsg::CHAT_MSG_WHISPER" + ); + assert_eq!(messages::CHAT_TYPE_EMOTE, 0x8, "ChatMsg::CHAT_MSG_EMOTE"); + // /yell and /emote share `messagechat`'s body shape, only the type field differs. + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_YELL, 7, "for the horde"), + hx("0500000007000000666f722074686520686f72646500"), + "CMSG_MESSAGECHAT (yell) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_EMOTE, 7, "dances"), + hx("080000000700000064616e63657300"), + "CMSG_MESSAGECHAT (emote) body" + ); + // A Horde say speaks Orcish (0x1) in the language field — hardcoded Common here once made + // vmangos drop every Horde character's sends at the `KnowsLanguage` gate, dot-commands + // included (only an SMSG_NOTIFICATION came back). + assert_eq!( + messages::messagechat( + messages::CHAT_TYPE_SAY, + messages::LANGUAGE_ORCISH, + "for the horde" + ), + hx("0000000001000000666f722074686520686f72646500"), + "CMSG_MESSAGECHAT (say, Orcish) body" + ); + // CMSG_MESSAGECHAT (whisper): type + language, then the target name, then the message — both + // NUL-terminated C-strings, target *before* message (VERIFIED vmangos + // `Server/Packets/Chat.cpp:3-12`, `ChatMessage::ReadFromWorldPacket`). + assert_eq!( + messages::messagechat_whisper(7, "Bob", "hi there"), + hx("0600000007000000426f6200686920746865726500"), + "CMSG_MESSAGECHAT (whisper) body" + ); + // The full 8-byte LE guid body, shared by CMSG_PLAYER_LOGIN / CMSG_SET_ACTIVE_MOVER / + // CMSG_SET_SELECTION (all read `recv_data >> guid` server-side — a raw uint64, not a packed guid). + assert_eq!( + messages::full_guid(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "full guid body" + ); + // CMSG_SET_SELECTION opcode value (317 / 0x013D) — verified vmangos `Opcodes_1_12_1.h`. + assert_eq!( + messages::opcode::CMSG_SET_SELECTION, + 0x013D, + "CMSG_SET_SELECTION opcode" + ); + let mi = MovementInfo { + flags: 0x1, + timestamp: 0x0102_0304, + position: Vector3d { + x: -8949.95, + y: -132.493, + z: 83.5312, + }, + orientation: 1.25, + transport: None, + pitch: 0.0, + fall_time: 0, + jump: None, + }; + assert_eq!( + messages::movement(&mi), + hx("0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"), + "MSG_MOVE_* body" + ); + assert_eq!( + messages::teleport_ack(0x1234_5678_9abc_def0, 7, 0x0102_0304), + hx("f0debc9a785634120700000004030201"), + "MSG_MOVE_TELEPORT_ACK body" + ); +} + +/// The full sendable `CMSG_MESSAGECHAT` type set beyond the original four (decision 0288 phase 1) — +/// every one VERIFIED against vmangos `Handlers/ChatHandler.cpp`'s `HandleChatMessageOpcode` switch +/// (253-655): PARTY/RAID/GUILD/OFFICER/RAID_LEADER/RAID_WARNING/BATTLEGROUND(+LEADER)/AFK/DND all +/// share [`messages::messagechat`]'s plain shape (no target field); CHANNEL carries the channel name +/// the same way WHISPER carries its target (`messages::messagechat_channel`). Bytes hand-computed +/// from the vmangos layout (`Server/Packets/Chat.cpp:3-12`), independent of the Rust builder. +#[test] +fn messagechat_sendable_types_golden() { + assert_eq!(messages::CHAT_TYPE_PARTY, 0x1, "ChatMsg::CHAT_MSG_PARTY"); + assert_eq!(messages::CHAT_TYPE_RAID, 0x2, "ChatMsg::CHAT_MSG_RAID"); + assert_eq!(messages::CHAT_TYPE_GUILD, 0x3, "ChatMsg::CHAT_MSG_GUILD"); + assert_eq!( + messages::CHAT_TYPE_OFFICER, + 0x4, + "ChatMsg::CHAT_MSG_OFFICER" + ); + assert_eq!( + messages::CHAT_TYPE_CHANNEL, + 0xE, + "ChatMsg::CHAT_MSG_CHANNEL" + ); + assert_eq!(messages::CHAT_TYPE_AFK, 0x14, "ChatMsg::CHAT_MSG_AFK"); + assert_eq!(messages::CHAT_TYPE_DND, 0x15, "ChatMsg::CHAT_MSG_DND"); + assert_eq!( + messages::CHAT_TYPE_RAID_LEADER, + 0x57, + "ChatMsg::CHAT_MSG_RAID_LEADER" + ); + assert_eq!( + messages::CHAT_TYPE_RAID_WARNING, + 0x58, + "ChatMsg::CHAT_MSG_RAID_WARNING" + ); + assert_eq!( + messages::CHAT_TYPE_BATTLEGROUND, + 0x5C, + "ChatMsg::CHAT_MSG_BATTLEGROUND" + ); + assert_eq!( + messages::CHAT_TYPE_BATTLEGROUND_LEADER, + 0x5D, + "ChatMsg::CHAT_MSG_BATTLEGROUND_LEADER" + ); + + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_PARTY, 7, "MT is icon 8"), + hx("01000000070000004d542069732069636f6e203800"), + "CMSG_MESSAGECHAT (party) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_RAID, 7, "form up"), + hx("0200000007000000666f726d20757000"), + "CMSG_MESSAGECHAT (raid) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_GUILD, 7, "hi guild"), + hx("03000000070000006869206775696c6400"), + "CMSG_MESSAGECHAT (guild) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_OFFICER, 7, "officers only"), + hx("04000000070000006f66666963657273206f6e6c7900"), + "CMSG_MESSAGECHAT (officer) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_RAID_LEADER, 7, "pull in 5"), + hx("570000000700000070756c6c20696e203500"), + "CMSG_MESSAGECHAT (raid leader) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_RAID_WARNING, 7, "BLOODLUST NOW"), + hx("5800000007000000424c4f4f444c555354204e4f5700"), + "CMSG_MESSAGECHAT (raid warning) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_BATTLEGROUND, 7, "defend flag"), + hx("5c00000007000000646566656e6420666c616700"), + "CMSG_MESSAGECHAT (battleground) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_BATTLEGROUND_LEADER, 7, "push mid"), + hx("5d0000000700000070757368206d696400"), + "CMSG_MESSAGECHAT (battleground leader) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_AFK, 7, "be back soon"), + hx("14000000070000006265206261636b20736f6f6e00"), + "CMSG_MESSAGECHAT (afk) body" + ); + // A bare `/afk` (no message) still needs the NUL-terminated empty string, not a truncated body. + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_AFK, 7, ""), + hx("140000000700000000"), + "CMSG_MESSAGECHAT (afk, empty message) body" + ); + assert_eq!( + messages::messagechat(messages::CHAT_TYPE_DND, 7, "do not disturb"), + hx("1500000007000000646f206e6f74206469737475726200"), + "CMSG_MESSAGECHAT (dnd) body" + ); + // CHANNEL: type + language, then the channel name, then the message — the same + // target-before-message shape as whisper (vmangos's `whisperTargetOrChannel` union). + assert_eq!( + messages::messagechat_channel(0, "General", "wtb boar livers"), + hx("0e0000000000000047656e6572616c0077746220626f6172206c697665727300"), + "CMSG_MESSAGECHAT (channel) body" + ); + // The generic builder both named wrappers are thin shells over: `target: None` matches + // `messagechat`, `target: Some(_)` matches `messagechat_whisper`/`messagechat_channel`. + assert_eq!( + messages::messagechat_kind(messages::CHAT_TYPE_SAY, 7, None, "hi"), + messages::messagechat(messages::CHAT_TYPE_SAY, 7, "hi"), + "messagechat_kind(None) == messagechat" + ); + assert_eq!( + messages::messagechat_kind(messages::CHAT_TYPE_WHISPER, 7, Some("Bob"), "hi"), + messages::messagechat_whisper(7, "Bob", "hi"), + "messagechat_kind(Some) == messagechat_whisper" + ); +} + +/// The channel wire family's CMSG bodies (decision 0288 phase 1) — join/leave/list + the full +/// moderation set, ALL verified vmangos `Server/Packets/Channel.cpp` (every `ReadFromWorldPacket` +/// there is a channel-name cstring, optionally followed by a second cstring: a password or a target +/// player name). Bytes hand-computed from that layout, independent of the Rust builder. +#[test] +fn channel_client_bodies_golden() { + assert_eq!( + messages::join_channel("General", ""), + hx("47656e6572616c0000"), + "CMSG_JOIN_CHANNEL body (no password)" + ); + assert_eq!( + messages::join_channel("Secret", "hunter2"), + hx("5365637265740068756e7465723200"), + "CMSG_JOIN_CHANNEL body (with password)" + ); + assert_eq!( + messages::leave_channel("General"), + hx("47656e6572616c00"), + "CMSG_LEAVE_CHANNEL body" + ); + assert_eq!( + messages::channel_list("Trade"), + hx("547261646500"), + "CMSG_CHANNEL_LIST body" + ); + assert_eq!( + messages::channel_password("General", "hunter2"), + hx("47656e6572616c0068756e7465723200"), + "CMSG_CHANNEL_PASSWORD body" + ); + assert_eq!( + messages::channel_set_owner("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_SET_OWNER body" + ); + assert_eq!( + messages::channel_owner("General"), + hx("47656e6572616c00"), + "CMSG_CHANNEL_OWNER body" + ); + assert_eq!( + messages::channel_moderator("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_MODERATOR body" + ); + assert_eq!( + messages::channel_unmoderator("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_UNMODERATOR body" + ); + assert_eq!( + messages::channel_mute("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_MUTE body" + ); + assert_eq!( + messages::channel_unmute("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_UNMUTE body" + ); + assert_eq!( + messages::channel_invite("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_INVITE body" + ); + assert_eq!( + messages::channel_kick("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_KICK body" + ); + assert_eq!( + messages::channel_ban("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_BAN body" + ); + assert_eq!( + messages::channel_unban("General", "Bob"), + hx("47656e6572616c00426f6200"), + "CMSG_CHANNEL_UNBAN body" + ); + assert_eq!( + messages::channel_announcements("General"), + hx("47656e6572616c00"), + "CMSG_CHANNEL_ANNOUNCEMENTS body" + ); + assert_eq!( + messages::channel_moderate("General"), + hx("47656e6572616c00"), + "CMSG_CHANNEL_MODERATE body" + ); + + // Every opcode in the family (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`, + // decimal 151-168). + assert_eq!(messages::opcode::CMSG_JOIN_CHANNEL, 0x0097); + assert_eq!(messages::opcode::CMSG_LEAVE_CHANNEL, 0x0098); + assert_eq!(messages::opcode::SMSG_CHANNEL_NOTIFY, 0x0099); + assert_eq!(messages::opcode::CMSG_CHANNEL_LIST, 0x009A); + assert_eq!(messages::opcode::SMSG_CHANNEL_LIST, 0x009B); + assert_eq!(messages::opcode::CMSG_CHANNEL_PASSWORD, 0x009C); + assert_eq!(messages::opcode::CMSG_CHANNEL_SET_OWNER, 0x009D); + assert_eq!(messages::opcode::CMSG_CHANNEL_OWNER, 0x009E); + assert_eq!(messages::opcode::CMSG_CHANNEL_MODERATOR, 0x009F); + assert_eq!(messages::opcode::CMSG_CHANNEL_UNMODERATOR, 0x00A0); + assert_eq!(messages::opcode::CMSG_CHANNEL_MUTE, 0x00A1); + assert_eq!(messages::opcode::CMSG_CHANNEL_UNMUTE, 0x00A2); + assert_eq!(messages::opcode::CMSG_CHANNEL_INVITE, 0x00A3); + assert_eq!(messages::opcode::CMSG_CHANNEL_KICK, 0x00A4); + assert_eq!(messages::opcode::CMSG_CHANNEL_BAN, 0x00A5); + assert_eq!(messages::opcode::CMSG_CHANNEL_UNBAN, 0x00A6); + assert_eq!(messages::opcode::CMSG_CHANNEL_ANNOUNCEMENTS, 0x00A7); + assert_eq!(messages::opcode::CMSG_CHANNEL_MODERATE, 0x00A8); +} + +/// The remaining decision-0288 phase-1 small bodies: `CMSG_CHAT_IGNORED` (reuses +/// [`messages::full_guid`] — VERIFIED vmangos `WorldPackets::Misc::ChatIgnored:: +/// ReadFromWorldPacket`, `Server/Packets/Misc.cpp:127-130`, a raw un-packed guid), `CMSG_PLAYED_TIME` +/// (empty), and `MSG_RANDOM_ROLL`'s client→server request shape (VERIFIED vmangos +/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`). +#[test] +fn chat_ignored_played_time_random_roll_golden() { + assert_eq!( + messages::full_guid(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_CHAT_IGNORED body (a raw guid, same shape as full_guid)" + ); + assert_eq!(messages::opcode::CMSG_CHAT_IGNORED, 0x0225); + + assert_eq!( + messages::played_time(), + Vec::::new(), + "CMSG_PLAYED_TIME body: empty" + ); + assert_eq!(messages::opcode::CMSG_PLAYED_TIME, 0x01CC); + assert_eq!(messages::opcode::SMSG_PLAYED_TIME, 0x01CD); + + assert_eq!( + messages::random_roll(1, 100), + hx("0100000064000000"), + "MSG_RANDOM_ROLL (request) body" + ); + assert_eq!(messages::opcode::MSG_RANDOM_ROLL, 0x01FB); +} + +#[test] +fn name_and_creature_query_roundtrip() { + // CMSG_CREATURE_QUERY body: entry u32 + full 8-byte guid (vmangos QueryCreature::ReadFromWorldPacket). + assert_eq!( + messages::creature_query(69, 0x1234_5678_9abc_def0), + hx("45000000f0debc9a78563412"), + "CMSG_CREATURE_QUERY body" + ); + + // SMSG_NAME_QUERY_RESPONSE: guid, name, realm (empty), race/gender/class u32s (vmangos + // NameQueryResponse::AppendBodyTo, 1.12.1 includes the realm string). + let body = hx("070000000000000042656e696c6c610000010000000000000001000000"); + match messages::parse_server(messages::opcode::SMSG_NAME_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::NameQueryResponse { + guid, + name, + race, + gender, + class, + } => { + assert_eq!(guid, 7); + assert_eq!(name, "Benilla"); + assert_eq!((race, gender, class), (1, 0, 1)); + } + _ => panic!("name query response"), + } + + // SMSG_CREATURE_QUERY_RESPONSE hit: entry, name, 3 empty names, subname, the 7-u32 tail + // (type_flags, TYPE — kept: the TAB critter filter's input — family, rank, unk, + // pet_spell_id, display_id), 2 u8 tail. + let body = hx(concat!( + "45000000", + "596f756e6720576f6c6600", // "Young Wolf" + "000000", // name2..4 empty + "5465737400", // subname "Test" + "10000000", // type_flags = 0x10 (hide-faction-tooltip) + "01000000", // type = 1 (Beast) + "0000000000000000000000000000000000000000", // family..display_id (5 × u32) + "0101" // civilian, racial_leader + )); + match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::CreatureQueryResponse { entry, info } => { + assert_eq!(entry, 69); + assert_eq!( + info, + Some(benilla_protocol::messages::CreatureQueryInfo { + name: "Young Wolf".into(), + subname: "Test".into(), + creature_type: 1, + rank: 0, + type_flags: 0x10, + civilian: true, + racial_leader: true, + }) + ); + } + _ => panic!("creature query response"), + } + + // Miss: the lone entry echoed with the top bit set (vmangos "NO CREATURE INFO" branch). + let body = hx("d2040080"); + match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::CreatureQueryResponse { entry, info } => { + assert_eq!(entry, 1234); + assert_eq!(info, None); + } + _ => panic!("creature query miss"), + } +} + +/// The event layer's subname law: an EMPTY wire subname is NO subname (vmangos sends "" for +/// creatures without one; the real client renders no line for it — the nameplate builder's +/// verified shape, `0x608f50`). `Some("")` reaching a consumer painted an empty tooltip line +/// whose zero-extent chain slot spilled every later line below the plate. +#[test] +fn creature_query_empty_subname_decodes_to_none() { + let body = |subname_hex: &str| { + hx(&format!( + "45000000{}000000{}{}", + "4465707574792057696c6c656d00", // "Deputy Willem" (+3 empty names after) + subname_hex, + // type_flags, type=7 Humanoid, family..display_id (5 × u32), civilian, racial_leader + "000000000700000000000000000000000000000000000000000000000000" + )) + }; + let parse = |b: &[u8]| { + messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, b).unwrap() + }; + match decode(parse(&body("00"))).as_slice() { + [SessionEvent::CreatureName { + entry, + name, + subname, + .. + }] => { + assert_eq!(*entry, 69); + assert_eq!(name.as_deref(), Some("Deputy Willem")); + assert_eq!(*subname, None, "an empty wire subname is NO subname"); + } + other => panic!("expected one CreatureName event, got {other:?}"), + } + // Control: a real subname survives verbatim. + match decode(parse(&body("5465737400"))).as_slice() { + [SessionEvent::CreatureName { subname, .. }] => { + assert_eq!(subname.as_deref(), Some("Test")); + } + other => panic!("expected one CreatureName event, got {other:?}"), + } +} + +/// The keepalive pair, byte-exact both ways: the `CMSG_PING` body is `{u32 sequence, u32 lastRtt}` +/// LE (VERIFIED wow-re net W1 `SendPing 0x537e10` + vmangos `_HandlePing`'s read order), and the +/// `SMSG_PONG` echo (`{u32 sequence}`) parses and decodes to the event the io layer times against +/// its ping clock. A new wire body never lands without a golden (method). +#[test] +fn ping_body_golden_and_pong_roundtrip() { + assert_eq!( + messages::ping(1, 0), + hx("0100000000000000"), + "first ping of a connection: seq 1, no RTT yet" + ); + assert_eq!( + messages::ping(0x0A0B_0C0D, 23), + hx("0d0c0b0a17000000"), + "CMSG_PING body: sequence then lastRtt, both u32 LE" + ); + let packet = messages::parse_server(messages::opcode::SMSG_PONG, &hx("0d0c0b0a")).unwrap(); + assert!(matches!( + packet, + ServerPacket::Pong { + sequence: 0x0A0B_0C0D + } + )); + match decode(packet).as_slice() { + [SessionEvent::Pong { + sequence: 0x0A0B_0C0D, + }] => {} + other => panic!("expected one Pong event, got {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/common/mod.rs b/crates/benilla-protocol/tests/common/mod.rs new file mode 100644 index 00000000..43575f36 --- /dev/null +++ b/crates/benilla-protocol/tests/common/mod.rs @@ -0,0 +1,13 @@ +//! Shared fixtures for the world message layer's oracle-free regression tests (split by domain +//! across `tests/*.rs`). Client packet bodies are pinned to golden hex captured from the validated +//! implementation (byte-validated against `wow_world_messages` during the decision-0021 migration); +//! `SMSG_UPDATE_OBJECT` fixtures are real serialized packet bodies from that same corpus, parsed + +//! decoded here. Simple server bodies are hand-built (their layout is trivial: a few little-endian +//! scalars). + +pub fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} diff --git a/crates/benilla-protocol/tests/gossip_vendor.rs b/crates/benilla-protocol/tests/gossip_vendor.rs new file mode 100644 index 00000000..74ee6ede --- /dev/null +++ b/crates/benilla-protocol/tests/gossip_vendor.rs @@ -0,0 +1,426 @@ +//! Oracle-free golden tests for the gossip/vendor interaction arc's protocol layer (Phase 1 of the +//! gossip/vendor arc): `CMSG_GOSSIP_HELLO`/`SELECT_OPTION`, `CMSG_NPC_TEXT_QUERY`, and the vendor +//! `LIST_INVENTORY`/`BUY_ITEM`/`SELL_ITEM` family. Split out from `messages.rs` (same idioms — +//! `hx(...)` golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`) since +//! it's a self-contained wire family or size. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; + +fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn gossip_bodies_golden() { + // CMSG_GOSSIP_HELLO (vmangos Npc.cpp:3) / CMSG_LIST_INVENTORY (Item.cpp:94) share the same + // full-guid body shape. + assert_eq!( + messages::gossip_hello(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_GOSSIP_HELLO body" + ); + + // CMSG_NPC_TEXT_QUERY (vmangos Npc.cpp:8-12): u32 textID, u64 guid. + assert_eq!( + messages::npc_text_query(55, 0x1234_5678_9abc_def0), + hx(concat!("37000000", "f0debc9a78563412")), + "CMSG_NPC_TEXT_QUERY body" + ); + + // CMSG_GOSSIP_SELECT_OPTION (vmangos Npc.cpp:78-86): guid, gossipListId, then an optional + // trailing code cstring — appended only for a coded option with a real code, omitted entirely + // otherwise (the server reads it only when the buffer is non-empty). + assert_eq!( + messages::gossip_select_option(0x1234_5678_9abc_def0, 3, None), + hx(concat!("f0debc9a78563412", "03000000")), + "CMSG_GOSSIP_SELECT_OPTION body, no code" + ); + let mut with_code = hx(concat!("f0debc9a78563412", "03000000")); + with_code.extend_from_slice(b"1234\0"); + assert_eq!( + messages::gossip_select_option(0x1234_5678_9abc_def0, 3, Some("1234")), + with_code, + "CMSG_GOSSIP_SELECT_OPTION body, coded option with a real code" + ); +} + +#[test] +fn vendor_bodies_golden() { + // CMSG_LIST_INVENTORY (vmangos Item.cpp:94): a full guid. + assert_eq!( + messages::list_inventory(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_LIST_INVENTORY body" + ); + + // CMSG_BUY_ITEM (vmangos Item.cpp:104-110): vendorGuid, item ENTRY (not muid), count, unk1(0). + // entry 2488 is the live-verified creature-54 vendor's Gladius row (decision 0081). + assert_eq!( + messages::buy_item(0x1234_5678_9abc_def0, 2488, 1), + hx(concat!("f0debc9a78563412", "b8090000", "01", "00")), + "CMSG_BUY_ITEM body" + ); + + // CMSG_SELL_ITEM (vmangos Item.cpp:87-92): vendorGuid, itemGuid, count (0 = whole stack). + assert_eq!( + messages::sell_item(0x1234_5678_9abc_def0, 0x42, 0), + hx(concat!("f0debc9a78563412", "4200000000000000", "00")), + "CMSG_SELL_ITEM body" + ); +} + +#[test] +fn gossip_message_wire() { + use benilla_protocol::messages::{GossipOption, QuestOption}; + + // SMSG_GOSSIP_MESSAGE (vmangos GossipDef.cpp:180-225, the 1.12 shape): objectGuid, textId, + // optionCount + options (index, icon, coded, message), questOptionCount + quest options. Zero + // quest options — the common case for a pure vendor/gossip NPC. + let mut body = 0xAAu64.to_le_bytes().to_vec(); // objectGuid + body.extend_from_slice(&100u32.to_le_bytes()); // textId + body.extend_from_slice(&2u32.to_le_bytes()); // optionCount + body.extend_from_slice(&0u32.to_le_bytes()); // option 0: index + body.push(0); // icon: chat bubble + body.push(0); // coded: false + body.extend_from_slice(b"Train me\0"); + body.extend_from_slice(&1u32.to_le_bytes()); // option 1: index + body.push(1); // icon: vendor + body.push(1); // coded: true + body.extend_from_slice(b"Show me your wares\0"); + body.extend_from_slice(&0u32.to_le_bytes()); // questOptionCount: 0 + + match messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body).unwrap() { + ServerPacket::GossipMessage { + npc, + text_id, + options, + quests, + } => { + assert_eq!((npc, text_id), (0xAA, 100)); + assert_eq!( + options, + vec![ + GossipOption { + index: 0, + icon: 0, + coded: false, + message: "Train me".into(), + }, + GossipOption { + index: 1, + icon: 1, + coded: true, + message: "Show me your wares".into(), + }, + ] + ); + assert!(quests.is_empty()); + } + other => panic!("gossip message, got {}", other.name()), + } + + // decode() surfaces a GossipMenu event carrying the (empty here) quest block. + let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::GossipMenu { + npc, + text_id, + options, + quests, + } => { + assert_eq!((npc, text_id, options.len()), (0xAA, 100, 2)); + assert!(quests.is_empty()); + } + other => panic!("gossip menu event, got {other:?}"), + } + + // Nonzero quest options: parsed for byte alignment, kept on the wire struct (arc ignores them + // downstream — quest-giver flows are out of scope, decision 0081). + let mut body_q = 0xBBu64.to_le_bytes().to_vec(); + body_q.extend_from_slice(&200u32.to_le_bytes()); // textId + body_q.extend_from_slice(&0u32.to_le_bytes()); // optionCount: 0 + body_q.extend_from_slice(&1u32.to_le_bytes()); // questOptionCount: 1 + body_q.extend_from_slice(&42u32.to_le_bytes()); // questId + body_q.extend_from_slice(&7u32.to_le_bytes()); // icon + body_q.extend_from_slice(&10u32.to_le_bytes()); // level + body_q.extend_from_slice(b"A Quest\0"); + + match messages::parse_server(messages::opcode::SMSG_GOSSIP_MESSAGE, &body_q).unwrap() { + ServerPacket::GossipMessage { + npc, + options, + quests, + .. + } => { + assert_eq!(npc, 0xBB); + assert!(options.is_empty()); + assert_eq!( + quests, + vec![QuestOption { + quest_id: 42, + icon: 7, + level: 10, + title: "A Quest".into(), + }] + ); + } + other => panic!("gossip message with quests, got {}", other.name()), + } + + // SMSG_GOSSIP_COMPLETE — empty body. + match messages::parse_server(messages::opcode::SMSG_GOSSIP_COMPLETE, &[]).unwrap() { + ServerPacket::GossipComplete => {} + other => panic!("gossip complete, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_GOSSIP_COMPLETE, &[]).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::GossipComplete => {} + other => panic!("gossip complete event, got {other:?}"), + } +} + +#[test] +fn npc_text_update_greeting_extraction() { + // SMSG_NPC_TEXT_UPDATE (vmangos GossipDef.cpp:298-369): always 8 blocks of {f32 probability, + // cstr text0 (male), cstr text1 (female), u32 languageId, 3x(u32 emoteDelay, u32 emoteId)}. + fn block(w: &mut Vec, probability: f32, text0: &str, text1: &str) { + w.extend_from_slice(&probability.to_le_bytes()); + w.extend_from_slice(text0.as_bytes()); + w.push(0); + w.extend_from_slice(text1.as_bytes()); + w.push(0); + w.extend_from_slice(&0u32.to_le_bytes()); // languageId + for _ in 0..3 { + w.extend_from_slice(&0u32.to_le_bytes()); // emoteDelay + w.extend_from_slice(&0u32.to_le_bytes()); // emoteId + } + } + + // Block 3 (probability 0.5) is the highest-probability block with a non-empty male text — it + // wins over both lower-probability blocks and the higher-index female-only block 6. + let mut body = 321u32.to_le_bytes().to_vec(); // textID + block(&mut body, 0.1, "Low probability greeting", ""); + block(&mut body, 0.0, "", ""); + block(&mut body, 0.0, "", ""); + block(&mut body, 0.5, "Welcome, $N!", "Welcome, traveler!"); + block(&mut body, 0.0, "", ""); + block(&mut body, 0.0, "", ""); + block(&mut body, 0.2, "", "Female-only greeting"); + block(&mut body, 0.0, "", ""); + + match messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap() { + ServerPacket::NpcText { text_id, greeting } => { + assert_eq!(text_id, 321); + assert_eq!(greeting, "Welcome, $N!"); + } + other => panic!("npc text update, got {}", other.name()), + } + + let packet = messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::NpcGreeting { text_id, greeting } => { + assert_eq!((text_id, greeting.as_str()), (321, "Welcome, $N!")); + } + other => panic!("npc greeting event, got {other:?}"), + } + + // Male-empty fallback: the winning block's text0 is empty, text1 is used instead. + let mut fallback_body = 322u32.to_le_bytes().to_vec(); + for _ in 0..7 { + block(&mut fallback_body, 0.0, "", ""); + } + block(&mut fallback_body, 1.0, "", "Only the ladies get a hello"); + match messages::parse_server(messages::opcode::SMSG_NPC_TEXT_UPDATE, &fallback_body).unwrap() { + ServerPacket::NpcText { text_id, greeting } => { + assert_eq!(text_id, 322); + assert_eq!(greeting, "Only the ladies get a hello"); + } + other => panic!("npc text fallback, got {}", other.name()), + } +} + +#[test] +fn vendor_list_inventory_wire() { + use benilla_protocol::messages::VendorItem; + + // SMSG_LIST_INVENTORY (vmangos ItemHandler.cpp:741-810): vendorGuid, u8 count, count x {muid, + // entry, displayId, currentCount, price, maxDurability, buyCount}. Two rows keyed off the + // live-verified creature-54 weapon vendor data (decision 0081): Gladius (entry + // 2488, price 536, display 22078, maxcount 0 -> unlimited) and a limited-stock second row. + // Trailing junk proves the parser stops reading rows at `count`. + let mut body = 0xCCu64.to_le_bytes().to_vec(); + body.push(2); // count + for v in [1u32, 2488, 22078, 0xFFFF_FFFF, 536, 35, 1] { + body.extend_from_slice(&v.to_le_bytes()); + } + for v in [2u32, 2489, 22079, 5, 342, 40, 1] { + body.extend_from_slice(&v.to_le_bytes()); + } + body.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); // trailing junk, must not become a third row + + match messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &body).unwrap() { + ServerPacket::VendorList { vendor, items } => { + assert_eq!(vendor, 0xCC); + assert_eq!( + items, + vec![ + VendorItem { + slot: 1, + entry: 2488, + display_id: 22078, + current_count: 0xFFFF_FFFF, + price: 536, + max_durability: 35, + buy_count: 1, + }, + VendorItem { + slot: 2, + entry: 2489, + display_id: 22079, + current_count: 5, + price: 342, + max_durability: 40, + buy_count: 1, + }, + ] + ); + } + other => panic!("vendor list, got {}", other.name()), + } + + let packet = messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::VendorInventory { vendor, items } => { + assert_eq!((vendor, items.len()), (0xCC, 2)); + } + other => panic!("vendor inventory event, got {other:?}"), + } + + // Empty stock: count = 0 followed by the trailing error byte (ItemHandler.cpp:728-733, + // 806-809) — the parser must tolerate it (the row loop simply never runs). + let mut empty_body = 0xDDu64.to_le_bytes().to_vec(); + empty_body.push(0); // count + empty_body.push(0); // trailing errorByte(0) + match messages::parse_server(messages::opcode::SMSG_LIST_INVENTORY, &empty_body).unwrap() { + ServerPacket::VendorList { vendor, items } => { + assert_eq!(vendor, 0xDD); + assert!(items.is_empty()); + } + other => panic!("empty vendor list, got {}", other.name()), + } +} + +#[test] +fn vendor_buy_sell_result_wire() { + use benilla_protocol::messages::{buy_result, sell_result}; + + // SMSG_BUY_ITEM (vmangos Item.cpp:190-196): vendorGuid, vendorSlot (1-based), newCount + // (0xFFFF_FFFF unlimited), purchaseCount. Only the vendor stock display changes here. + let mut body = 0xCCu64.to_le_bytes().to_vec(); + for v in [1u32, 0xFFFF_FFFF, 1] { + body.extend_from_slice(&v.to_le_bytes()); + } + match messages::parse_server(messages::opcode::SMSG_BUY_ITEM, &body).unwrap() { + ServerPacket::BuyItem { + vendor, + slot, + new_count, + purchase_count, + } => { + assert_eq!( + (vendor, slot, new_count, purchase_count), + (0xCC, 1, 0xFFFF_FFFF, 1) + ); + } + other => panic!("buy item, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_BUY_ITEM, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::VendorBuyResult { vendor, slot, .. } => { + assert_eq!((vendor, slot), (0xCC, 1)); + } + other => panic!("vendor buy result event, got {other:?}"), + } + + // SMSG_SELL_ITEM (vmangos Item.cpp:183-188) — the error path only: vendorGuid, itemGuid, reason. + let mut body = 0xCCu64.to_le_bytes().to_vec(); + body.extend_from_slice(&0x42u64.to_le_bytes()); + body.push(sell_result::CANT_FIND_ITEM); + match messages::parse_server(messages::opcode::SMSG_SELL_ITEM, &body).unwrap() { + ServerPacket::SellItemResult { + vendor, + item_guid, + reason, + } => { + assert_eq!( + (vendor, item_guid, reason), + (0xCC, 0x42, sell_result::CANT_FIND_ITEM) + ); + } + other => panic!("sell item error, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_SELL_ITEM, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::VendorSellFailed { reason, .. } => { + assert_eq!(reason, sell_result::CANT_FIND_ITEM); + } + other => panic!("vendor sell failed event, got {other:?}"), + } + + // SMSG_BUY_FAILED (vmangos Item.h:277): vendorGuid, itemEntry, reason. + let mut body = 0xCCu64.to_le_bytes().to_vec(); + body.extend_from_slice(&2488u32.to_le_bytes()); + body.push(buy_result::NOT_ENOUGH_MONEY); + match messages::parse_server(messages::opcode::SMSG_BUY_FAILED, &body).unwrap() { + ServerPacket::BuyFailed { + vendor, + item_entry, + reason, + } => { + assert_eq!( + (vendor, item_entry, reason), + (0xCC, 2488, buy_result::NOT_ENOUGH_MONEY) + ); + } + other => panic!("buy failed, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_BUY_FAILED, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::VendorBuyFailed { reason, .. } => { + assert_eq!(reason, buy_result::NOT_ENOUGH_MONEY); + } + other => panic!("vendor buy failed event, got {other:?}"), + } + + // Result enum values (vmangos ItemDefines.h:120-141) — pinned so a future edit can't drift them. + assert_eq!( + ( + buy_result::CANT_FIND_ITEM, + buy_result::ITEM_ALREADY_SOLD, + buy_result::NOT_ENOUGH_MONEY, + buy_result::SELLER_DONT_LIKE_YOU, + buy_result::DISTANCE_TOO_FAR, + buy_result::ITEM_SOLD_OUT, + buy_result::CANT_CARRY_MORE, + buy_result::RANK_REQUIRE, + buy_result::REPUTATION_REQUIRE, + ), + (0, 1, 2, 4, 5, 7, 8, 11, 12) + ); + assert_eq!( + ( + sell_result::CANT_FIND_ITEM, + sell_result::CANT_SELL_ITEM, + sell_result::CANT_FIND_VENDOR, + sell_result::YOU_DONT_OWN_THAT_ITEM, + sell_result::UNK, + sell_result::ONLY_EMPTY_BAG, + ), + (1, 2, 3, 4, 5, 6) + ); +} diff --git a/crates/benilla-protocol/tests/group.rs b/crates/benilla-protocol/tests/group.rs new file mode 100644 index 00000000..81dad938 --- /dev/null +++ b/crates/benilla-protocol/tests/group.rs @@ -0,0 +1,677 @@ +//! Golden tests for the group/party opcode family — invite/accept/decline/kick/leader/disband, the +//! loot method, the roster push (`SMSG_GROUP_LIST`), party command feedback, live member stats for +//! the party/raid frame, minimap pings, raid subgroup management, raid-target icons, and ready +//! checks. CMSG bodies are asserted byte-exact against the builder output; SMSG bodies are hand-built +//! per the vmangos layouts (cited inline) and round-tripped through `parse_server` + `decode`. See +//! `tests/common` for the shared `hx()` fixture helper and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{ + self, member_status, opcode, party_member_mask, party_operation, party_result, GroupLootInfo, + GroupMemberEntry, PartyMemberStatsInfo, GROUP_MEMBER_ASSISTANT, +}; +use benilla_protocol::ServerPacket; +use common::hx; + +/// Every CMSG builder in the family, byte-exact — including the empty-body ones (asserting an +/// empty `Vec`) and the `raid_target_set` vs `raid_target_request` distinction. +#[test] +fn cmsg_bodies_golden() { + let guid = 0x1234_5678_9abc_def0u64; + let guid_hex = "f0debc9a78563412"; + + // CMSG_GROUP_INVITE / CMSG_GROUP_UNINVITE (vmangos Group.cpp:4-7 / 9-12): one cstring. + assert_eq!( + messages::group_invite("Bob"), + hx("426f6200"), + "CMSG_GROUP_INVITE body" + ); + assert_eq!( + messages::group_uninvite("Bob"), + hx("426f6200"), + "CMSG_GROUP_UNINVITE body" + ); + + // CMSG_GROUP_ACCEPT / DECLINE / DISBAND / RAID_CONVERT (vmangos Opcodes.cpp: NullClientPacket): + // all empty. + assert_eq!( + messages::group_accept(), + Vec::::new(), + "CMSG_GROUP_ACCEPT body" + ); + assert_eq!( + messages::group_decline(), + Vec::::new(), + "CMSG_GROUP_DECLINE body" + ); + assert_eq!( + messages::group_disband(), + Vec::::new(), + "CMSG_GROUP_DISBAND body" + ); + assert_eq!( + messages::group_raid_convert(), + Vec::::new(), + "CMSG_GROUP_RAID_CONVERT body" + ); + + // CMSG_GROUP_UNINVITE_GUID / CMSG_GROUP_SET_LEADER / CMSG_REQUEST_PARTY_MEMBER_STATS: a full guid. + assert_eq!( + messages::group_uninvite_guid(guid), + hx(guid_hex), + "CMSG_GROUP_UNINVITE_GUID body" + ); + assert_eq!( + messages::group_set_leader(guid), + hx(guid_hex), + "CMSG_GROUP_SET_LEADER body" + ); + assert_eq!( + messages::request_party_member_stats(guid), + hx(guid_hex), + "CMSG_REQUEST_PARTY_MEMBER_STATS body" + ); + + // CMSG_LOOT_METHOD (Group.cpp:26-31): u32 method, full guid lootMaster, u32 threshold. + assert_eq!( + messages::loot_method(2, guid, 3), + hx(concat!("02000000", "f0debc9a78563412", "03000000")), + "CMSG_LOOT_METHOD body" + ); + + // CMSG_GROUP_CHANGE_SUB_GROUP (Group.cpp:45-49): cstring name + u8 groupNr. + assert_eq!( + messages::group_change_sub_group("Bar", 5), + hx("4261720005"), + "CMSG_GROUP_CHANGE_SUB_GROUP body" + ); + + // CMSG_GROUP_SWAP_SUB_GROUP (Group.cpp:51-55): cstring name + cstring nameSwapWith. + assert_eq!( + messages::group_swap_sub_group("A", "B"), + hx("41004200"), + "CMSG_GROUP_SWAP_SUB_GROUP body" + ); + + // CMSG_GROUP_ASSISTANT_LEADER (Group.cpp:66-74, 1.12 guid branch): full guid + u8 flag. + assert_eq!( + messages::group_assistant_leader(guid, true), + hx(concat!("f0debc9a78563412", "01")), + "CMSG_GROUP_ASSISTANT_LEADER body, grant" + ); + assert_eq!( + messages::group_assistant_leader(guid, false), + hx(concat!("f0debc9a78563412", "00")), + "CMSG_GROUP_ASSISTANT_LEADER body, revoke" + ); + + // MSG_MINIMAP_PING, outbound (Group.cpp:33-37): f32 x, f32 y — no guid. + assert_eq!( + messages::minimap_ping(1.0, 2.0), + hx("0000803f00000040"), + "MSG_MINIMAP_PING outbound body" + ); + + // MSG_RAID_TARGET_UPDATE, client bodies (Group.cpp:77-82): icon+guid to set/clear one icon, a + // lone 0xFF to ask for the current set — the two builders are never interchangeable byte-shapes. + assert_eq!( + messages::raid_target_set(3, guid), + hx(concat!("03", "f0debc9a78563412")), + "raid_target_set body" + ); + assert_eq!( + messages::raid_target_set(1, 0), + hx(concat!("01", "0000000000000000")), + "raid_target_set clear (guid 0) body" + ); + assert_eq!( + messages::raid_target_request(), + hx("ff"), + "raid_target_request body" + ); + + // MSG_RAID_READY_CHECK, client bodies (Group.cpp:84-92): empty to start, one byte to answer. + assert_eq!( + messages::ready_check_start(), + Vec::::new(), + "ready_check_start body" + ); + assert_eq!( + messages::ready_check_answer(true), + hx("01"), + "ready_check_answer(true) body" + ); + assert_eq!( + messages::ready_check_answer(false), + hx("00"), + "ready_check_answer(false) body" + ); +} + +/// The name/notice trio (`SMSG_GROUP_INVITE`/`_DECLINE`/`_SET_LEADER`, one cstring each) plus the +/// two empty-body notices (`SMSG_GROUP_UNINVITE`/`_DESTROYED`). +#[test] +fn group_notification_smsg_wire() { + // SMSG_GROUP_INVITE (Group.cpp:107-110): one cstring, the inviter's name. + let p = messages::parse_server(opcode::SMSG_GROUP_INVITE, &hx("426f6200")).unwrap(); + match &p { + ServerPacket::GroupInvite { inviter } => assert_eq!(inviter, "Bob"), + other => panic!("expected GroupInvite, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::GroupInvite { inviter }] => assert_eq!(inviter, "Bob"), + other => panic!("group invite decode: {other:?}"), + } + + // SMSG_GROUP_DECLINE (Group.cpp:112-115): one cstring, the declining player's name. + let p = messages::parse_server(opcode::SMSG_GROUP_DECLINE, &hx("416c69636500")).unwrap(); + match &p { + ServerPacket::GroupDecline { name } => assert_eq!(name, "Alice"), + other => panic!("expected GroupDecline, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::GroupDecline { name }] => assert_eq!(name, "Alice"), + other => panic!("group decline decode: {other:?}"), + } + + // SMSG_GROUP_SET_LEADER (Group.cpp:150-153): one cstring, the new leader's name. + let p = messages::parse_server(opcode::SMSG_GROUP_SET_LEADER, &hx("426f6200")).unwrap(); + match &p { + ServerPacket::GroupLeaderChanged { name } => assert_eq!(name, "Bob"), + other => panic!("expected GroupLeaderChanged, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::GroupLeaderChanged { name }] => assert_eq!(name, "Bob"), + other => panic!("group leader changed decode: {other:?}"), + } + + // SMSG_GROUP_UNINVITE (Group.cpp:117-119): empty body — we were kicked/left. + let p = messages::parse_server(opcode::SMSG_GROUP_UNINVITE, &[]).unwrap(); + assert!(matches!(p, ServerPacket::GroupUninvited)); + assert!(matches!( + decode(p).as_slice(), + [SessionEvent::GroupUninvited] + )); + + // SMSG_GROUP_DESTROYED (Group.cpp:121-123): empty body — the group disbanded outright. + let p = messages::parse_server(opcode::SMSG_GROUP_DESTROYED, &[]).unwrap(); + assert!(matches!(p, ServerPacket::GroupDestroyed)); + assert!(matches!( + decode(p).as_slice(), + [SessionEvent::GroupDestroyed] + )); +} + +/// `SMSG_GROUP_LIST`, party shape: 2 other members + the full loot tail (master loot, so +/// `looterGuid` carries the master's guid). +#[test] +fn group_list_party_two_members_master_loot() { + // Group.cpp:155-180 (GroupList::AppendBodyTo): groupType, ownGroupAndAssistantFlag, + // memberCount, memberCount x member rows, leaderGuid, then (memberCount > 0) the loot tail. + let mut body = vec![0u8]; // groupType: party + body.push(0x00); // ownGroupAndAssistantFlag: subgroup 0, not assistant + body.extend_from_slice(&2u32.to_le_bytes()); // memberCount + + body.extend_from_slice(b"Alice\0"); + body.extend_from_slice(&0x1111u64.to_le_bytes()); + body.push(member_status::ONLINE); + body.push(0x00); + + body.extend_from_slice(b"Carol\0"); + body.extend_from_slice(&0x3333u64.to_le_bytes()); + body.push(member_status::ONLINE | member_status::DEAD); + body.push(0x00); + + body.extend_from_slice(&0x1111u64.to_le_bytes()); // leaderGuid: Alice + + body.push(2); // lootMethod: master loot + body.extend_from_slice(&0x3333u64.to_le_bytes()); // looterGuid: Carol is master looter + body.push(3); // lootThreshold + body.push(0); // dungeonDifficulty (always 0 at 5875) + + let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap(); + match &p { + ServerPacket::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } => { + assert_eq!(*group_type, 0); + assert_eq!(*own_flags, 0x00); + assert_eq!( + members, + &vec![ + GroupMemberEntry { + name: "Alice".into(), + guid: 0x1111, + status: member_status::ONLINE, + flags: 0x00, + }, + GroupMemberEntry { + name: "Carol".into(), + guid: 0x3333, + status: member_status::ONLINE | member_status::DEAD, + flags: 0x00, + }, + ] + ); + assert_eq!(*leader, 0x1111); + assert_eq!( + *loot, + Some(GroupLootInfo { + method: 2, + master: 0x3333, + threshold: 3, + }) + ); + } + other => panic!("expected GroupList, got {}", other.name()), + } + + match decode(p).as_slice() { + [SessionEvent::GroupList { + group_type: 0, + members, + leader: 0x1111, + loot: Some(loot), + .. + }] => { + assert_eq!(members.len(), 2); + assert_eq!(loot.master, 0x3333); + } + other => panic!("group list decode: {other:?}"), + } +} + +/// `SMSG_GROUP_LIST`, raid shape: `groupType == 1`, one member carrying the raid-assistant bit +/// (`0x80`), round-robin loot (so `looterGuid` is `0` — vmangos only fills it for master loot). +#[test] +fn group_list_raid_with_assistant_flag() { + let mut body = vec![1u8]; // groupType: raid + body.push(0x02); // ownGroupAndAssistantFlag: subgroup 2, not assistant + body.extend_from_slice(&1u32.to_le_bytes()); // memberCount + + body.extend_from_slice(b"Dave\0"); + body.extend_from_slice(&0x4444u64.to_le_bytes()); + body.push(member_status::ONLINE); + body.push(GROUP_MEMBER_ASSISTANT | 0x01); // subgroup 1, assistant + + body.extend_from_slice(&0x4444u64.to_le_bytes()); // leaderGuid: Dave + + body.push(1); // lootMethod: round-robin + body.extend_from_slice(&0u64.to_le_bytes()); // looterGuid: 0 (not master loot) + body.push(2); // lootThreshold + body.push(0); // dungeonDifficulty + + let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap(); + match &p { + ServerPacket::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } => { + assert_eq!(*group_type, 1); + assert_eq!(*own_flags, 0x02); + assert_eq!( + members, + &vec![GroupMemberEntry { + name: "Dave".into(), + guid: 0x4444, + status: member_status::ONLINE, + flags: GROUP_MEMBER_ASSISTANT | 0x01, + }] + ); + assert!(members[0].flags & GROUP_MEMBER_ASSISTANT != 0); + assert_eq!(*leader, 0x4444); + assert_eq!( + *loot, + Some(GroupLootInfo { + method: 1, + master: 0, + threshold: 2, + }) + ); + } + other => panic!("expected GroupList, got {}", other.name()), + } +} + +/// The degenerate "you left the group" shape (`Group.cpp:155-180` with an empty member list): +/// exactly 14 bytes (`groupType, ownFlags, u32 memberCount=0, u64 leaderGuid=0`), no loot tail. +#[test] +fn group_list_empty_you_left_shape_is_14_bytes() { + let body = vec![0u8; 14]; + assert_eq!(body.len(), 14); + + let p = messages::parse_server(opcode::SMSG_GROUP_LIST, &body).unwrap(); + match &p { + ServerPacket::GroupList { + group_type, + own_flags, + members, + leader, + loot, + } => { + assert_eq!(*group_type, 0); + assert_eq!(*own_flags, 0); + assert!(members.is_empty()); + assert_eq!(*leader, 0); + assert!(loot.is_none(), "empty member list carries no loot tail"); + } + other => panic!("expected GroupList, got {}", other.name()), + } + + match decode(p).as_slice() { + [SessionEvent::GroupList { + members, + loot: None, + .. + }] => assert!(members.is_empty()), + other => panic!("empty group list decode: {other:?}"), + } +} + +/// `SMSG_PARTY_COMMAND_RESULT` (Group.cpp:100-105): a named refusal, and the ignoring-you refusal +/// which names no one (`Handlers/GroupHandler.cpp:466`: `SendPartyResult(PARTY_OP_INVITE, "", ...)`). +#[test] +fn party_command_result_wire() { + let mut body = party_operation::INVITE.to_le_bytes().to_vec(); + body.extend_from_slice(b"Bob\0"); + body.extend_from_slice(&party_result::ALREADY_IN_GROUP.to_le_bytes()); + let p = messages::parse_server(opcode::SMSG_PARTY_COMMAND_RESULT, &body).unwrap(); + match &p { + ServerPacket::PartyCommandResult { + operation, + member, + result, + } => { + assert_eq!(*operation, party_operation::INVITE); + assert_eq!(member, "Bob"); + assert_eq!(*result, party_result::ALREADY_IN_GROUP); + } + other => panic!("expected PartyCommandResult, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::PartyCommandResult { member, .. }] => assert_eq!(member, "Bob"), + other => panic!("party command result decode: {other:?}"), + } + + let mut body = party_operation::LEAVE.to_le_bytes().to_vec(); + body.push(0); // empty cstring: no member named + body.extend_from_slice(&party_result::IGNORING_YOU.to_le_bytes()); + let p = messages::parse_server(opcode::SMSG_PARTY_COMMAND_RESULT, &body).unwrap(); + match &p { + ServerPacket::PartyCommandResult { + operation, + member, + result, + } => { + assert_eq!(*operation, party_operation::LEAVE); + assert!(member.is_empty()); + assert_eq!(*result, party_result::IGNORING_YOU); + } + other => panic!("expected PartyCommandResult, got {}", other.name()), + } +} + +/// `SMSG_PARTY_MEMBER_STATS` (delta form): mask `0x000000FF` — every bit from `STATUS` through +/// `ZONE` — hand-computed body, the plain (non-`_FULL`) opcode. +#[test] +fn party_member_stats_delta_status_through_zone() { + let body = hx(concat!( + "012a", // packed guid 0x2A (mask 0x01, one nonzero byte) + "ff000000", // mask: STATUS|CUR_HP|MAX_HP|POWER_TYPE|CUR_POWER|MAX_POWER|LEVEL|ZONE + "01", // status: ONLINE + "6400", // cur_hp 100 + "9600", // max_hp 150 + "00", // power_type: mana + "c800", // cur_power 200 + "fa00", // max_power 250 + "3c00", // level 60 + "ef05", // zone 1519 + )); + let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS, &body).unwrap(); + match &p { + ServerPacket::PartyMemberStats { guid, full, info } => { + assert_eq!(*guid, 0x2A); + assert!(!*full); + assert_eq!( + **info, + PartyMemberStatsInfo { + status: Some(member_status::ONLINE), + cur_hp: Some(100), + max_hp: Some(150), + power_type: Some(0), + cur_power: Some(200), + max_power: Some(250), + level: Some(60), + zone: Some(1519), + ..Default::default() + } + ); + } + other => panic!("expected PartyMemberStats, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::PartyMemberStats { + guid: 0x2A, + full: false, + info, + }] => assert_eq!(info.level, Some(60)), + other => panic!("party member stats delta decode: {other:?}"), + } +} + +/// `SMSG_PARTY_MEMBER_STATS_FULL`: `POSITION` + `AURAS` (2 bits set) + `AURAS_NEGATIVE` (1 bit) + +/// the whole pet block (`PET_GUID`..`PET_AURAS_NEGATIVE`, including `PET_AURAS`) — every remaining +/// field family the delta test above didn't touch. +#[test] +fn party_member_stats_full_position_auras_and_pet_block() { + let mask = party_member_mask::POSITION + | party_member_mask::AURAS + | party_member_mask::AURAS_NEGATIVE + | party_member_mask::PET_GUID + | party_member_mask::PET_NAME + | party_member_mask::PET_MODEL_ID + | party_member_mask::PET_CUR_HP + | party_member_mask::PET_MAX_HP + | party_member_mask::PET_POWER_TYPE + | party_member_mask::PET_CUR_POWER + | party_member_mask::PET_MAX_POWER + | party_member_mask::PET_AURAS + | party_member_mask::PET_AURAS_NEGATIVE; + assert_eq!(mask, 0x001F_FF00, "sanity: the hand-picked bit set"); + + let mut body = vec![0x01, 0x7F]; // packed guid 0x7F (mask 0x01, one nonzero byte) + body.extend_from_slice(&mask.to_le_bytes()); + + // POSITION: i16 x, i16 y (negative, to prove the signed cast). + body.extend_from_slice(&1234i16.to_le_bytes()); + body.extend_from_slice(&(-5678i16).to_le_bytes()); + // AURAS: u32 posMask, bits 0 and 5 set -> spell ids 133 (Fireball), 116 (Frostbolt). + body.extend_from_slice(&0x0000_0021u32.to_le_bytes()); + body.extend_from_slice(&133u16.to_le_bytes()); + body.extend_from_slice(&116u16.to_le_bytes()); + // AURAS_NEGATIVE: u16 negMask, bit 2 set -> spell id 8050. + body.extend_from_slice(&0x0004u16.to_le_bytes()); + body.extend_from_slice(&8050u16.to_le_bytes()); + // PET_GUID. + body.extend_from_slice(&0x1122_3344_5566_7788u64.to_le_bytes()); + // PET_NAME. + body.extend_from_slice(b"Fido\0"); + // PET_MODEL_ID, PET_CUR_HP, PET_MAX_HP. + body.extend_from_slice(&618u16.to_le_bytes()); + body.extend_from_slice(&40u16.to_le_bytes()); + body.extend_from_slice(&50u16.to_le_bytes()); + // PET_POWER_TYPE: mana. + body.push(0); + // PET_CUR_POWER, PET_MAX_POWER. + body.extend_from_slice(&80u16.to_le_bytes()); + body.extend_from_slice(&100u16.to_le_bytes()); + // PET_AURAS: u32 mask, bit 3 set -> spell id 1126 (Mark of the Wild). + body.extend_from_slice(&0x0000_0008u32.to_le_bytes()); + body.extend_from_slice(&1126u16.to_le_bytes()); + // PET_AURAS_NEGATIVE: u16 mask, bit 1 set -> spell id 770 (Faerie Fire). + body.extend_from_slice(&0x0002u16.to_le_bytes()); + body.extend_from_slice(&770u16.to_le_bytes()); + + let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS_FULL, &body).unwrap(); + match &p { + ServerPacket::PartyMemberStats { guid, full, info } => { + assert_eq!(*guid, 0x7F); + assert!(*full); + assert_eq!(info.position, Some((1234, -5678))); + assert_eq!(info.auras, Some(vec![133, 116])); + assert_eq!(info.auras_negative, Some(vec![8050])); + assert_eq!(info.pet_guid, Some(0x1122_3344_5566_7788)); + assert_eq!(info.pet_name.as_deref(), Some("Fido")); + assert_eq!(info.pet_model_id, Some(618)); + assert_eq!(info.pet_cur_hp, Some(40)); + assert_eq!(info.pet_max_hp, Some(50)); + assert_eq!(info.pet_power_type, Some(0)); + assert_eq!(info.pet_cur_power, Some(80)); + assert_eq!(info.pet_max_power, Some(100)); + assert_eq!(info.pet_auras, Some(vec![1126])); + assert_eq!(info.pet_auras_negative, Some(vec![770])); + // Bits not in the mask stay None. + assert_eq!(info.status, None); + assert_eq!(info.level, None); + } + other => panic!("expected PartyMemberStats, got {}", other.name()), + } + + match decode(p).as_slice() { + [SessionEvent::PartyMemberStats { + guid: 0x7F, + full: true, + info, + }] => assert_eq!(info.pet_name.as_deref(), Some("Fido")), + other => panic!("party member stats full decode: {other:?}"), + } +} + +/// The offline-miss reply (`Handlers/GroupHandler.cpp:763-774`, the not-in-raid-with-us branch): +/// `SMSG_PARTY_MEMBER_STATS_FULL`, mask `STATUS` only, status `MEMBER_STATUS_OFFLINE` (`0`). +#[test] +fn party_member_stats_offline_miss_is_status_only() { + let body = hx(concat!( + "015c", // packed guid 0x5C + "01000000", // mask: STATUS only + "00", // status: offline + )); + let p = messages::parse_server(opcode::SMSG_PARTY_MEMBER_STATS_FULL, &body).unwrap(); + match &p { + ServerPacket::PartyMemberStats { guid, full, info } => { + assert_eq!(*guid, 0x5C); + assert!(*full); + assert_eq!(info.status, Some(member_status::OFFLINE)); + assert_eq!(info.cur_hp, None); + } + other => panic!("expected PartyMemberStats, got {}", other.name()), + } +} + +/// `MSG_MINIMAP_PING` inbound (`Handlers/GroupHandler.cpp:382-391`): full guid + f32 x + f32 y — +/// the server-stamped rebroadcast shape (outbound, guid-less, is covered in [`cmsg_bodies_golden`]). +#[test] +fn minimap_ping_inbound_wire() { + let mut body = 0x77u64.to_le_bytes().to_vec(); + body.extend_from_slice(&3.5f32.to_le_bytes()); + body.extend_from_slice(&(-4.25f32).to_le_bytes()); + let p = messages::parse_server(opcode::MSG_MINIMAP_PING, &body).unwrap(); + match &p { + ServerPacket::MinimapPing { guid, x, y } => { + assert_eq!(*guid, 0x77); + assert_eq!(*x, 3.5); + assert_eq!(*y, -4.25); + } + other => panic!("expected MinimapPing, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::MinimapPing { guid: 0x77, .. }] => {} + other => panic!("minimap ping decode: {other:?}"), + } +} + +/// `MSG_RAID_TARGET_UPDATE`, all three server shapes (Group.cpp:132-147): mode 0 (delta), mode 1 +/// with 2 entries, and mode 1 empty (no icons currently set — a raid with a clean target board). +#[test] +fn raid_target_update_smsg_shapes() { + let mut body = vec![0u8, 3]; // mode 0 (delta), icon 3 + body.extend_from_slice(&0x99u64.to_le_bytes()); + let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &body).unwrap(); + match &p { + ServerPacket::RaidTargetSet { icon, guid } => { + assert_eq!(*icon, 3); + assert_eq!(*guid, 0x99); + } + other => panic!("expected RaidTargetSet, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::RaidTargetSet { + icon: 3, + guid: 0x99, + }] => {} + other => panic!("raid target set decode: {other:?}"), + } + + let mut body = vec![1u8]; // mode 1 (full list), 2 entries + body.push(0); + body.extend_from_slice(&0x11u64.to_le_bytes()); + body.push(7); + body.extend_from_slice(&0x22u64.to_le_bytes()); + let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &body).unwrap(); + match &p { + ServerPacket::RaidTargetList { entries } => { + assert_eq!(entries, &vec![(0u8, 0x11u64), (7u8, 0x22u64)]); + } + other => panic!("expected RaidTargetList, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::RaidTargetList { entries }] => assert_eq!(entries.len(), 2), + other => panic!("raid target list decode: {other:?}"), + } + + // mode 1, empty: no icons currently marked. + let p = messages::parse_server(opcode::MSG_RAID_TARGET_UPDATE, &[1u8]).unwrap(); + match &p { + ServerPacket::RaidTargetList { entries } => assert!(entries.is_empty()), + other => panic!("expected empty RaidTargetList, got {}", other.name()), + } +} + +/// `MSG_RAID_READY_CHECK`, both server shapes (Group.cpp:94-96 empty / 126-130 answer): the empty +/// "a check just started" body, and a member's forwarded answer (full guid + state). +#[test] +fn ready_check_smsg_shapes() { + let p = messages::parse_server(opcode::MSG_RAID_READY_CHECK, &[]).unwrap(); + assert!(matches!(p, ServerPacket::ReadyCheckRequest)); + assert!(matches!( + decode(p).as_slice(), + [SessionEvent::ReadyCheckRequest] + )); + + let mut body = 0x88u64.to_le_bytes().to_vec(); + body.push(1); + let p = messages::parse_server(opcode::MSG_RAID_READY_CHECK, &body).unwrap(); + match &p { + ServerPacket::ReadyCheckAnswer { guid, ready } => { + assert_eq!(*guid, 0x88); + assert_eq!(*ready, 1); + } + other => panic!("expected ReadyCheckAnswer, got {}", other.name()), + } + match decode(p).as_slice() { + [SessionEvent::ReadyCheckAnswer { + guid: 0x88, + ready: 1, + }] => {} + other => panic!("ready check answer decode: {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/handshake.rs b/crates/benilla-protocol/tests/handshake.rs new file mode 100644 index 00000000..dfa07487 --- /dev/null +++ b/crates/benilla-protocol/tests/handshake.rs @@ -0,0 +1,104 @@ +//! The world handshake against a fake server, exercising [`WorldSession::connect`] over a real +//! socket with real header obfuscation. +//! +//! What these pin down is the packet *ordering* tolerance the handshake needs. A server does not +//! promise `SMSG_AUTH_RESPONSE` is the first encrypted packet — it interleaves its own traffic — +//! and one of those interleaved packets, `SMSG_WARDEN_DATA`, means the server runs an anticheat we +//! cannot answer and must be refused at login rather than entered and kicked ~30 s later. + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::thread; + +use benilla_protocol::messages::opcode; +use benilla_protocol::{messages, WardenRequired, WorldSession}; +use benilla_srp::vanilla_header::HeaderCrypto; +use benilla_srp::SESSION_KEY_LENGTH; + +const SESSION_KEY: [u8; SESSION_KEY_LENGTH] = [7u8; SESSION_KEY_LENGTH]; +const SERVER_SEED: u32 = 0xDEAD_BEEF; + +/// Write one server packet: 2-byte BE size (counts the opcode, not itself) + 2-byte LE opcode, +/// encrypted once `crypto` is in play, then the plaintext body. +fn send(stream: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, opcode: u16, body: &[u8]) { + let size = (body.len() + 2) as u16; + let s = size.to_be_bytes(); + let o = opcode.to_le_bytes(); + let mut header = [s[0], s[1], o[0], o[1]]; + if let Some(c) = crypto { + c.encrypter().encrypt(&mut header); + } + stream.write_all(&header).unwrap(); + stream.write_all(body).unwrap(); +} + +/// Read the client's unencrypted `CMSG_AUTH_SESSION` (6-byte header: BE size + LE u32 opcode). +fn read_auth_session(stream: &mut TcpStream) { + let mut header = [0u8; 6]; + stream.read_exact(&mut header).unwrap(); + let size = u16::from_be_bytes([header[0], header[1]]) as usize; + let mut body = vec![0u8; size - 4]; + stream.read_exact(&mut body).unwrap(); +} + +/// Stand up a fake world server that sends `pre` (opcode, body) pairs — encrypted, in order — +/// before a successful `SMSG_AUTH_RESPONSE`. Returns the address to point `connect` at. +fn fake_server(pre: Vec<(u16, Vec)>) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + send( + &mut stream, + None, + opcode::SMSG_AUTH_CHALLENGE, + &SERVER_SEED.to_le_bytes(), + ); + read_auth_session(&mut stream); + let mut crypto = HeaderCrypto::from_session_key(SESSION_KEY); + for (op, body) in pre { + send(&mut stream, Some(&mut crypto), op, &body); + } + send( + &mut stream, + Some(&mut crypto), + opcode::SMSG_AUTH_RESPONSE, + &[messages::AUTH_OK], + ); + // Hold the socket open so the client's reads never see a premature EOF. + thread::sleep(std::time::Duration::from_secs(2)); + }); + addr +} + +/// The plain case: `SMSG_AUTH_RESPONSE` leads, the handshake completes. +#[test] +fn auth_response_alone_completes_the_handshake() { + let addr = fake_server(vec![]); + assert!(WorldSession::connect(&addr, "one", SESSION_KEY).is_ok()); +} + +/// A server interleaving its own traffic ahead of the auth response must not fail the handshake — +/// the real one does this, and demanding AUTH_RESPONSE lead once broke login outright. +#[test] +fn packets_ahead_of_the_auth_response_are_skipped() { + let addr = fake_server(vec![ + (opcode::SMSG_LOGIN_VERIFY_WORLD, vec![0u8; 20]), + (opcode::SMSG_SET_FACTION_STANDING, vec![0u8; 12]), + ]); + assert!(WorldSession::connect(&addr, "one", SESSION_KEY).is_ok()); +} + +/// Warden among them is refused, with the error the login screen shows — never a session that +/// would be kicked once the server's response clock expires. +#[test] +fn a_warden_server_is_refused_at_the_handshake() { + let addr = fake_server(vec![(opcode::SMSG_WARDEN_DATA, vec![0u8; 16])]); + let Err(err) = WorldSession::connect(&addr, "one", SESSION_KEY) else { + panic!("a Warden server must not yield a session"); + }; + assert!( + err.downcast_ref::().is_some(), + "expected WardenRequired, got: {err:#}" + ); +} diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs new file mode 100644 index 00000000..e091cc15 --- /dev/null +++ b/crates/benilla-protocol/tests/items.rs @@ -0,0 +1,458 @@ +//! Item wire tests (mirrors `src/messages/items.rs`): the query request/response roundtrip, +//! inventory-change-failure (level refusal vs. plain), the GM-command system chat shape, and the +//! full `SMSG_ITEM_QUERY_SINGLE_RESPONSE` field walk (hit + undiscovered miss). Split out of the +//! former `tests/messages.rs` — see `tests/common` for the shared fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; +use common::hx; + +#[test] +fn item_query_wire() { + // CMSG_ITEM_QUERY_SINGLE: entry u32 + full item guid (vmangos QueryItem::ReadFromWorldPacket). + assert_eq!(messages::item_query(117, 0), hx("750000000000000000000000")); + + // CMSG_USE_ITEM (vmangos UseItem::ReadFromWorldPacket): bagIndex, slot, spellSlot, then a + // self-shaped target block (u16 mask 0). Backpack slot 1 = bag 255 + player-array slot 23. + assert_eq!(messages::use_item(255, 23, 0), hx("ff17000000")); + + // CMSG_AUTOEQUIP_ITEM (vmangos AutoEquipItem::ReadFromWorldPacket): bagIndex, slot. + assert_eq!(messages::auto_equip_item(255, 25), hx("ff19")); + + // SMSG_INVENTORY_CHANGE_FAILURE (vmangos InventoryChangeFailure::AppendBodyTo): the level + // refusal (reason 1) carries a u32 required level before the guids; others don't. + let level_fail = messages::parse_server( + messages::opcode::SMSG_INVENTORY_CHANGE_FAILURE, + &hx("010a000000420000000000004000000000000000000000"), + ) + .unwrap(); + match level_fail { + ServerPacket::InventoryChangeFailure { + reason, + required_level, + item_guid, + } => { + assert_eq!((reason, required_level), (1, Some(10))); + assert_eq!(item_guid, 0x4000_0000_0000_0042); + } + other => panic!("level refusal, got {}", other.name()), + } + let plain_fail = messages::parse_server( + messages::opcode::SMSG_INVENTORY_CHANGE_FAILURE, + &hx("14420000000000004000000000000000000000"), + ) + .unwrap(); + match plain_fail { + ServerPacket::InventoryChangeFailure { + reason, + required_level, + .. + } => assert_eq!((reason, required_level), (0x14, None)), + other => panic!("plain refusal, got {}", other.name()), + } + + // SMSG_MESSAGECHAT, the system shape (type 0x0A: guid, len-prefixed text incl. NUL, tag) — + // the GM-command feedback channel (live-verified: '.additem' answers exactly this shape). + let sys = messages::parse_server( + messages::opcode::SMSG_MESSAGECHAT, + &hx("0a0000000000000000000000000600000068656c6c6f0000"), + ) + .unwrap(); + match sys { + ServerPacket::MessageChat(m) => { + assert_eq!((m.chat_type, m.text.as_str()), (0x0a, "hello")); + assert_eq!((m.sender_guid, m.sender_name, m.channel), (0, None, None)); + } + other => panic!("system chat, got {}", other.name()), + } +} + +/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: the full 1.12.1 item template, byte-exact (VERIFIED +/// field order vmangos `HandleItemQuerySingleOpcode`, `ItemHandler.cpp:269-415`; every +/// `SUPPORTED_CLIENT_BUILD` conditional there is included for build 5875) — decision 0274 P1's +/// tooltip builder needs every line. A main-hand sword exercising every filtered/mirrored shape at +/// once: 2 of 5 damage blocks, 3 of 10 stats, 2 of 6 resistances, 2 of 5 spells (one ON_USE trigger +/// 0, one ON_EQUIP trigger 1), a nonempty description, and real requirement/durability values. +/// Every field carries a distinct value so a transposed or misaligned read fails the assert below. +#[test] +fn item_query_response_full_weapon_golden() { + use benilla_protocol::messages::{ItemDamage, ItemInfo, ItemSpellEntry, ItemUseSpell}; + + let mut body = 12_345u32.to_le_bytes().to_vec(); // entry + body.extend_from_slice(&2u32.to_le_bytes()); // class: weapon + body.extend_from_slice(&7u32.to_le_bytes()); // subclass: sword + body.extend_from_slice(b"Verified Blade of Testing\0"); + body.extend_from_slice(&[0, 0, 0]); // name2..4: empty cstrings + body.extend_from_slice(&6303u32.to_le_bytes()); // displayInfoID + body.extend_from_slice(&4u32.to_le_bytes()); // quality: epic + body.extend_from_slice(&66u32.to_le_bytes()); // flags + body.extend_from_slice(&4200u32.to_le_bytes()); // buyPrice + body.extend_from_slice(&1050u32.to_le_bytes()); // sellPrice + body.extend_from_slice(&21u32.to_le_bytes()); // inventoryType: WEAPONMAINHAND + body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableClass: all classes + body.extend_from_slice(&3i32.to_le_bytes()); // AllowableRace + body.extend_from_slice(&60u32.to_le_bytes()); // ItemLevel + body.extend_from_slice(&55u32.to_le_bytes()); // RequiredLevel + body.extend_from_slice(&164u32.to_le_bytes()); // RequiredSkill + body.extend_from_slice(&225u32.to_le_bytes()); // RequiredSkillRank + body.extend_from_slice(&5209u32.to_le_bytes()); // RequiredSpell + body.extend_from_slice(&3u32.to_le_bytes()); // RequiredHonorRank + body.extend_from_slice(&2u32.to_le_bytes()); // RequiredCityRank + body.extend_from_slice(&69u32.to_le_bytes()); // RequiredReputationFaction + body.extend_from_slice(&4u32.to_le_bytes()); // RequiredReputationRank + body.extend_from_slice(&5u32.to_le_bytes()); // MaxCount + body.extend_from_slice(&1u32.to_le_bytes()); // Stackable + body.extend_from_slice(&0u32.to_le_bytes()); // ContainerSlots + + // 10x ItemStat { type, value } — 3 nonzero (one negative value proves signed reads), 7 empty + // (dropped by the nonzero filter). + let stat_slots: [(u32, i32); 10] = [ + (4, 15), // STRENGTH +15 + (7, 20), // STAMINA +20 + (5, -3), // INTELLECT -3 + (0, 0), + (0, 0), + (0, 0), + (0, 0), + (0, 0), + (0, 0), + (0, 0), + ]; + for (stat_type, stat_value) in stat_slots { + body.extend_from_slice(&stat_type.to_le_bytes()); + body.extend_from_slice(&stat_value.to_le_bytes()); + } + + // 5x Damage { min f32, max f32, type u32 } — 2 real blocks (physical + a secondary Fire line), + // 3 empty (dropped by the `max > 0` filter). Block 0 also mirrors into dmg_min/dmg_max/dmg_type. + let dmg_slots: [(f32, f32, u32); 5] = [ + (12.0, 22.0, 0), // physical + (3.0, 7.0, 2), // Fire + (0.0, 0.0, 0), + (0.0, 0.0, 0), + (0.0, 0.0, 0), + ]; + for (min, max, school) in dmg_slots { + body.extend_from_slice(&min.to_le_bytes()); + body.extend_from_slice(&max.to_le_bytes()); + body.extend_from_slice(&school.to_le_bytes()); + } + + body.extend_from_slice(&15u32.to_le_bytes()); // Armor + // Resistances: Holy/Fire/Nature/Frost/Shadow/Arcane — 2 of 6 nonzero. + for r in [0i32, 8, 0, 0, 12, 0] { + body.extend_from_slice(&r.to_le_bytes()); + } + body.extend_from_slice(&2800u32.to_le_bytes()); // Delay (ms) + body.extend_from_slice(&2u32.to_le_bytes()); // AmmoType + body.extend_from_slice(&1.5f32.to_le_bytes()); // RangedModRange + + // 5x Spell block { SpellId, SpellTrigger, SpellCharges, Cooldown, Category, CategoryCooldown } + // — slot 0 ON_USE (a lone -1 use-cooldown next to a resolved category pair, the mixed shape + // vmangos can send), slot 1 ON_EQUIP (a plain passive, no cooldown), slots 2-4 the server's own + // unresolved-slot sentinel (0,0,0,-1,0,-1). + let spell_slots: [(u32, u32, i32, i32, u32, i32); 5] = [ + (17_251, 0, 0, -1, 4, 60_000), + (671, 1, 0, 0, 0, 0), + (0, 0, 0, -1, 0, -1), + (0, 0, 0, -1, 0, -1), + (0, 0, 0, -1, 0, -1), + ]; + for (spell_id, trigger, charges, cooldown_ms, category, category_cooldown_ms) in spell_slots { + body.extend_from_slice(&spell_id.to_le_bytes()); + body.extend_from_slice(&trigger.to_le_bytes()); + body.extend_from_slice(&charges.to_le_bytes()); + body.extend_from_slice(&cooldown_ms.to_le_bytes()); + body.extend_from_slice(&category.to_le_bytes()); + body.extend_from_slice(&category_cooldown_ms.to_le_bytes()); + } + + body.extend_from_slice(&2u32.to_le_bytes()); // Bonding: BIND_WHEN_EQUIPPED + body.extend_from_slice(b"A blade of pure verification.\0"); // Description + body.extend_from_slice(&333u32.to_le_bytes()); // PageText + body.extend_from_slice(&7u32.to_le_bytes()); // LanguageID + body.extend_from_slice(&2u32.to_le_bytes()); // PageMaterial + body.extend_from_slice(&444u32.to_le_bytes()); // StartQuest + body.extend_from_slice(&12u32.to_le_bytes()); // LockID + body.extend_from_slice(&3u32.to_le_bytes()); // Material + body.extend_from_slice(&3u32.to_le_bytes()); // Sheath: one-handed + body.extend_from_slice(&87u32.to_le_bytes()); // RandomProperty + body.extend_from_slice(&18u32.to_le_bytes()); // Block + body.extend_from_slice(&25u32.to_le_bytes()); // ItemSet + body.extend_from_slice(&120u32.to_le_bytes()); // MaxDurability + body.extend_from_slice(&8u32.to_le_bytes()); // Area + body.extend_from_slice(&1u32.to_le_bytes()); // Map + body.extend_from_slice(&4u32.to_le_bytes()); // BagFamily + + match messages::parse_server(messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE, &body).unwrap() + { + ServerPacket::ItemQueryResponse { entry, info } => { + assert_eq!(entry, 12_345); + assert_eq!( + info, + Some(Box::new(ItemInfo { + class: 2, + subclass: 7, + name: "Verified Blade of Testing".into(), + display_info_id: 6303, + quality: 4, + flags: 66, + buy_price: 4200, + sell_price: 1050, + inventory_type: 21, + allowable_class: -1, + allowable_race: 3, + item_level: 60, + required_level: 55, + required_skill: 164, + required_skill_rank: 225, + required_spell: 5209, + required_honor_rank: 3, + required_city_rank: 2, + required_rep_faction: 69, + required_rep_rank: 4, + max_count: 5, + stackable: 1, + container_slots: 0, + stats: vec![(4, 15), (7, 20), (5, -3)], + damages: vec![ + ItemDamage { + min: 12.0, + max: 22.0, + school: 0 + }, + ItemDamage { + min: 3.0, + max: 7.0, + school: 2 + }, + ], + dmg_min: 12.0, + dmg_max: 22.0, + dmg_type: 0, + armor: 15, + resistances: [0, 8, 0, 0, 12, 0], + delay_ms: 2800, + ammo_type: 2, + ranged_mod_range: 1.5, + spells: vec![ + ItemSpellEntry { + spell_id: 17_251, + trigger: 0, + charges: 0, + cooldown_ms: -1, + category: 4, + category_cooldown_ms: 60_000, + }, + ItemSpellEntry { + spell_id: 671, + trigger: 1, + charges: 0, + cooldown_ms: 0, + category: 0, + category_cooldown_ms: 0, + }, + ], + use_spell: Some(ItemUseSpell { + spell_id: 17_251, + cooldown_ms: -1, + category: 4, + category_cooldown_ms: 60_000, + }), + bonding: 2, + description: "A blade of pure verification.".into(), + page_text: 333, + language_id: 7, + page_material: 2, + start_quest: 444, + lock_id: 12, + material: 3, + sheath: 3, + random_property: 87, + block: 18, + item_set: 25, + max_durability: 120, + area: 8, + map: 1, + bag_family: 4, + })) + ); + } + other => panic!("item query hit, got {}", other.name()), + } +} + +/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, hit: a minimal consumable (a potion). The server zeroes +/// `subclass` for every `ITEM_CLASS_CONSUMABLE` row regardless of the DB value (VERIFIED vmangos +/// `ItemHandler.cpp:300`) — that's a server-side rule, so this fixture just carries the +/// already-zeroed wire byte; the parser itself has no class-conditional logic. What this exercises +/// instead is the "everything empty" path the weapon golden above only partly covers: `stats`, +/// `damages`, and 4 of 5 `spells` slots all read as real zeroed wire data that must land as **empty +/// `Vec`s**, not get pushed as garbage entries — while the one real ON_USE slot still resolves both +/// into `spells` and into the legacy `use_spell` accessor. +#[test] +fn item_query_response_consumable_all_zero_slots_are_empty_not_garbage() { + use benilla_protocol::messages::{ItemInfo, ItemSpellEntry, ItemUseSpell}; + + let mut body = 117u32.to_le_bytes().to_vec(); // entry + body.extend_from_slice(&0u32.to_le_bytes()); // class: consumable + body.extend_from_slice(&0u32.to_le_bytes()); // subclass: server-zeroed for consumables + body.extend_from_slice(b"Minor Healing Potion\0"); + body.extend_from_slice(&[0, 0, 0]); // name2..4: empty cstrings + body.extend_from_slice(&1712u32.to_le_bytes()); // displayInfoID + body.extend_from_slice(&1u32.to_le_bytes()); // quality: common + body.extend_from_slice(&0u32.to_le_bytes()); // flags + body.extend_from_slice(&40u32.to_le_bytes()); // buyPrice + body.extend_from_slice(&10u32.to_le_bytes()); // sellPrice + body.extend_from_slice(&0u32.to_le_bytes()); // inventoryType: none + body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableClass: all classes + body.extend_from_slice(&(-1i32).to_le_bytes()); // AllowableRace: all races + body.extend_from_slice(&1u32.to_le_bytes()); // ItemLevel + for _ in 0..6 { + body.extend_from_slice(&0u32.to_le_bytes()); // RequiredLevel .. RequiredCityRank + } + body.extend_from_slice(&0u32.to_le_bytes()); // RequiredReputationFaction + body.extend_from_slice(&0u32.to_le_bytes()); // RequiredReputationRank + body.extend_from_slice(&20u32.to_le_bytes()); // MaxCount + body.extend_from_slice(&20u32.to_le_bytes()); // Stackable + body.extend_from_slice(&0u32.to_le_bytes()); // ContainerSlots + for _ in 0..20 { + body.extend_from_slice(&0u32.to_le_bytes()); // 10x ItemStat { type, value }: all empty + } + for _ in 0..5 { + body.extend_from_slice(&0f32.to_le_bytes()); + body.extend_from_slice(&0f32.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // 5x Damage: all empty + } + body.extend_from_slice(&0u32.to_le_bytes()); // Armor + for _ in 0..6 { + body.extend_from_slice(&0u32.to_le_bytes()); // resistances: all empty + } + body.extend_from_slice(&0u32.to_le_bytes()); // Delay + body.extend_from_slice(&0u32.to_le_bytes()); // AmmoType + body.extend_from_slice(&0f32.to_le_bytes()); // RangedModRange + // Spell slot 0: the ON_USE heal; slots 1-4 the server's unresolved-slot sentinel. + body.extend_from_slice(&2024u32.to_le_bytes()); // SpellId + body.extend_from_slice(&0u32.to_le_bytes()); // SpellTrigger: ON_USE + body.extend_from_slice(&0u32.to_le_bytes()); // SpellCharges + body.extend_from_slice(&(-1i32).to_le_bytes()); // Cooldown: the spell's own + body.extend_from_slice(&0u32.to_le_bytes()); // Category + body.extend_from_slice(&(-1i32).to_le_bytes()); // CategoryCooldown: the spell's own + for _ in 0..4 { + body.extend_from_slice(&0u32.to_le_bytes()); // SpellId + body.extend_from_slice(&0u32.to_le_bytes()); // SpellTrigger + body.extend_from_slice(&0u32.to_le_bytes()); // SpellCharges + body.extend_from_slice(&(-1i32).to_le_bytes()); // Cooldown + body.extend_from_slice(&0u32.to_le_bytes()); // Category + body.extend_from_slice(&(-1i32).to_le_bytes()); // CategoryCooldown + } + body.extend_from_slice(&0u32.to_le_bytes()); // Bonding + body.push(0); // Description: empty cstring + for _ in 0..6 { + body.extend_from_slice(&0u32.to_le_bytes()); // PageText .. Material + } + body.extend_from_slice(&0u32.to_le_bytes()); // Sheath + body.extend_from_slice(&0u32.to_le_bytes()); // RandomProperty + body.extend_from_slice(&0u32.to_le_bytes()); // Block + body.extend_from_slice(&0u32.to_le_bytes()); // ItemSet + body.extend_from_slice(&0u32.to_le_bytes()); // MaxDurability: consumables have none + body.extend_from_slice(&0u32.to_le_bytes()); // Area + body.extend_from_slice(&0u32.to_le_bytes()); // Map + body.extend_from_slice(&0u32.to_le_bytes()); // BagFamily + + match messages::parse_server(messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE, &body).unwrap() + { + ServerPacket::ItemQueryResponse { entry, info } => { + assert_eq!(entry, 117); + assert_eq!( + info, + Some(Box::new(ItemInfo { + class: 0, + subclass: 0, + name: "Minor Healing Potion".into(), + display_info_id: 1712, + quality: 1, + flags: 0, + buy_price: 40, + sell_price: 10, + inventory_type: 0, + allowable_class: -1, + allowable_race: -1, + item_level: 1, + required_level: 0, + required_skill: 0, + required_skill_rank: 0, + required_spell: 0, + required_honor_rank: 0, + required_city_rank: 0, + required_rep_faction: 0, + required_rep_rank: 0, + max_count: 20, + stackable: 20, + container_slots: 0, + stats: Vec::new(), + damages: Vec::new(), + dmg_min: 0.0, + dmg_max: 0.0, + dmg_type: 0, + armor: 0, + resistances: [0; 6], + delay_ms: 0, + ammo_type: 0, + ranged_mod_range: 0.0, + spells: vec![ItemSpellEntry { + spell_id: 2024, + trigger: 0, + charges: 0, + cooldown_ms: -1, + category: 0, + category_cooldown_ms: -1, + }], + use_spell: Some(ItemUseSpell { + spell_id: 2024, + cooldown_ms: -1, + category: 0, + category_cooldown_ms: -1, + }), + bonding: 0, + description: String::new(), + page_text: 0, + language_id: 0, + page_material: 0, + start_quest: 0, + lock_id: 0, + material: 0, + sheath: 0, + random_property: 0, + block: 0, + item_set: 0, + max_durability: 0, + area: 0, + map: 0, + bag_family: 0, + })) + ); + } + other => panic!("item query hit, got {}", other.name()), + } +} + +/// `SMSG_ITEM_QUERY_SINGLE_RESPONSE`, miss: the lone entry with the top bit set (vmangos +/// undiscovered/unknown-entry branch) — the same shape as the creature miss. +#[test] +fn item_query_response_miss() { + let fail = messages::parse_server( + messages::opcode::SMSG_ITEM_QUERY_SINGLE_RESPONSE, + &hx("d2040080"), + ) + .unwrap(); + match decode(fail).pop().unwrap() { + SessionEvent::ItemTemplate { entry, info } => { + assert_eq!((entry, info), (1234, None)); + } + _ => panic!("item query miss event"), + } +} diff --git a/crates/benilla-protocol/tests/mail.rs b/crates/benilla-protocol/tests/mail.rs new file mode 100644 index 00000000..2a7a874d --- /dev/null +++ b/crates/benilla-protocol/tests/mail.rs @@ -0,0 +1,362 @@ +//! Oracle-free golden tests for the mail arc's protocol layer (decision 0544 phase P0): the +//! `CMSG_GET_MAIL_LIST`/`CMSG_SEND_MAIL`/take-money/take-item/mark-read/return/delete/ +//! `CMSG_ITEM_TEXT_QUERY` send verbs, the `SMSG_MAIL_LIST_RESULT` inbox page (all three sender +//! shapes + the always-present item block), `SMSG_SEND_MAIL_RESULT`'s three tail shapes, the +//! letter-body fetch reply, and the arrival pair (`SMSG_RECEIVED_MAIL` / `MSG_QUERY_NEXT_MAIL_TIME`). +//! Same idioms as `tests/trainer.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies +//! round-tripped through `parse_server`, and a `decode()` bridge assertion. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{ + self, mail_action, mail_error, mail_message_type, MailAttachment, +}; +use benilla_protocol::ServerPacket; + +fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +const MAILBOX: u64 = 0x00F1_1000_0000_0042; + +#[test] +fn mail_send_bodies_golden() { + // CMSG_GET_MAIL_LIST: one full mailbox guid. + assert_eq!( + messages::get_mail_list(MAILBOX), + hx("420000000010f100"), + "CMSG_GET_MAIL_LIST body" + ); + + // CMSG_SEND_MAIL: u64 mailbox, cstr receiver, cstr subject, cstr body, u32 stationery, u32 + // package, u64 itemGuid, u32 money, u32 COD, then the 9-byte zero tail (u64 0 + u8 0). + assert_eq!( + messages::send_mail( + MAILBOX, + "Bob", + "Hello", + "Here is your item.", + 41, + 0, + 0x0001_0000_0000_0099, + 12_345, + 500, + ), + hx(concat!( + "420000000010f100", // mailbox + "426f6200", // "Bob\0" + "48656c6c6f00", // "Hello\0" + "4865726520697320796f7572206974656d2e00", // "Here is your item.\0" + "29000000", // stationery 41 + "00000000", // package 0 + "9900000000000100", // item guid + "39300000", // money 12345 + "f4010000", // cod 500 + "0000000000000000", // the 9-byte zero tail: u64 0 + "00", // + u8 0 + )), + "CMSG_SEND_MAIL body" + ); + + // The five `u64 mailbox, u32 mailId` CMSGs share one shape. + let golden = hx("420000000010f1004d000000"); // mailbox + mailId 77 + assert_eq!(messages::mail_take_money(MAILBOX, 77), golden); + assert_eq!(messages::mail_take_item(MAILBOX, 77), golden); + assert_eq!(messages::mail_mark_as_read(MAILBOX, 77), golden); + assert_eq!(messages::mail_return_to_sender(MAILBOX, 77), golden); + assert_eq!(messages::mail_delete(MAILBOX, 77), golden); + + // CMSG_MAIL_CREATE_TEXT_ITEM: the shared shape + u32 mailTemplateId(0) (vmangos + // `MailCreateTextItem::ReadFromWorldPacket`). + assert_eq!( + messages::mail_create_text_item(MAILBOX, 77), + hx("420000000010f1004d00000000000000"), + "CMSG_MAIL_CREATE_TEXT_ITEM body" + ); + + // CMSG_ITEM_TEXT_QUERY: u32 textId, u32 mailId, u32 unk(0). + assert_eq!( + messages::item_text_query(1234, 77), + hx("d20400004d00000000000000"), + "CMSG_ITEM_TEXT_QUERY body" + ); +} + +/// Append one 8-byte-fixed item block (the tail every `SMSG_MAIL_LIST_RESULT` row always carries, +/// zeroed when the mail has no attachment): entry, permEnchant, randomPropId, suffixFactor u32 ×4, +/// stackCount u8, spellCharges/durabilityMax/durabilityCur u32 ×3. +#[allow(clippy::too_many_arguments)] +fn push_item_block( + body: &mut Vec, + entry: u32, + perm_enchant: u32, + random_prop_id: u32, + suffix_factor: u32, + count: u8, + charges: u32, + durability_max: u32, + durability: u32, +) { + body.extend_from_slice(&entry.to_le_bytes()); + body.extend_from_slice(&perm_enchant.to_le_bytes()); + body.extend_from_slice(&random_prop_id.to_le_bytes()); + body.extend_from_slice(&suffix_factor.to_le_bytes()); + body.push(count); + body.extend_from_slice(&charges.to_le_bytes()); + body.extend_from_slice(&durability_max.to_le_bytes()); + body.extend_from_slice(&durability.to_le_bytes()); +} + +#[test] +fn mail_list_result_wire() { + // SMSG_MAIL_LIST_RESULT: u8 count, then per row: u32 messageId, u8 messageType, the sender + // branch keyed by messageType, cstr subject, u32 itemTextId, u32 package(dropped), u32 + // stationery, the item block (ALWAYS present), u32 money, u32 COD, u32 checked, f32 + // expireDays, u32 mailTemplateId. Three rows exercise all three sender shapes. + let mut body = vec![3u8]; // count + + // Row 1 — MAIL_NORMAL: a player sender (guid), an attached item, money + COD + checked flags, + // a fractional expire_days. + body.extend_from_slice(&1u32.to_le_bytes()); // messageId + body.push(mail_message_type::NORMAL); + body.extend_from_slice(&0x0000_0001_0000_00AAu64.to_le_bytes()); // sender guid + body.extend_from_slice(b"Old Friend\0"); + body.extend_from_slice(&555u32.to_le_bytes()); // itemTextId + body.extend_from_slice(&0u32.to_le_bytes()); // package (dropped) + body.extend_from_slice(&1u32.to_le_bytes()); // stationery + push_item_block(&mut body, 6948, 0, 0, 0, 3, 0, 100, 80); + body.extend_from_slice(&10_000u32.to_le_bytes()); // money + body.extend_from_slice(&2_500u32.to_le_bytes()); // COD + body.extend_from_slice(&0x9u32.to_le_bytes()); // checked: READ|COD_PAYMENT + body.extend_from_slice(&2.5f32.to_le_bytes()); // expire_days + body.extend_from_slice(&0u32.to_le_bytes()); // mailTemplateId + + // Row 2 — MAIL_AUCTION: a u32 sender id, no item (all-zero item block). + body.extend_from_slice(&2u32.to_le_bytes()); + body.push(mail_message_type::AUCTION); + body.extend_from_slice(&42u32.to_le_bytes()); // sender id (auction id) + body.extend_from_slice(b"Auction won\0"); + body.extend_from_slice(&0u32.to_le_bytes()); // itemTextId + body.extend_from_slice(&0u32.to_le_bytes()); // package + body.extend_from_slice(&0u32.to_le_bytes()); // stationery + push_item_block(&mut body, 0, 0, 0, 0, 0, 0, 0, 0); + body.extend_from_slice(&500u32.to_le_bytes()); // money + body.extend_from_slice(&0u32.to_le_bytes()); // COD + body.extend_from_slice(&0x1u32.to_le_bytes()); // checked: READ + body.extend_from_slice(&1.0f32.to_le_bytes()); // expire_days + body.extend_from_slice(&0u32.to_le_bytes()); + + // Row 3 — MAIL_ITEM: NO sender bytes at all. + body.extend_from_slice(&3u32.to_le_bytes()); + body.push(mail_message_type::ITEM); + body.extend_from_slice(b"Welcome\0"); + body.extend_from_slice(&0u32.to_le_bytes()); // itemTextId + body.extend_from_slice(&0u32.to_le_bytes()); // package + body.extend_from_slice(&41u32.to_le_bytes()); // stationery + push_item_block(&mut body, 0, 0, 0, 0, 0, 0, 0, 0); + body.extend_from_slice(&0u32.to_le_bytes()); // money + body.extend_from_slice(&0u32.to_le_bytes()); // COD + body.extend_from_slice(&0u32.to_le_bytes()); // checked + body.extend_from_slice(&30.0f32.to_le_bytes()); // expire_days + body.extend_from_slice(&8383u32.to_le_bytes()); // mailTemplateId + + let packet = messages::parse_server(messages::opcode::SMSG_MAIL_LIST_RESULT, &body).unwrap(); + match &packet { + ServerPacket::MailList { mails } => { + assert_eq!(mails.len(), 3); + + let m = &mails[0]; + assert_eq!(m.message_id, 1); + assert_eq!(m.message_type, mail_message_type::NORMAL); + assert_eq!(m.sender_guid, Some(0x0000_0001_0000_00AA)); + assert_eq!(m.sender_id, None); + assert_eq!(m.subject, "Old Friend"); + assert_eq!(m.item_text_id, 555); + assert_eq!(m.stationery, 1); + assert_eq!( + m.item, + Some(MailAttachment { + entry: 6948, + perm_enchant: 0, + random_prop_id: 0, + suffix_factor: 0, + count: 3, + charges: 0, + durability_max: 100, + durability: 80, + }) + ); + assert_eq!(m.money, 10_000); + assert_eq!(m.cod, 2_500); + assert_eq!(m.checked, 0x9); + assert_eq!(m.expire_days, 2.5); + assert_eq!(m.mail_template_id, 0); + + let m = &mails[1]; + assert_eq!(m.message_id, 2); + assert_eq!(m.message_type, mail_message_type::AUCTION); + assert_eq!(m.sender_guid, None); + assert_eq!(m.sender_id, Some(42)); + assert_eq!(m.subject, "Auction won"); + assert_eq!(m.item, None, "all-zero item block folds to None"); + assert_eq!(m.money, 500); + assert_eq!(m.checked, 0x1); + assert_eq!(m.expire_days, 1.0); + + let m = &mails[2]; + assert_eq!(m.message_id, 3); + assert_eq!(m.message_type, mail_message_type::ITEM); + assert_eq!(m.sender_guid, None); + assert_eq!(m.sender_id, None, "MAIL_ITEM reads no sender bytes at all"); + assert_eq!(m.subject, "Welcome"); + assert_eq!(m.item, None); + assert_eq!(m.mail_template_id, 8383); + } + other => panic!("mail list, got {}", other.name()), + } + + // The decode() bridge carries the wire rows through unchanged. + match decode(packet).pop().unwrap() { + SessionEvent::MailList { mails } => assert_eq!(mails.len(), 3), + other => panic!("mail list event, got {other:?}"), + } +} + +#[test] +fn send_mail_result_wire() { + // Plain shape: action SEND, error OK — neither conditional tail rides. + let mut plain = 1u32.to_le_bytes().to_vec(); + plain.extend_from_slice(&mail_action::SEND.to_le_bytes()); + plain.extend_from_slice(&mail_error::OK.to_le_bytes()); + match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &plain).unwrap() { + ServerPacket::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } => { + assert_eq!( + (mail_id, action, error), + (1, mail_action::SEND, mail_error::OK) + ); + assert_eq!(equip_error, None); + assert_eq!(item, None); + } + other => panic!("send mail result (plain), got {}", other.name()), + } + // The decode() bridge, asserted on this shape. + let packet = messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &plain).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } => { + assert_eq!( + (mail_id, action, error), + (1, mail_action::SEND, mail_error::OK) + ); + assert_eq!((equip_error, item), (None, None)); + } + other => panic!("send mail result event, got {other:?}"), + } + + // Equip-error shape: error == MAIL_ERR_EQUIP_ERROR carries one trailing u32. + let mut equip = 2u32.to_le_bytes().to_vec(); + equip.extend_from_slice(&mail_action::ITEM_TAKEN.to_le_bytes()); + equip.extend_from_slice(&mail_error::EQUIP_ERROR.to_le_bytes()); + equip.extend_from_slice(&1234u32.to_le_bytes()); // the equip-error code + match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &equip).unwrap() { + ServerPacket::SendMailResult { + mail_id, + error, + equip_error, + item, + .. + } => { + assert_eq!((mail_id, error), (2, mail_error::EQUIP_ERROR)); + assert_eq!(equip_error, Some(1234)); + assert_eq!(item, None); + } + other => panic!("send mail result (equip error), got {}", other.name()), + } + + // Item-taken shape: action == MAIL_ITEM_TAKEN and error == OK carries entry + count. + let mut taken = 3u32.to_le_bytes().to_vec(); + taken.extend_from_slice(&mail_action::ITEM_TAKEN.to_le_bytes()); + taken.extend_from_slice(&mail_error::OK.to_le_bytes()); + taken.extend_from_slice(&6948u32.to_le_bytes()); // item entry + taken.extend_from_slice(&3u32.to_le_bytes()); // item count + match messages::parse_server(messages::opcode::SMSG_SEND_MAIL_RESULT, &taken).unwrap() { + ServerPacket::SendMailResult { + mail_id, + action, + error, + equip_error, + item, + } => { + assert_eq!( + (mail_id, action, error), + (3, mail_action::ITEM_TAKEN, mail_error::OK) + ); + assert_eq!(equip_error, None); + assert_eq!(item, Some((6948, 3))); + } + other => panic!("send mail result (item taken), got {}", other.name()), + } +} + +#[test] +fn item_text_query_response_wire() { + // SMSG_ITEM_TEXT_QUERY_RESPONSE: u32 textId, cstr text. + let mut body = 1234u32.to_le_bytes().to_vec(); + body.extend_from_slice(b"This is the letter body.\0"); + match messages::parse_server(messages::opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE, &body).unwrap() { + ServerPacket::ItemTextQueryResponse { text_id, text } => { + assert_eq!(text_id, 1234); + assert_eq!(text, "This is the letter body."); + } + other => panic!("item text query response, got {}", other.name()), + } + let packet = + messages::parse_server(messages::opcode::SMSG_ITEM_TEXT_QUERY_RESPONSE, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::MailItemText { text_id, text } => { + assert_eq!(text_id, 1234); + assert_eq!(text, "This is the letter body."); + } + other => panic!("mail item text event, got {other:?}"), + } +} + +#[test] +fn received_mail_wire() { + // SMSG_RECEIVED_MAIL: one u32, always 0 on vmangos. + let body = 0u32.to_le_bytes(); + match messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap() { + ServerPacket::ReceivedMail => {} + other => panic!("received mail, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_RECEIVED_MAIL, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::ReceivedMail => {} + other => panic!("received mail event, got {other:?}"), + } +} + +#[test] +fn query_next_mail_time_wire() { + // MSG_QUERY_NEXT_MAIL_TIME's reply: one f32 — 0.0 unread waiting, -86400.0 none. + for seconds in [0.0f32, -86400.0f32] { + let body = seconds.to_le_bytes(); + match messages::parse_server(messages::opcode::MSG_QUERY_NEXT_MAIL_TIME, &body).unwrap() { + ServerPacket::NextMailTime { seconds: got } => assert_eq!(got, seconds), + other => panic!("query next mail time, got {}", other.name()), + } + } +} diff --git a/crates/benilla-protocol/tests/movement.rs b/crates/benilla-protocol/tests/movement.rs new file mode 100644 index 00000000..0b49a3bd --- /dev/null +++ b/crates/benilla-protocol/tests/movement.rs @@ -0,0 +1,438 @@ +//! Player-movement relay + teleport-ack wire tests: the `[packed guid][MovementInfo]` relay shape +//! that rebroadcasts other players' `MSG_MOVE_*` locomotion (including the jump ballistic tail), and +//! `MSG_MOVE_TELEPORT_ACK`'s server-form pose. Split out of the former `tests/messages.rs` — see +//! `tests/common` for the shared fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, MovementInfo, TransportPose}; +use benilla_protocol::wire::{write_packed_guid, Vector3d}; +use benilla_protocol::ServerPacket; +use common::hx; + +#[test] +fn movement_relay_decodes_to_unit_move() { + // A relayed player move: [packed mover guid][MovementInfo]. Packed guid 0xAA = mask 0x01 + byte + // 0xAA; the MovementInfo body is the same 28-byte FORWARD info pinned in `client_bodies_golden` + // (flags 0x1, time, pos, orientation 1.25, fall_time). The server rebroadcasts with the mover's + // opcode — here MSG_MOVE_START_FORWARD — which is how another player's walking reaches us. + let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"); + match messages::parse_server(messages::opcode::MSG_MOVE_START_FORWARD, &body).unwrap() { + ServerPacket::PlayerMove { + guid, + opcode, + flags, + position, + orientation, + jump, + .. + } => { + assert_eq!(guid, 0xAA); + assert_eq!(opcode, messages::opcode::MSG_MOVE_START_FORWARD); + assert_eq!(flags, 0x1); + assert_eq!( + (position.x, position.y, position.z), + (-8949.95, -132.493, 83.5312) + ); + assert_eq!(orientation, 1.25); + assert_eq!(jump, None, "a non-jumping relay carries no jump tail"); + } + p => panic!("expected PlayerMove, got {}", p.name()), + } + // The whole relay family routes through the same body shape — spot-check a couple more opcodes and + // the fan-out into a SessionEvent the ECS consumes. + for op in [ + messages::opcode::MSG_MOVE_HEARTBEAT, + messages::opcode::MSG_MOVE_SET_FACING, + messages::opcode::MSG_MOVE_STOP, + ] { + let events = decode(messages::parse_server(op, &body).unwrap()); + assert!( + matches!( + events.as_slice(), + [SessionEvent::UnitMove { + guid: 0xAA, + orientation, + flags: 0x1, + .. + }] if *orientation == 1.25 + ), + "opcode {op:#06x} should decode to one UnitMove, got {events:?}" + ); + } +} + +#[test] +fn jump_relay_round_trips_the_ballistic_tail() { + // A jumping MovementInfo (JUMPING 0x2000 | FORWARD) carries the ballistic launch tail. Round-trip it + // through write → parse to pin the wire layout: `fall_time` is a u32 (ms), and the jump tail order is + // `zspeed, cosAngle, sinAngle, xyspeed` — cos *before* sin (VERIFIED vmangos `MovementInfo::Read`). + let mi = MovementInfo { + flags: 0x2000 | 0x1, + timestamp: 0x0102_0304, + position: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + transport: None, // not on a transport — no transport tail is written + pitch: 0.0, // not swimming — no pitch tail is written + fall_time: 250, + jump: Some(messages::JumpInfo { + zspeed: 7.955_547, + cos_angle: 0.25, + sin_angle: 0.75, + xy_speed: 7.0, + }), + }; + let mut body = hx("01aa"); // [packed guid 0xAA][MovementInfo] — the relay shape. + body.extend_from_slice(&messages::movement(&mi)); + match messages::parse_server(messages::opcode::MSG_MOVE_JUMP, &body).unwrap() { + ServerPacket::PlayerMove { + flags, + fall_time, + jump, + .. + } => { + assert_eq!(flags, 0x2001); + assert_eq!(fall_time, 250, "fall_time is a u32 ms count, not an f32"); + let j = jump.expect("a JUMPING relay carries the ballistic tail"); + assert_eq!(j.zspeed, 7.955_547); + assert_eq!( + j.cos_angle, 0.25, + "cosAngle is the 2nd jump float (before sin)" + ); + assert_eq!(j.sin_angle, 0.75, "sinAngle is the 3rd jump float"); + assert_eq!(j.xy_speed, 7.0); + } + p => panic!("expected PlayerMove, got {}", p.name()), + } +} + +/// A relayed rider on a transport: `MOVEFLAG_ON_TRANSPORT | MOVEFLAG_SWIMMING | MOVEFLAG_JUMPING` all +/// set, so the transport pose, swim pitch, `fall_time`, and jump tail all ride the same packet — the +/// exact regression the old discard comment warned about (`messages/movement.rs`'s prior "the +/// transport ... tail is parsed to stay aligned but discarded"): a wrong transport-tail length would +/// misalign every tail after it. `write()` never serializes a transport tail (benilla doesn't set the +/// flag outbound — riding is decision 0438 phase 2), so this fixture is hand-built to the exact byte +/// order `read_movement_info` implements. +#[test] +fn movement_relay_surfaces_transport_pose_and_keeps_the_rest_aligned() { + // An elevator (HIGH_TRANSPORT, 0xF120): template entry 900, DB spawn low guid 4242 — the standard + // entry layout (guid.rs `entry()`). + let transport_guid: u64 = 4242 | (900u64 << 24) | (0xF120u64 << 48); + + let mut body = Vec::new(); + write_packed_guid(0xAA, &mut body).unwrap(); // mover guid + let flags: u32 = 0x0200_0000 | 0x20_0000 | 0x2000 | 0x1; // ON_TRANSPORT | SWIMMING | JUMPING | FORWARD + body.extend_from_slice(&flags.to_le_bytes()); + body.extend_from_slice(&0x1122_3344u32.to_le_bytes()); // timestamp + body.extend_from_slice(&10.0f32.to_le_bytes()); // position.x + body.extend_from_slice(&20.0f32.to_le_bytes()); // position.y + body.extend_from_slice(&30.0f32.to_le_bytes()); // position.z + body.extend_from_slice(&0.75f32.to_le_bytes()); // orientation + body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid + body.extend_from_slice(&1.0f32.to_le_bytes()); // local x + body.extend_from_slice(&2.0f32.to_le_bytes()); // local y + body.extend_from_slice(&3.0f32.to_le_bytes()); // local z + body.extend_from_slice(&0.5f32.to_le_bytes()); // local o + body.extend_from_slice(&(-0.3f32).to_le_bytes()); // SWIMMING tail: pitch + body.extend_from_slice(&999u32.to_le_bytes()); // fall_time (u32 — must land right after pitch) + body.extend_from_slice(&7.9f32.to_le_bytes()); // JUMPING tail: zspeed + body.extend_from_slice(&0.6f32.to_le_bytes()); // cos_angle + body.extend_from_slice(&0.8f32.to_le_bytes()); // sin_angle + body.extend_from_slice(&5.0f32.to_le_bytes()); // xy_speed + + let want_transport = TransportPose { + guid: transport_guid, + pos: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + }; + + match messages::parse_server(messages::opcode::MSG_MOVE_HEARTBEAT, &body).unwrap() { + ServerPacket::PlayerMove { + orientation, + transport, + fall_time, + jump, + .. + } => { + assert_eq!( + orientation, 0.75, + "position/orientation land before the tail, unaffected" + ); + assert_eq!( + transport, + Some(want_transport), + "the ON_TRANSPORT tail surfaces as the rider's local pose" + ); + assert_eq!( + fall_time, 999, + "fall_time still lands right after the transport + swim-pitch tails" + ); + let j = + jump.expect("the jump tail still parses after the transport/pitch/fall_time run"); + assert_eq!(j.zspeed, 7.9); + assert_eq!(j.cos_angle, 0.6); + assert_eq!(j.sin_angle, 0.8); + assert_eq!(j.xy_speed, 5.0); + } + p => panic!("expected PlayerMove, got {}", p.name()), + } + + // The same fixture through the SessionEvent fan-out: UnitMove carries the same transport pose and + // the same post-tail fields. + let events = + decode(messages::parse_server(messages::opcode::MSG_MOVE_HEARTBEAT, &body).unwrap()); + match events.as_slice() { + [SessionEvent::UnitMove { + transport, + fall_time, + jump, + .. + }] => { + assert_eq!(transport, &Some(want_transport)); + assert_eq!(*fall_time, 999); + assert!(jump.is_some()); + } + other => panic!("expected one UnitMove, got {other:?}"), + } +} + +#[test] +fn teleport_ack_parses_pose() { + // MSG_MOVE_TELEPORT_ACK (server→client form): [packed guid][counter u32][MovementInfo]. Exercises + // the refactored read_movement_info (non-transport path) end to end. + let body = hx("01aa070000000100000004030201cdd70bc6357e04c3f90fa7420000a03f00000000"); + match messages::parse_server(messages::opcode::MSG_MOVE_TELEPORT_ACK, &body).unwrap() { + ServerPacket::Teleport { + guid, + counter, + position, + orientation, + } => { + assert_eq!(guid, 0xAA); + assert_eq!(counter, 7); + assert_eq!( + (position.x, position.y, position.z), + (-8949.95, -132.493, 83.5312) + ); + assert_eq!(orientation, 1.25); + } + p => panic!("expected Teleport, got {}", p.name()), + } +} + +/// The force-speed-change family, byte-exact both directions (a new wire body never lands without +/// a golden — method): the SMSG body is `[packed guid][u32 counter][f32 speed]` (vmangos +/// `SendSpeedChangeToController`, 5875 branch), and the ACK body is `[u64 FULL guid][u32 counter] +/// [MovementInfo][f32 speed]` (`MoveSpeedAck::ReadFromWorldPacket` — a plain `ObjectGuid` +/// extraction is a raw u64, `ObjectGuid.cpp:180`, NOT packed like the SMSG's). +#[test] +fn force_speed_change_parses_and_ack_body_golden() { + use benilla_protocol::messages::SpeedKind; + + // SMSG_FORCE_RUN_SPEED_CHANGE: packed guid 8 (mask 0x01, one byte), counter 7, speed 14.0. + let body = hx("01080700000000006041"); + let packet = + messages::parse_server(messages::opcode::SMSG_FORCE_RUN_SPEED_CHANGE, &body).unwrap(); + match &packet { + ServerPacket::ForceSpeedChange { + guid: 8, + kind: SpeedKind::Run, + counter: 7, + speed, + } => assert_eq!(*speed, 14.0), + _ => panic!("expected ForceSpeedChange (run, guid 8, counter 7)"), + } + match decode(packet).as_slice() { + [SessionEvent::ForceSpeedChange { + guid: 8, + kind: SpeedKind::Run, + counter: 7, + speed, + }] => assert_eq!(*speed, 14.0), + other => panic!("expected one ForceSpeedChange event, got {other:?}"), + } + // Every kind maps to its own SMSG opcode (the walk/swim-back/turn-rate trio lives in the + // 730-735 block, not the 226-231 run). + for (op, kind) in [ + ( + messages::opcode::SMSG_FORCE_WALK_SPEED_CHANGE, + SpeedKind::Walk, + ), + ( + messages::opcode::SMSG_FORCE_SWIM_SPEED_CHANGE, + SpeedKind::Swim, + ), + ( + messages::opcode::SMSG_FORCE_TURN_RATE_CHANGE, + SpeedKind::TurnRate, + ), + ] { + match messages::parse_server(op, &body).unwrap() { + ServerPacket::ForceSpeedChange { kind: k, .. } => assert_eq!(k, kind), + _ => panic!("expected ForceSpeedChange for opcode {op:#06x}"), + } + } + + // The ACK body golden: full 8-byte guid, counter, a stationary MovementInfo (flags 0 => no + // conditional tails), then the echoed speed. + let info = MovementInfo { + flags: 0, + timestamp: 12345, + position: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + transport: None, + pitch: 0.0, + fall_time: 42, + jump: None, + }; + assert_eq!( + messages::force_speed_ack(8, 7, &info, 14.0), + hx("08000000000000000700000000000000393000000000803f00000040000040400000003f2a00000000006041"), + "CMSG_FORCE_*_SPEED_CHANGE_ACK body" + ); + // The ack opcode per kind — the run ack is 227, the walk ack lives in the high block (731). + assert_eq!(SpeedKind::Run.ack_opcode(), 0x00E3); + assert_eq!(SpeedKind::Walk.ack_opcode(), 0x02DB); + assert_eq!(SpeedKind::TurnRate.ack_opcode(), 0x02DF); +} + +/// The OBSERVER speed legs, byte-exact (decision 0441 — how an observed unit's mounted speed +/// reaches us; no counter, no ack on either): `SMSG_SPLINE_SET_*_SPEED` is `[packed guid] +/// [f32 speed]` (vmangos `SendSpeedChangeToAll` + the mid-spline observer branch), and +/// `MSG_MOVE_SET_*_SPEED` is `[packed guid][MovementInfo][f32 speed]` +/// (`SendSpeedChangeToObservers`, finalized branch) — a speed change AND a fresh pose, decoding +/// to a `UnitMove` + `SpeedChanged` pair. +#[test] +fn observer_speed_legs_parse_golden() { + use benilla_protocol::messages::SpeedKind; + + // SMSG_SPLINE_SET_RUN_SPEED: packed guid 0xAA (mask 0x01, one byte), speed 14.0. + let body = hx("01aa00006041"); + let packet = + messages::parse_server(messages::opcode::SMSG_SPLINE_SET_RUN_SPEED, &body).unwrap(); + match &packet { + ServerPacket::SplineSpeedChange { + guid: 0xAA, + kind: SpeedKind::Run, + speed, + } => assert_eq!(*speed, 14.0), + p => panic!( + "expected SplineSpeedChange (run, guid 0xAA), got {}", + p.name() + ), + } + match decode(packet).as_slice() { + [SessionEvent::SpeedChanged { + guid: 0xAA, + kind: SpeedKind::Run, + speed, + }] => assert_eq!(*speed, 14.0), + other => panic!("expected one SpeedChanged event, got {other:?}"), + } + // Each kind maps to its own opcode — spot-check the walk (769) and swim-back (770) slots, + // whose order differs from the FORCE family's. + for (op, kind) in [ + ( + messages::opcode::SMSG_SPLINE_SET_WALK_SPEED, + SpeedKind::Walk, + ), + ( + messages::opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED, + SpeedKind::SwimBack, + ), + ] { + match messages::parse_server(op, &body).unwrap() { + ServerPacket::SplineSpeedChange { kind: k, .. } => assert_eq!(k, kind), + p => panic!("expected SplineSpeedChange for {op:#06x}, got {}", p.name()), + } + } + + // MSG_MOVE_SET_RUN_SPEED: [packed guid 0xAA][the shared 28-byte FORWARD MovementInfo golden] + // [speed 14.0] — decodes to the pose + the speed, in one drain. + let body = hx("01aa0100000004030201cdd70bc6357e04c3f90fa7420000a03f0000000000006041"); + let packet = messages::parse_server(messages::opcode::MSG_MOVE_SET_RUN_SPEED, &body).unwrap(); + match &packet { + ServerPacket::MoveSetSpeed { + guid: 0xAA, + kind: SpeedKind::Run, + flags: 0x1, + orientation, + speed, + .. + } => { + assert_eq!(*orientation, 1.25); + assert_eq!(*speed, 14.0); + } + p => panic!("expected MoveSetSpeed (run, guid 0xAA), got {}", p.name()), + } + match decode(packet).as_slice() { + [SessionEvent::UnitMove { + guid: 0xAA, + flags: 0x1, + position, + .. + }, SessionEvent::SpeedChanged { + guid: 0xAA, + kind: SpeedKind::Run, + speed, + }] => { + assert_eq!(position, &[-8949.95, -132.493, 83.5312]); + assert_eq!(*speed, 14.0); + } + other => panic!("expected UnitMove + SpeedChanged, got {other:?}"), + } + + // SMSG_MOUNTRESULT / SMSG_DISMOUNTRESULT: one u32 code (OK = 10 / 3). + match decode( + messages::parse_server(messages::opcode::SMSG_MOUNTRESULT, &hx("0a000000")).unwrap(), + ) + .as_slice() + { + [SessionEvent::MountResult { + mount: true, + code: 10, + }] => {} + other => panic!("expected MountResult(mount, 10), got {other:?}"), + } + match decode( + messages::parse_server(messages::opcode::SMSG_DISMOUNTRESULT, &hx("03000000")).unwrap(), + ) + .as_slice() + { + [SessionEvent::MountResult { + mount: false, + code: 3, + }] => {} + other => panic!("expected MountResult(dismount, 3), got {other:?}"), + } + + // SMSG_MOUNTSPECIAL_ANIM: one raw u64 guid, NOT packed (vmangos + // `HandleMountSpecialAnimOpcode` writes `data << GetObjectGuid()` — full 8 bytes). + match decode( + messages::parse_server( + messages::opcode::SMSG_MOUNTSPECIAL_ANIM, + &hx("0400000000000040"), + ) + .unwrap(), + ) + .as_slice() + { + [SessionEvent::MountSpecial { + guid: 0x4000_0000_0000_0004, + }] => {} + other => panic!("expected MountSpecial(player 4), got {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/simple_packets.rs b/crates/benilla-protocol/tests/simple_packets.rs new file mode 100644 index 00000000..0095a042 --- /dev/null +++ b/crates/benilla-protocol/tests/simple_packets.rs @@ -0,0 +1,414 @@ +//! Simple bare-scalar server packets — bodies too trivial to need golden hex from a validated +//! implementation: auth challenge/new-world/login-timespeed, the server-pushed sound trio, and +//! weather. Split out of the former `tests/messages.rs` — see `tests/common` for the shared +//! fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; +use common::hx; + +#[test] +fn simple_server_bodies_parse() { + // SMSG_AUTH_CHALLENGE: just a u32 seed. + match messages::parse_server(messages::opcode::SMSG_AUTH_CHALLENGE, &hx("efbeadde")).unwrap() { + ServerPacket::AuthChallenge { server_seed } => assert_eq!(server_seed, 0xDEAD_BEEF), + _ => panic!("auth challenge"), + } + // SMSG_NEW_WORLD: map u32=1, position (1,2,3), orientation 0.5. + let nw = hx("010000000000803f0000004000004040000000bf"); + match messages::parse_server(messages::opcode::SMSG_NEW_WORLD, &nw).unwrap() { + ServerPacket::NewWorld { + map, + position, + orientation, + } => { + assert_eq!(map, 1); + assert_eq!((position.x, position.y, position.z), (1.0, 2.0, 3.0)); + assert_eq!(orientation, -0.5); + } + _ => panic!("new world"), + } + // SMSG_LOGIN_SETTIMESPEED: packed DateTime (LSB up: minute:6, hour:5, weekday:3, day:6, + // month:4, year:5) with 14:30 on year 26 / month 6 / day 17; timescale. The day serial + // flattens the date with the packed convention's 31-day months: 26·372 + 6·31 + 17 = 9875. + let datetime: u32 = (26 << 24) | (6 << 20) | (17 << 14) | (14 << 6) | 30; + let mut ts = datetime.to_le_bytes().to_vec(); + ts.extend_from_slice(&0.0166_6667f32.to_le_bytes()); + match messages::parse_server(messages::opcode::SMSG_LOGIN_SETTIMESPEED, &ts).unwrap() { + ServerPacket::TimeSpeed { + hours, + minutes, + day_serial, + timescale, + } => { + assert_eq!((hours, minutes), (14, 30)); + assert_eq!(day_serial, 26 * 372 + 6 * 31 + 17); + assert_eq!(timescale, 0.0166_6667); + } + _ => panic!("settimespeed"), + } +} + +/// The cinematic trigger (opcode 250, body a bare `CinematicSequences.dbc` id u32 per the vmangos +/// `SendCinematicStart` sender) parses and decodes to the event the Net drain must ack — and the +/// char-delete result (opcode 60, one result byte) parses. +#[test] +fn cinematic_and_char_delete_parse_and_decode() { + // SMSG_TRIGGER_CINEMATIC: u32 cinematic id (41 = the dwarf intro, live-captured 2026-07-07). + let body = 41u32.to_le_bytes(); + let p = messages::parse_server(messages::opcode::SMSG_TRIGGER_CINEMATIC, &body).unwrap(); + assert!(matches!( + p, + ServerPacket::TriggerCinematic { cinematic_id: 41 } + )); + assert!(matches!( + decode(p)[..], + [SessionEvent::CinematicTriggered { cinematic_id: 41 }] + )); + + // SMSG_CHAR_DELETE: one result byte (0x39 = success, live-verified). + let p = messages::parse_server(messages::opcode::SMSG_CHAR_DELETE, &[0x39]).unwrap(); + assert!(matches!(p, ServerPacket::CharDelete { result: 0x39 })); +} + +/// The server-pushed sound trio (opcodes vmangos `Opcodes_1_12_1.h` 631/632/722; bodies are bare +/// LE scalars per the vmangos senders): each parses to its packet and decodes to one event. +#[test] +fn server_sound_trio_parse_and_decode() { + // SMSG_PLAY_SOUND: u32 soundId. + let body = 8595u32.to_le_bytes(); + let p = messages::parse_server(messages::opcode::SMSG_PLAY_SOUND, &body).unwrap(); + assert!(matches!(p, ServerPacket::PlaySound { sound_id: 8595 })); + assert!(matches!( + decode(p)[..], + [SessionEvent::PlaySound { sound_id: 8595 }] + )); + + // SMSG_PLAY_MUSIC: u32 musicId. + let body = 2523u32.to_le_bytes(); + let p = messages::parse_server(messages::opcode::SMSG_PLAY_MUSIC, &body).unwrap(); + assert!(matches!( + decode(p)[..], + [SessionEvent::PlayMusic { music_id: 2523 }] + )); + + // SMSG_PLAY_OBJECT_SOUND: u32 soundId + u64 guid (the fishing-bobber splash, vmangos + // GameObject.cpp:373 sends exactly soundId 3355). + let mut body = 3355u32.to_le_bytes().to_vec(); + body.extend_from_slice(&0xF110_0000_0000_002Au64.to_le_bytes()); + let p = messages::parse_server(messages::opcode::SMSG_PLAY_OBJECT_SOUND, &body).unwrap(); + assert!(matches!( + decode(p)[..], + [SessionEvent::PlayObjectSound { + sound_id: 3355, + guid: 0xF110_0000_0000_002A, + }] + )); +} + +/// SMSG_WEATHER (opcode vmangos 756; body `u32 type, f32 grade, u32 soundId, u8 instant` per +/// `Weather::SendWeatherForPlayersInZone`, 1.12 branch): parses and decodes to one event, with +/// the soundId being a real SoundEntries loop kit (8534 = RainMedium). +#[test] +fn weather_parses_and_decodes() { + let mut body = 1u32.to_le_bytes().to_vec(); // WEATHER_TYPE_RAIN + body.extend_from_slice(&0.7f32.to_le_bytes()); + body.extend_from_slice(&8534u32.to_le_bytes()); + body.push(0); + let p = messages::parse_server(messages::opcode::SMSG_WEATHER, &body).unwrap(); + match decode(p).as_slice() { + [SessionEvent::Weather { + weather_type, + grade, + sound_id, + instant, + }] => { + assert_eq!((*weather_type, *sound_id, *instant), (1, 8534, false)); + assert!((grade - 0.7).abs() < 1e-6); + } + other => panic!("weather decode: {} events", other.len()), + } +} + +/// `SMSG_SET_PROFICIENCY` (295): u8 itemClass + u32 subclass bitmask (vmangos `Skill.h` / +/// `Skill.cpp AppendBodyTo`) — parses and decodes to the proficiency event the item tooltip's +/// slot-line red reads. +#[test] +fn set_proficiency_parses_and_decodes() { + assert_eq!(messages::opcode::SMSG_SET_PROFICIENCY, 295); + // Weapons (class 2), mask 0x2408F: the fresh-warrior shape. + let body = hx("028f400200"); + let p = messages::parse_server(messages::opcode::SMSG_SET_PROFICIENCY, &body).unwrap(); + match p { + ServerPacket::SetProficiency { + item_class, + subclass_mask, + } => { + assert_eq!(item_class, 2); + assert_eq!(subclass_mask, 0x0002_408f); + } + _ => panic!("set proficiency"), + } + let p = messages::parse_server(messages::opcode::SMSG_SET_PROFICIENCY, &body).unwrap(); + assert!(matches!( + decode(p)[..], + [SessionEvent::Proficiency { + item_class: 2, + subclass_mask: 0x0002_408f + }] + )); +} + +/// `SMSG_SET_FACTION_STANDING` (292): u32 count + count x (u32 reputationListId, i32 standing) +/// (vmangos `ReputationMgr::SendState` / `SetFactionStanding::AppendBodyTo`) — the mid-session +/// standing delta the reputation red re-ranks from. +#[test] +fn set_faction_standing_parses_and_decodes() { + assert_eq!(messages::opcode::SMSG_SET_FACTION_STANDING, 292); + // Two slots: list 46 -> 3000, list 89 -> -6000 (a standing can go down). + let body = hx("020000002e000000b80b00005900000090e8ffff"); + let p = messages::parse_server(messages::opcode::SMSG_SET_FACTION_STANDING, &body).unwrap(); + match p { + ServerPacket::SetFactionStanding { ref standings } => { + assert_eq!(standings[..], [(46, 3000), (89, -6000)]); + } + _ => panic!("set faction standing"), + } + assert!(matches!( + decode(p)[..], + [SessionEvent::ReputationDelta { ref standings }] + if standings[..] == [(46, 3000), (89, -6000)] + )); +} + +/// The dropped-packet tally feed (the wire-coverage instrument): an opcode with no parse arm falls +/// through to `ServerPacket::Other` and MUST decode to a `PacketDropped` event (not silence) so the +/// app can tally the gap — and the generated 1.12.1 name table resolves both known and unassigned +/// opcode numbers. +#[test] +fn unknown_opcode_decodes_to_packet_dropped() { + // 0x0319 = MSG_MOVE_TIME_SKIPPED — assigned in 1.12.1 but deliberately unparsed by benilla. + let packet = messages::parse_server(0x0319, &hx("0102030405")).unwrap(); + assert!(matches!(packet, ServerPacket::Other { opcode: 0x0319 })); + match decode(packet).as_slice() { + [SessionEvent::PacketDropped { + opcode: 0x0319, + unparseable: false, + }] => {} + other => panic!("expected one PacketDropped event, got {other:?}"), + } + // The generated name table: a known opcode resolves, an unassigned number doesn't. + assert_eq!(messages::opcode_name(0x0319), Some("MSG_MOVE_TIME_SKIPPED")); + assert_eq!( + messages::opcode_name(messages::opcode::SMSG_UPDATE_OBJECT), + Some("SMSG_UPDATE_OBJECT") + ); + assert_eq!(messages::opcode_name(0xFFFF), None); +} + +/// The death arc's wire family (decision 0308): the corpse query answer (both shapes + the +/// dungeon-entrance map split), the reclaim delay, a resurrect request, the spirit-healer +/// confirm, and the ack'd movement-flag family with its PACKED mover guid — all through +/// `parse_server` and the event decode. +#[test] +fn death_arc_family_parses_and_decodes() { + use benilla_protocol::messages::opcode; + + // MSG_CORPSE_QUERY, found: u8(1), i32 map 0, xyz, u32 corpsemap 36 (a dungeon corpse whose + // display coords were entrance-rewritten — the two maps deliberately differ). + let mut body = vec![1u8]; + body.extend_from_slice(&0i32.to_le_bytes()); + body.extend_from_slice(&(-11209.6f32).to_le_bytes()); + body.extend_from_slice(&1666.54f32.to_le_bytes()); + body.extend_from_slice(&25.0f32.to_le_bytes()); + body.extend_from_slice(&36u32.to_le_bytes()); + let pkt = messages::parse_server(opcode::MSG_CORPSE_QUERY, &body).unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::CorpseQuery { + found, + display_map, + position, + corpse_map, + }] => { + assert!(found); + assert_eq!(*display_map, 0); + assert_eq!(*corpse_map, 36); + assert_eq!(position[2], 25.0); + } + other => panic!("corpse query decode: {other:?}"), + } + // The unprompted not-found shape (bones conversion): the lone u8(0). + let pkt = messages::parse_server(opcode::MSG_CORPSE_QUERY, &[0u8]).unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::CorpseQuery { found: false, .. }] => {} + other => panic!("corpse query not-found decode: {other:?}"), + } + + // SMSG_CORPSE_RECLAIM_DELAY: u32 ms. + let pkt = messages::parse_server(opcode::SMSG_CORPSE_RECLAIM_DELAY, &30_000u32.to_le_bytes()) + .unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::CorpseReclaimDelay { delay_ms: 30_000 }] => {} + other => panic!("reclaim delay decode: {other:?}"), + } + + // SMSG_RESURRECT_REQUEST, player caster: guid, u32 len 1, empty cstring, sickness 0, timer 1. + let mut body = 0x2Au64.to_le_bytes().to_vec(); + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&[0, 0, 1]); + let pkt = messages::parse_server(opcode::SMSG_RESURRECT_REQUEST, &body).unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::ResurrectRequest { + caster: 0x2A, + name, + sickness: false, + has_timer: true, + }] => assert!(name.is_empty(), "player caster ⇒ empty wire name"), + other => panic!("resurrect request decode: {other:?}"), + } + + // SMSG_SPIRIT_HEALER_CONFIRM: one full guid. + let pkt = messages::parse_server( + opcode::SMSG_SPIRIT_HEALER_CONFIRM, + &0xF130_0000_0000_2AB3u64.to_le_bytes(), + ) + .unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::SpiritHealerConfirm { + npc: 0xF130_0000_0000_2AB3, + }] => {} + other => panic!("spirit healer confirm decode: {other:?}"), + } + + // SMSG_FORCE_MOVE_ROOT / SMSG_MOVE_WATER_WALK: PACKED guid + u32 counter. Guid 0x2A packs as + // mask 0x01 + one byte. + let body = hx("012a07000000"); // packed(0x2A) + counter 7 + let pkt = messages::parse_server(opcode::SMSG_FORCE_MOVE_ROOT, &body).unwrap(); + match decode(pkt).as_slice() { + [SessionEvent::MoveRoot { + guid: 0x2A, + counter: 7, + rooted: true, + }] => {} + other => panic!("move root decode: {other:?}"), + } + let pkt = messages::parse_server(opcode::SMSG_FORCE_MOVE_UNROOT, &body).unwrap(); + assert!(matches!( + decode(pkt).as_slice(), + [SessionEvent::MoveRoot { rooted: false, .. }] + )); + let pkt = messages::parse_server(opcode::SMSG_MOVE_WATER_WALK, &body).unwrap(); + assert!(matches!( + decode(pkt).as_slice(), + [SessionEvent::WaterWalk { + on: true, + counter: 7, + .. + }] + )); + let pkt = messages::parse_server(opcode::SMSG_MOVE_LAND_WALK, &body).unwrap(); + assert!(matches!( + decode(pkt).as_slice(), + [SessionEvent::WaterWalk { on: false, .. }] + )); +} + +/// The move-flag ack bodies (client side): full guid + echoed counter + MovementInfo, with the +/// trailing `u32 apply` on the water-walk ack ONLY (vmangos `Movement.cpp:38-59` — `MoveRootAck` +/// has no apply dword). The root ack must be exactly 4 bytes shorter than the water-walk ack for +/// the same inputs. +#[test] +fn move_flag_ack_bodies_differ_by_the_apply_tail() { + use benilla_protocol::messages::{move_flag_ack, MovementInfo}; + use benilla_protocol::wire::Vector3d; + + let info = MovementInfo { + flags: 0, + timestamp: 0x1122_3344, + position: Vector3d { + x: 1.0, + y: 2.0, + z: 3.0, + }, + orientation: 0.5, + transport: None, + pitch: 0.0, + fall_time: 0, + jump: None, + }; + let root = move_flag_ack(0x2A, 7, &info, None); + let walk = move_flag_ack(0x2A, 7, &info, Some(true)); + assert_eq!(root.len() + 4, walk.len()); + assert_eq!(&walk[..root.len()], root.as_slice()); + assert_eq!(&walk[root.len()..], 1u32.to_le_bytes()); + // The full (unpacked) guid + counter head. + assert_eq!(&root[..8], &0x2Au64.to_le_bytes()); + assert_eq!(&root[8..12], &7u32.to_le_bytes()); +} + +/// `SMSG_DURABILITY_DAMAGE_DEATH` (0x2BD): an EMPTY body (vmangos `DurabilityDamageDeath:: +/// AppendBodyTo` writes nothing) parsing to its cue event — the red 10%-loss error line. +#[test] +fn durability_damage_death_is_an_empty_body_cue() { + let pkt = messages::parse_server(messages::opcode::SMSG_DURABILITY_DAMAGE_DEATH, &[]).unwrap(); + assert!(matches!(pkt, ServerPacket::DurabilityDamageDeath)); + assert!(matches!( + decode(pkt).as_slice(), + [SessionEvent::DurabilityDamageDeath] + )); +} + +/// `SMSG_TRANSFER_PENDING` (0x3F): `u32 mapId` alone for an ordinary far teleport; `+ u32 +/// transportEntry + u32 oldMapId` when the player rides a transport through the transfer +/// (VERIFIED vmangos `Misc.cpp:493-501`). The block's presence routes how NEW_WORLD's +/// coordinates are read (decision 0455), so both shapes are pinned — and the abort +/// (`SMSG_TRANSFER_ABORTED`, one reason byte) clears the latch. +#[test] +fn transfer_pending_both_shapes_and_abort_parse_and_decode() { + // Plain: mapId 1 (Kalimdor), no transport block. + let plain = + messages::parse_server(messages::opcode::SMSG_TRANSFER_PENDING, &hx("01000000")).unwrap(); + assert!(matches!( + plain, + ServerPacket::TransferPending { + map: 1, + transport: None + } + )); + assert!(matches!( + decode(plain).as_slice(), + [SessionEvent::TransferPending { + map_id: 1, + transport_entry: None + }] + )); + // Riding: mapId 0 (EK), transportEntry 176310 (0x2B0B6 — the Menethil boat), oldMapId 1. + let mut riding = 0u32.to_le_bytes().to_vec(); + riding.extend_from_slice(&176310u32.to_le_bytes()); + riding.extend_from_slice(&1u32.to_le_bytes()); + let riding = messages::parse_server(messages::opcode::SMSG_TRANSFER_PENDING, &riding).unwrap(); + assert!(matches!( + riding, + ServerPacket::TransferPending { + map: 0, + transport: Some((176310, 1)) + } + )); + assert!(matches!( + decode(riding).as_slice(), + [SessionEvent::TransferPending { + map_id: 0, + transport_entry: Some(176310) + }] + )); + // Abort: one reason byte. + let abort = messages::parse_server(messages::opcode::SMSG_TRANSFER_ABORTED, &[2]).unwrap(); + assert!(matches!(abort, ServerPacket::TransferAborted { reason: 2 })); + assert!(matches!( + decode(abort).as_slice(), + [SessionEvent::TransferAborted { reason: 2 }] + )); +} diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs new file mode 100644 index 00000000..631c60af --- /dev/null +++ b/crates/benilla-protocol/tests/spells.rs @@ -0,0 +1,842 @@ +//! Spell/action/attack + spell-visual wire tests (mirrors `src/messages/spells.rs`): initial spells, +//! action buttons, cast/attack swing, cast result, attacker state, plus the spell-visual pipeline +//! (`SMSG_SPELL_START`/`GO`, `SMSG_SPELL_FAILED_OTHER`, `SMSG_CANCEL_AUTO_REPEAT`, +//! `SMSG_PLAY_SPELL_VISUAL`, decision 0099 phase 1). Split out of the former `tests/messages.rs` — +//! see `tests/common` for the shared fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::ServerPacket; +use common::hx; + +#[test] +fn spell_and_action_wire() { + use benilla_protocol::messages::{ActionButton, CastOutcome, ACTION_KIND_ITEM}; + + // SMSG_INITIAL_SPELLS (vmangos Player::SendInitialSpells): u8 0; u16 n; n x (u16 id, u16 0); + // u16 m; m x (u16 spell, u16 item, u16 category, u32 spellCdMs, u32 catCdMs). + let body = hx(concat!( + "00", "0300", "4e000000", // 78 Heroic Strike + "cb190000", // 6603 Attack + "111a0000", // 6673 Battle Shout + "0100", "4e00", "0000", "2301", "88130000", + "dc050000" // 78 on 5000ms cd, category 0x123 1500ms + )); + match messages::parse_server(messages::opcode::SMSG_INITIAL_SPELLS, &body).unwrap() { + ServerPacket::InitialSpells { + spell_ids, + cooldowns, + } => { + assert_eq!(spell_ids, vec![78, 6603, 6673]); + assert_eq!(cooldowns.len(), 1); + assert_eq!((cooldowns[0].spell_id, cooldowns[0].category), (78, 0x123)); + assert_eq!( + (cooldowns[0].spell_cd_ms, cooldowns[0].category_cd_ms), + (5000, 1500) + ); + } + _ => panic!("initial spells"), + } + + // SMSG_ACTION_BUTTONS: 120 packed u32s — action bits 0-23, kind bits 24-31, 0 = empty. + let mut body = vec![0u8; 120 * 4]; + body[0..4].copy_from_slice(&0x0000_1A11u32.to_le_bytes()); // slot 0: spell 6673 + body[4..8].copy_from_slice(&0x8000_0075u32.to_le_bytes()); // slot 1: item 117 + body[44..48].copy_from_slice(&0x4000_0003u32.to_le_bytes()); // slot 11: macro 3 + let packet = messages::parse_server(messages::opcode::SMSG_ACTION_BUTTONS, &body).unwrap(); + let buttons = match &packet { + ServerPacket::ActionButtons { buttons } => buttons.clone(), + _ => panic!("action buttons"), + }; + assert_eq!( + buttons, + vec![ + ActionButton { + slot: 0, + action: 6673, + kind: 0 + }, + ActionButton { + slot: 1, + action: 117, + kind: ACTION_KIND_ITEM + }, + ActionButton { + slot: 11, + action: 3, + kind: 0x40 + }, + ] + ); + // ...and the decode passes them through as the ActionButtons event. + match decode(packet).pop().unwrap() { + SessionEvent::ActionButtons { buttons: b } => assert_eq!(b, buttons), + _ => panic!("action buttons event"), + } + + // CMSG_CAST_SPELL golden bytes (vmangos SpellCastTargets::read): self cast = mask 0, nothing + // follows; unit cast = mask 2 + PACKED guid (build > 1.8.4 reads targets packed). + assert_eq!( + messages::cast_spell(6673, None), + hx("111a00000000"), + "self cast: u32 id + u16 mask 0" + ); + let victim = 0xF130_0000_4500_002Au64; // creature: high 0xF130, entry 69, counter 0x2A + assert_eq!( + messages::cast_spell(5176, Some(victim)), + hx("381400000200c92a4530f1"), + "unit cast: u32 id + u16 mask 2 + packed guid (mask 0xC9)" + ); + + // CMSG_CAST_SPELL at an ITEM (0437 phase 3 — the enchant pick): mask 0x0010 + packed guid. + // Item guid 0x4700_0000_0000_0951: present bytes 0 (0x51), 1 (0x09), 7 (0x47) → mask 0x83. + let enchant_target = 0x4700_0000_0000_0951u64; + assert_eq!( + messages::cast_spell_item(7418, enchant_target), + hx("fa1c0000100083510947"), + "item cast: u32 id + u16 mask 0x10 + packed item guid" + ); + + // CMSG_ATTACKSWING: one full 8-byte guid. + assert_eq!(messages::attack_swing(victim), hx("2a000045000030f1")); + + // CMSG_SET_ACTION_BUTTON opcode (296 / 0x0128 — vmangos Opcodes_1_12_1.h) + body: button u8 + + // packed u32 (WorldPackets::Misc::SetActionButton::ReadFromWorldPacket, Misc.cpp:87-90). + assert_eq!( + messages::opcode::CMSG_SET_ACTION_BUTTON, + 0x0128, + "CMSG_SET_ACTION_BUTTON opcode" + ); + assert_eq!( + messages::set_action_button(11, 0x8000_0075), + hx("0b75000080"), + "button 11 (lua slot 12), item 117 (kind 0x80<<24 | action 0x75)" + ); + assert_eq!( + messages::set_action_button(0, 0), + hx("0000000000"), + "packed 0 clears the slot" + ); + + // CMSG_SETSHEATHED opcode (480 / 0x01E0 — vmangos Opcodes_1_12_1.h) + body: one u32 state. + assert_eq!( + messages::opcode::CMSG_SETSHEATHED, + 0x01E0, + "CMSG_SETSHEATHED opcode" + ); + assert_eq!(messages::set_sheathed(1), hx("01000000")); + + // SMSG_CAST_RESULT: ok = status 0 ends the body; fail = status 2 + u8 reason. + match messages::parse_server(messages::opcode::SMSG_CAST_RESULT, &hx("111a000000")).unwrap() { + ServerPacket::CastResult { spell_id, outcome } => { + assert_eq!((spell_id, outcome), (6673, CastOutcome::Ok)); + } + _ => panic!("cast ok"), + } + let fail = + messages::parse_server(messages::opcode::SMSG_CAST_RESULT, &hx("111a00000255")).unwrap(); + match decode(fail).pop().unwrap() { + SessionEvent::CastResult { + spell_id, + success, + reason, + } => assert_eq!((spell_id, success, reason), (6673, false, Some(0x55))), + _ => panic!("cast fail event"), + } + + // SMSG_ATTACKSTART: two full guids. SMSG_ATTACKSTOP: two packed guids + u32 isDead. + let body = hx("01000000000000002a000045000030f1"); + match messages::parse_server(messages::opcode::SMSG_ATTACKSTART, &body).unwrap() { + ServerPacket::AttackStart { + attacker, + victim: v, + } => { + assert_eq!((attacker, v), (1, victim)); + } + _ => panic!("attack start"), + } + let body = hx(concat!("0101", "c92a4530f1", "00000000")); + match messages::parse_server(messages::opcode::SMSG_ATTACKSTOP, &body).unwrap() { + ServerPacket::AttackStop { + attacker, + victim: v, + } => { + assert_eq!((attacker, v), (1, victim)); + } + _ => panic!("attack stop"), + } + + // SMSG_ATTACKERSTATEUPDATE (vmangos Unit::SendAttackStateUpdate, Unit.cpp:4572-4605 — decision + // 0073): HitInfo, attacker PackGUID **first**, victim PackGUID, TotalDamage, *two* sub-damage + // blocks (absorb summed across both — decision 0137 phase 2's floating combat text feed), + // VictimState, two u32s (zero + spell id), blocked. The offhand bit (0x4) rides HitInfo. + let mut body = 0x6u32.to_le_bytes().to_vec(); // HitInfo: 0x2 | 0x4 (offhand) + body.extend_from_slice(&hx("0101")); // attacker packed guid = 1 + body.extend_from_slice(&hx("c92a4530f1")); // victim packed guid + body.extend_from_slice(&42u32.to_le_bytes()); // TotalDamage + body.push(2); // SubDamageCount + body.extend_from_slice(&0u32.to_le_bytes()); // sub 1: school (physical) + body.extend_from_slice(&42f32.to_le_bytes()); // sub 1: damage f32 + body.extend_from_slice(&42u32.to_le_bytes()); // sub 1: damage u32 + body.extend_from_slice(&5u32.to_le_bytes()); // sub 1: absorb + body.extend_from_slice(&0u32.to_le_bytes()); // sub 1: resist + body.extend_from_slice(&3u32.to_le_bytes()); // sub 2: school (frost) + body.extend_from_slice(&0f32.to_le_bytes()); // sub 2: damage f32 + body.extend_from_slice(&0u32.to_le_bytes()); // sub 2: damage u32 + body.extend_from_slice(&7u32.to_le_bytes()); // sub 2: absorb + body.extend_from_slice(&0u32.to_le_bytes()); // sub 2: resist + body.extend_from_slice(&1u32.to_le_bytes()); // VictimState: hit + body.extend_from_slice(&0u32.to_le_bytes()); // zero + body.extend_from_slice(&0u32.to_le_bytes()); // spell id + body.extend_from_slice(&15u32.to_le_bytes()); // blocked + match messages::parse_server(messages::opcode::SMSG_ATTACKERSTATEUPDATE, &body).unwrap() { + ServerPacket::AttackerState(s) => { + assert_eq!(s.attacker, 1); + assert_eq!(s.victim, victim); + assert_eq!(s.hit_info, 0x6); + assert_eq!(s.damage, 42); + assert_eq!(s.victim_state, 1); + assert_eq!( + s.absorb, 12, + "absorb summed across both sub-damage blocks (5 + 7)" + ); + assert_eq!(s.blocked, 15); + } + _ => panic!("attacker state"), + } + + // SMSG_AI_REACTION (vmangos Creature::SendAIReaction → Misc.cpp:445-449): raw (unpacked) + // guid + reaction u32 — 2 HOSTILE (aggro), 0 ALERT (stealth pre-aggro). + let body = hx(concat!("3412000000000000", "02000000")); + let packet = messages::parse_server(messages::opcode::SMSG_AI_REACTION, &body).unwrap(); + match &packet { + ServerPacket::AiReaction { unit, reaction } => { + assert_eq!((*unit, *reaction), (0x1234, 2)); + } + other => panic!("ai reaction, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::AiReaction { unit, reaction } => assert_eq!((unit, reaction), (0x1234, 2)), + other => panic!("ai reaction event, got {other:?}"), + } +} + +/// The spell-visual pipeline wire (decision 0099 phase 1): `SMSG_SPELL_START`/`GO`, +/// `SMSG_SPELL_FAILED_OTHER`, `SMSG_CANCEL_AUTO_REPEAT`, `SMSG_PLAY_SPELL_VISUAL`. Golden bytes are +/// hand-built from the vmangos writers (`Spell.cpp:4468-4659`, `SpellCastTargetsInfo.cpp:180-234`, +/// `Spell.cpp:4780-4789`, `Server/Packets/Misc.cpp:548-550`, `Server/Packets/Spell.cpp:54-58`) — +/// there is no existing oracle for these opcodes. +#[test] +fn spell_visual_wire_golden() { + use benilla_protocol::messages::{SpellCastTargets, SpellGo, SpellStart}; + + let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere) + + // SMSG_SPELL_START, a Fireball-style cast: item_or_caster pguid (no cast item, guid 1) + caster + // pguid (the creature) + spellId + castFlags (CAST_FLAG_UNKNOWN2 only) + remaining cast-time ms + // + a unit-target SpellCastTargets block. No ammo (castFlags doesn't carry CAST_FLAG_AMMO). + let body = hx(concat!( + "0101", // item_or_caster pguid: mask 1, guid 1 + "c92a4530f1", // caster pguid: the creature guid, mask 0xC9 + "85000000", // spellId 133 + "0200", // castFlags 0x2 (CAST_FLAG_UNKNOWN2) + "b80b0000", // remaining cast time: 3000ms + "0200", // SpellCastTargets mask: TARGET_FLAG_UNIT (0x2) + "0155", // unit target pguid: mask 1, guid 0x55 + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).unwrap(); + match &packet { + ServerPacket::SpellStart(s) => { + assert_eq!( + s, + &SpellStart { + item_or_caster: 1, + caster: creature, + spell_id: 133, + cast_flags: 0x2, + cast_time_ms: 3000, + targets: SpellCastTargets { + mask: 0x2, + unit_target: Some(0x55), + go_target: None, + dest: None, + }, + ammo_display_id: None, + } + ); + } + other => panic!("spell start, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellStart { + caster, + spell_id, + cast_flags, + cast_time_ms, + target, + ammo_display_id, + } => assert_eq!( + ( + caster, + spell_id, + cast_flags, + cast_time_ms, + target, + ammo_display_id, + ), + (creature, 133, 0x2, 3000, Some(0x55), None) + ), + other => panic!("spell start event, got {other:?}"), + } + + // SMSG_SPELL_START, an instant self-cast: cast time 0 (never gets a `Casting` component — the + // net bridge's insert gate is `cast_time_ms > 0`), target mask 0 (TARGET_FLAG_SELF) — nothing + // follows the mask. + let body = hx(concat!( + "0109", // item_or_caster pguid: mask 1, guid 9 (no cast item — this is the caster itself) + "0109", // caster pguid: same object, guid 9 + "11000000", // spellId 17 + "0200", // castFlags 0x2 + "00000000", // remaining cast time: 0 (instant) + "0000", // SpellCastTargets mask: 0 (SELF) — nothing follows + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::SpellStart { + caster, + spell_id, + cast_time_ms, + target, + .. + } => assert_eq!((caster, spell_id, cast_time_ms, target), (9, 17, 0, None)), + other => panic!("instant spell start event, got {other:?}"), + } + + // SMSG_SPELL_GO, a ranged Auto Shot: the same guid pair + spellId + castFlags (UNKNOWN9 | + // AMMO) + a 2-hit/2-miss target list (one plain miss, one SPELL_MISS_REFLECT with its trailing + // reflectResult byte) + a unit-target SpellCastTargets block + the ammo block (displayId + // 5996, inventoryType dropped). + let body = hx(concat!( + "0101", // item_or_caster pguid: guid 1 + "c92a4530f1", // caster pguid: the creature + "4b000000", // spellId 75 (Auto Shot) + "2001", // castFlags 0x120 (CAST_FLAG_UNKNOWN9 0x100 | CAST_FLAG_AMMO 0x20) + "02", // hit count 2 + "aa00000000000000", // hit guid 0xAA, raw (unpacked) + "bb00000000000000", // hit guid 0xBB, raw + "02", // miss count 2 + "cc00000000000000", + "02", // miss guid 0xCC, SPELL_MISS_RESIST (2) + "dd00000000000000", + "0b", + "02", // miss guid 0xDD, SPELL_MISS_REFLECT (11) + reflectResult 2 + "0200", // SpellCastTargets mask: TARGET_FLAG_UNIT + "01ee", // unit target pguid: guid 0xEE + "6c170000", // ammo displayId 5996 + "12000000", // ammo inventoryType 18 (dropped) + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap(); + match &packet { + ServerPacket::SpellGo(s) => { + assert_eq!( + s, + &SpellGo { + item_or_caster: 1, + caster: creature, + spell_id: 75, + cast_flags: 0x120, + hits: vec![0xAA, 0xBB], + misses: vec![(0xCC, 2), (0xDD, 11)], + targets: SpellCastTargets { + mask: 0x2, + unit_target: Some(0xEE), + go_target: None, + dest: None, + }, + ammo_display_id: Some(5996), + } + ); + } + other => panic!("spell go, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellGo { + caster, + spell_id, + cast_flags, + hits, + misses, + target, + go_target, + ammo_display_id, + item_caster, + } => { + assert_eq!(caster, creature); + assert_eq!(spell_id, 75); + assert_eq!(cast_flags, 0x120); + assert_eq!(hits, vec![0xAA, 0xBB]); + assert_eq!(misses, vec![(0xCC, 2), (0xDD, 11)]); + assert_eq!(target, Some(0xEE)); + assert_eq!(go_target, None); // a unit-targeted cast leaves the GO target empty + assert_eq!(ammo_display_id, Some(5996)); + // The packet's first guid (1) differs from the caster — an item cast, surfaced for + // the item-use cooldown key. + assert_eq!(item_caster, Some(1)); + } + other => panic!("spell go event, got {other:?}"), + } + + // SMSG_SPELL_FAILED_OTHER: raw (unpacked) guid + spellId (vmangos `Spell::SendInterrupted`). + let body = hx(concat!("4200000000000000", "85000000")); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_FAILED_OTHER, &body).unwrap(); + match &packet { + ServerPacket::SpellFailedOther { caster, spell_id } => { + assert_eq!((*caster, *spell_id), (0x42, 133)); + } + other => panic!("spell failed other, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellFailedOther { caster, spell_id } => { + assert_eq!((caster, spell_id), (0x42, 133)); + } + other => panic!("spell failed other event, got {other:?}"), + } + + // SMSG_SPELL_DELAYED: raw (unpacked) guid + pushback ms (vmangos `Spell::Delayed` — `data << + // ObjectGuid` is a raw u64, then `u32` delaytime). 500ms = 0x1F4. + let body = hx(concat!("4200000000000000", "f4010000")); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_DELAYED, &body).unwrap(); + match &packet { + ServerPacket::SpellDelayed { caster, delay_ms } => { + assert_eq!((*caster, *delay_ms), (0x42, 500)); + } + other => panic!("spell delayed, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellDelayed { caster, delay_ms } => { + assert_eq!((caster, delay_ms), (0x42, 500)); + } + other => panic!("spell delayed event, got {other:?}"), + } + + // SMSG_CANCEL_AUTO_REPEAT: empty body (vmangos `CancelAutoRepeat::AppendBodyTo` writes nothing). + let packet = messages::parse_server(messages::opcode::SMSG_CANCEL_AUTO_REPEAT, &[]).unwrap(); + assert!(matches!(packet, ServerPacket::CancelAutoRepeat)); + assert!(matches!( + decode(packet).pop().unwrap(), + SessionEvent::CancelAutoRepeat + )); + + // SMSG_PLAY_SPELL_VISUAL: raw (unpacked) guid + kit id. + let body = hx(concat!("3412000000000000", "39000000")); + let packet = messages::parse_server(messages::opcode::SMSG_PLAY_SPELL_VISUAL, &body).unwrap(); + match &packet { + ServerPacket::PlaySpellVisual { unit, kit_id } => { + assert_eq!((*unit, *kit_id), (0x1234, 57)); + } + other => panic!("play spell visual, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::PlaySpellVisual { unit, kit_id } => assert_eq!((unit, kit_id), (0x1234, 57)), + other => panic!("play spell visual event, got {other:?}"), + } + + // MSG_CHANNEL_START: spellId + duration, both u32 — self-only, no guid on the wire (vmangos + // `Spell::SendChannelStart`, `Spell.cpp:4951-4954`; decision 0137). 10797 Starshards, 6s. + let body = hx(concat!("2d2a0000", "70170000")); + let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_START, &body).unwrap(); + match &packet { + ServerPacket::ChannelStart { + spell_id, + duration_ms, + } => assert_eq!((*spell_id, *duration_ms), (10797, 6000)), + other => panic!("channel start, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::ChannelStart { + spell_id, + duration_ms, + } => assert_eq!((spell_id, duration_ms), (10797, 6000)), + other => panic!("channel start event, got {other:?}"), + } + + // MSG_CHANNEL_UPDATE: one u32 time-left (vmangos `Player::SendChannelUpdate`, + // `Player.cpp:21106-21110`); 0 = the channel is over. + let body = hx("b80b0000"); + let packet = messages::parse_server(messages::opcode::MSG_CHANNEL_UPDATE, &body).unwrap(); + match &packet { + ServerPacket::ChannelUpdate { remaining_ms } => assert_eq!(*remaining_ms, 3000), + other => panic!("channel update, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::ChannelUpdate { remaining_ms } => assert_eq!(remaining_ms, 3000), + other => panic!("channel update event, got {other:?}"), + } + + // Truncated buffers fail cleanly rather than silently short-reading (the codec's usual + // contract — every reader is `Read::read_exact`-backed, which errors on a short buffer). + let body = hx(concat!( + "0101", + "c92a4530f1", + "85000000", + "0200", // truncated before the u32 cast-time + )); + assert!(messages::parse_server(messages::opcode::SMSG_SPELL_START, &body).is_err()); + let body = hx(concat!( + "0101", + "c92a4530f1", + "4b000000", + "2001", + "02", + "aa00000000000000", // hit count says 2, only 1 hit guid present + )); + assert!(messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).is_err()); +} + +/// An open-lock cast on a chest / locked door: `SMSG_SPELL_GO` whose `SpellCastTargets` names a +/// GameObject (`TARGET_FLAG_GAMEOBJECT`, no unit target). The decoder must surface the GO guid so the +/// lid/door animation can open it (decision 0250) — the guid the older decode read for alignment and +/// dropped. +#[test] +fn spell_go_surfaces_the_gameobject_target() { + let body = hx(concat!( + "0101", // item_or_caster pguid: guid 1 + "0107", // caster pguid: guid 7 + "250d0000", // spellId 3365 ("Opening") + "0001", // castFlags 0x100 (CAST_FLAG_UNKNOWN9, no ammo) + "00", // hit count 0 + "00", // miss count 0 + "0008", // SpellCastTargets mask: TARGET_FLAG_GAMEOBJECT (0x0800) + "033412", // GO target pguid: guid 0x1234 + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELL_GO, &body).unwrap(); + match &packet { + ServerPacket::SpellGo(s) => { + assert_eq!(s.targets.go_target, Some(0x1234)); + assert_eq!(s.targets.unit_target, None); + } + other => panic!("spell go, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellGo { + spell_id, + go_target, + target, + .. + } => { + assert_eq!(spell_id, 3365); + assert_eq!(go_target, Some(0x1234)); // the chest the open-lock cast will pop + assert_eq!(target, None); + } + other => panic!("spell go event, got {other:?}"), + } +} + +/// The combat-log wire (decision 0137 phase 2's floating-combat-text data feed): +/// `SMSG_SPELLNONMELEEDAMAGELOG`, `SMSG_PERIODICAURALOG`, `SMSG_SPELLDAMAGESHIELD`, +/// `SMSG_SPELLLOGMISS`, `SMSG_LOG_XPGAIN`, `SMSG_LEVELUP_INFO`. Golden bytes are hand-built from +/// the vmangos writers (`Server/Packets/Spell.cpp:68-86,124-140`, `Unit.cpp:4395-4443`, +/// `Server/Packets/Combat.cpp:73-79`, `Server/Packets/Misc.cpp:512-532`) — there is no existing +/// oracle for these opcodes. +#[test] +fn combat_log_wire_golden() { + use benilla_protocol::messages::{ + DamageShield, EnvironmentalDamageLog, LevelUpInfo, PeriodicAuraLog, PeriodicTick, + SpellDamageLog, SpellLogMiss, XpGain, + }; + + let creature = 0xF130_0000_4500_002Au64; // packs mask 0xC9 (same fixture guid as elsewhere) + + // SMSG_SPELLNONMELEEDAMAGELOG: target PackedGuid, attacker PackedGuid, spellId, damage, school + // u8, absorbed, resist i32, periodicLog u8 (bool), unused u8, blocked, hitInfo, extendedData u8 + // (always 0, dropped). hitInfo 0x2 = SPELL_HIT_TYPE_CRIT. + let body = hx(concat!( + "c92a4530f1", // target pguid: the creature + "0101", // attacker pguid: guid 1 + "85000000", // spellId 133 + "f4010000", // damage 500 + "03", // school 3 (frost) + "32000000", // absorbed 50 + "ecffffff", // resist -20 + "00", // periodicLog: false (direct, not a DoT tick) + "00", // unused + "0a000000", // blocked 10 + "02000000", // hitInfo 0x2 (crit) + "00", // extendedData (always 0, dropped) + )); + let packet = + messages::parse_server(messages::opcode::SMSG_SPELLNONMELEEDAMAGELOG, &body).unwrap(); + match &packet { + ServerPacket::SpellDamageLog(s) => { + assert_eq!( + s, + &SpellDamageLog { + target: creature, + attacker: 1, + spell_id: 133, + damage: 500, + school: 3, + absorb: 50, + resist: -20, + periodic: false, + blocked: 10, + hit_info: 0x2, + } + ); + } + other => panic!("spell damage log, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellDamageLog(s) => assert_eq!(s.hit_info & 0x2, 0x2, "crit bit"), + other => panic!("spell damage log event, got {other:?}"), + } + + // SMSG_PERIODICAURALOG: target PackedGuid, caster PackedGuid, spellId, count, then `count` x + // {auraType u32, payload}. First vector: one PERIODIC_DAMAGE (3) tick. + let body = hx(concat!( + "c92a4530f1", // target pguid: the creature + "0101", // caster pguid: guid 1 + "ac000000", // spellId 172 (Corruption) + "01000000", // count 1 + "03000000", // auraType 3 (PERIODIC_DAMAGE) + "58000000", // damage 88 + "06000000", // school 6 (shadow) + "0c000000", // absorb 12 + "fbffffff", // resist -5 + )); + let packet = messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).unwrap(); + match &packet { + ServerPacket::PeriodicAuraLog(l) => { + assert_eq!( + l, + &PeriodicAuraLog { + target: creature, + caster: 1, + spell_id: 172, + ticks: vec![PeriodicTick::Damage { + amount: 88, + school: 6, + absorb: 12, + resist: -5, + }], + } + ); + } + other => panic!("periodic aura log (damage), got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::PeriodicAuraLog(l) => assert_eq!(l.ticks.len(), 1), + other => panic!("periodic aura log event, got {other:?}"), + } + + // Second vector: one PERIODIC_HEAL (8) tick — a self-heal (target == caster). + let body = hx(concat!( + "0101", // target pguid: guid 1 + "0101", // caster pguid: guid 1 (self) + "59000000", // spellId 89 (Renew) + "01000000", // count 1 + "08000000", // auraType 8 (PERIODIC_HEAL) + "2d000000", // amount 45 + )); + let packet = messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).unwrap(); + match &packet { + ServerPacket::PeriodicAuraLog(l) => { + assert_eq!( + l, + &PeriodicAuraLog { + target: 1, + caster: 1, + spell_id: 89, + ticks: vec![PeriodicTick::Heal { amount: 45 }], + } + ); + } + other => panic!("periodic aura log (heal), got {}", other.name()), + } + + // Third vector: an unrecognized auraType (4) cannot be skipped without desyncing the stream — + // decode must fail rather than guess a payload width. + let body = hx(concat!( + "0101", // target + "0101", // caster + "01000000", // spellId 1 + "01000000", // count 1 + "04000000", // auraType 4 (unhandled) + )); + assert!(messages::parse_server(messages::opcode::SMSG_PERIODICAURALOG, &body).is_err()); + + // SMSG_SPELLDAMAGESHIELD: victim raw guid, attacker raw guid, damage, school — all four fields + // distinct. `victim` is the shield's bearer; `attacker` receives the damage back. + let body = hx(concat!( + "1111000000000000", // victim raw guid 0x1111 + "2222000000000000", // attacker raw guid 0x2222 + "21000000", // damage 33 + "04000000", // school 4 (nature) + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELLDAMAGESHIELD, &body).unwrap(); + match &packet { + ServerPacket::DamageShield(s) => { + assert_eq!( + s, + &DamageShield { + victim: 0x1111, + attacker: 0x2222, + damage: 33, + school: 4, + } + ); + } + other => panic!("damage shield, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::DamageShield(s) => assert_eq!((s.victim, s.attacker), (0x1111, 0x2222)), + other => panic!("damage shield event, got {other:?}"), + } + + // SMSG_ENVIRONMENTALDAMAGELOG: victim raw guid (vmangos `ObjectGuid.cpp:174` streams the raw + // u64, NOT a PackedGuid), damageType u8, damage, absorbed, resist i32 (the > 1.6.1 tail, + // `Server/Packets/Combat.cpp:58-67`). Type 2 = fall. + let body = hx(concat!( + "0300000000000000", // victim raw guid 3 + "02", // damageType 2 (DAMAGE_FALL) + "17000000", // damage 23 + "05000000", // absorbed 5 + "ffffffff", // resist -1 + )); + let packet = + messages::parse_server(messages::opcode::SMSG_ENVIRONMENTALDAMAGELOG, &body).unwrap(); + match &packet { + ServerPacket::EnvironmentalDamageLog(e) => { + assert_eq!( + e, + &EnvironmentalDamageLog { + victim: 3, + damage_type: 2, + damage: 23, + absorb: 5, + resist: -1, + } + ); + } + other => panic!("environmental damage log, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::EnvironmentalDamageLog(e) => { + assert_eq!((e.victim, e.damage_type, e.damage), (3, 2, 23)); + } + other => panic!("environmental damage log event, got {other:?}"), + } + + // SMSG_SPELLLOGMISS: spellId, caster raw guid, useExtended u8 (0 here — no trailing 2xf32 per + // entry), count, then `count` x {target raw guid, missInfo u8}. Two entries: DODGE (3) and + // REFLECT (11) — the reflect trailing byte only rides SMSG_SPELL_GO's miss list, not this one. + let body = hx(concat!( + "85000000", // spellId 133 + "4200000000000000", // caster raw guid 0x42 + "00", // useExtended: 0 + "02000000", // count 2 + "aa00000000000000", // target 0xAA + "03", // missInfo 3 (DODGE) + "bb00000000000000", // target 0xBB + "0b", // missInfo 11 (REFLECT) + )); + let packet = messages::parse_server(messages::opcode::SMSG_SPELLLOGMISS, &body).unwrap(); + match &packet { + ServerPacket::SpellLogMiss(s) => { + assert_eq!( + s, + &SpellLogMiss { + spell_id: 133, + caster: 0x42, + misses: vec![(0xAA, 3), (0xBB, 11)], + } + ); + } + other => panic!("spell log miss, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellLogMiss(s) => assert_eq!(s.misses, vec![(0xAA, 3), (0xBB, 11)]), + other => panic!("spell log miss event, got {other:?}"), + } + + // SMSG_LOG_XPGAIN: victim raw guid (0 for non-kill) + total + xpType (0 kill, carries trailing + // base+bonus; 1 non-kill, nothing trailing). Both vectors consume exactly their own body — a + // kill's trailing base/bonus is present, a non-kill's is absent. + let mut body = 0xCCu64.to_le_bytes().to_vec(); // victim + body.extend_from_slice(&250u32.to_le_bytes()); // total + body.push(0); // xpType: kill + body.extend_from_slice(&200u32.to_le_bytes()); // base xp (kept: rested bonus = total − base) + body.extend_from_slice(&1.25f32.to_le_bytes()); // group bonus (dropped) + let packet = messages::parse_server(messages::opcode::SMSG_LOG_XPGAIN, &body).unwrap(); + match &packet { + ServerPacket::XpGain(x) => { + assert_eq!( + x, + &XpGain { + victim: 0xCC, + total: 250, + base: 200, + kill: true, + } + ); + } + other => panic!("xp gain (kill), got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::XpGain(x) => assert_eq!((x.victim, x.total, x.kill), (0xCC, 250, true)), + other => panic!("xp gain event, got {other:?}"), + } + + let mut body = 0u64.to_le_bytes().to_vec(); // victim: 0 (non-kill xp has no victim) + body.extend_from_slice(&50u32.to_le_bytes()); // total + body.push(1); // xpType: non-kill — nothing trails + let packet = messages::parse_server(messages::opcode::SMSG_LOG_XPGAIN, &body).unwrap(); + match &packet { + ServerPacket::XpGain(x) => { + assert_eq!( + x, + &XpGain { + victim: 0, + total: 50, + base: 50, // non-kill carries no base on the wire; base = total + kill: false, + } + ); + } + other => panic!("xp gain (non-kill), got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::XpGain(x) => assert_eq!((x.victim, x.total, x.kill), (0, 50, false)), + other => panic!("xp gain event, got {other:?}"), + } + + // SMSG_LEVELUP_INFO: twelve u32 — level, healthGain, powerGains[5] (mana..happiness), + // statGains[5] (str..spirit). VERIFIED vmangos Misc.cpp:524-532; exactly 48 bytes, no guid + // (self-addressed). The vector is a caster-flavored ding: mana + int/spirit heavy. + let mut body = Vec::new(); + for v in [7u32, 22, 15, 0, 0, 0, 0, 1, 0, 1, 2, 1] { + body.extend_from_slice(&v.to_le_bytes()); + } + assert_eq!(body.len(), 48); + let packet = messages::parse_server(messages::opcode::SMSG_LEVELUP_INFO, &body).unwrap(); + match &packet { + ServerPacket::LevelUp(l) => { + assert_eq!( + l, + &LevelUpInfo { + level: 7, + health: 22, + powers: [15, 0, 0, 0, 0], + stats: [1, 0, 1, 2, 1], + } + ); + } + other => panic!("level up, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::LevelUp(l) => assert_eq!((l.level, l.health, l.powers[0]), (7, 22, 15)), + other => panic!("level up event, got {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/taxi.rs b/crates/benilla-protocol/tests/taxi.rs new file mode 100644 index 00000000..5ab454cc --- /dev/null +++ b/crates/benilla-protocol/tests/taxi.rs @@ -0,0 +1,210 @@ +//! Oracle-free golden tests for the taxi/flight-master arc's protocol layer (decision 0484 phase +//! 1): the four `CMSG_TAXI*` send verbs, the `SMSG_SHOWTAXINODES` map + `TaxiMask` bit law, the +//! `SMSG_TAXINODE_STATUS` status/learn packet, `SMSG_ACTIVATETAXIREPLY`'s verdict codes, and the +//! empty `SMSG_NEW_TAXI_PATH` ack. Same idioms as `trainer.rs`/`gossip_vendor.rs` — `hx(...)` +//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`, each SMSG's +//! `decode()` bridge exercised too. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, taxi_reply, TaxiMask}; +use benilla_protocol::ServerPacket; + +fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn taxi_send_bodies_golden() { + // CMSG_TAXINODE_STATUS_QUERY / CMSG_TAXIQUERYAVAILABLENODES (vmangos Packets/Taxi.cpp): both + // a single full 8-byte guid — same shape as CMSG_GOSSIP_HELLO / CMSG_TRAINER_LIST. + assert_eq!( + messages::taxi_node_status_query(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_TAXINODE_STATUS_QUERY body" + ); + assert_eq!( + messages::taxi_query_available_nodes(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_TAXIQUERYAVAILABLENODES body" + ); + + // CMSG_ACTIVATETAXI (vmangos ActivateTaxi::ReadFromWorldPacket): guid, node1, node2 — a + // single-hop flight, Stormwind (2) -> Sentinel Hill (4). + assert_eq!( + messages::activate_taxi(0x1234_5678_9abc_def0, 2, 4), + hx(concat!("f0debc9a78563412", "02000000", "04000000")), + "CMSG_ACTIVATETAXI body" + ); + + // CMSG_ACTIVATETAXIEXPRESS (vmangos ActivateTaxiExpress::ReadFromWorldPacket): guid, + // totalcost, nodeCount, nodeCount x node — a 3-node chain at a combined 110-copper fare. + assert_eq!( + messages::activate_taxi_express(0x1234_5678_9abc_def0, 110, &[2, 3, 4]), + hx(concat!( + "f0debc9a78563412", + "6e000000", + "03000000", + "02000000", + "03000000", + "04000000" + )), + "CMSG_ACTIVATETAXIEXPRESS body" + ); + // An empty node list still writes a zero count, not a truncated body. + assert_eq!( + messages::activate_taxi_express(0x1234_5678_9abc_def0, 0, &[]), + hx(concat!("f0debc9a78563412", "00000000", "00000000")), + "CMSG_ACTIVATETAXIEXPRESS body, empty node list" + ); +} + +#[test] +fn taxi_mask_bit_law() { + // Bit law (vmangos PlayerTaxi.h IsTaximaskNodeKnown/SetTaximaskNode): 1-based node id, + // field = (id-1)/32, bit = (id-1)%32. Node 2 -> field 0 bit 1 (0x2); node 4 -> field 0 bit 3 + // (0x8); node 33 -> field 1 bit 0 (0x1) — the first id that crosses into the second word. + let mask = TaxiMask([0x0000_000A, 0x0000_0001, 0, 0, 0, 0, 0, 0]); + assert!(mask.is_known(2)); + assert!(mask.is_known(4)); + assert!(mask.is_known(33)); + assert!(!mask.is_known(3), "bit not set for node 3"); + assert!(!mask.is_known(65), "field 2 is all-zero"); + assert!(!mask.is_known(0), "node id 0 is never valid"); +} + +#[test] +fn show_taxi_nodes_wire() { + // SMSG_SHOWTAXINODES (vmangos SendTaxiMenu, TaxiHandler.cpp:82-96): u32 windowConstant + // (always 1), u64 flightmasterGuid (PLAIN, not packed), u32 nearestNode, u32[8] knownMask. + // 48 bytes fixed. Mask carries node 2 (Stormwind) and node 33 (crosses into the second word). + let mut body = 1u32.to_le_bytes().to_vec(); // window constant + body.extend_from_slice(&0xCCu64.to_le_bytes()); // flightmaster guid + body.extend_from_slice(&2u32.to_le_bytes()); // nearest node + let mask_words = [0x0000_0002u32, 0x0000_0001, 0, 0, 0, 0, 0, 0]; + for w in mask_words { + body.extend_from_slice(&w.to_le_bytes()); + } + assert_eq!(body.len(), 4 + 8 + 4 + 8 * 4, "48-byte fixed body"); + + match messages::parse_server(messages::opcode::SMSG_SHOWTAXINODES, &body).unwrap() { + ServerPacket::ShowTaxiNodes { + window, + flightmaster, + nearest_node, + known, + } => { + assert_eq!((window, flightmaster, nearest_node), (1, 0xCC, 2)); + assert_eq!(known, TaxiMask(mask_words)); + assert!(known.is_known(2)); + assert!(known.is_known(33)); + assert!(!known.is_known(3)); + } + other => panic!("show taxi nodes, got {}", other.name()), + } + + // The decode() bridge drops the gate word and carries the rest straight through. + let packet = messages::parse_server(messages::opcode::SMSG_SHOWTAXINODES, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::TaxiNodesShown { + flightmaster, + nearest_node, + known_mask, + } => { + assert_eq!((flightmaster, nearest_node), (0xCC, 2)); + assert_eq!(known_mask, TaxiMask(mask_words)); + } + other => panic!("taxi nodes shown event, got {other:?}"), + } + + // The leading word is the client parser's own conditional gate (0496 §claim-1): a zero gate + // ends the body right there — 4 bytes total, and no event out of decode. vmangos never sends + // it; the parse is faithful anyway. + let gated = 0u32.to_le_bytes().to_vec(); + let packet = messages::parse_server(messages::opcode::SMSG_SHOWTAXINODES, &gated).unwrap(); + assert!( + decode(packet).is_empty(), + "a zero-gated SHOWTAXINODES carries no menu and yields no event" + ); +} + +#[test] +fn taxi_node_status_wire() { + // SMSG_TAXINODE_STATUS (vmangos TaxiNodeStatus::AppendBodyTo): u64 guid (PLAIN), u8 known + // (a C++ bool — one byte). Exercise both known and unknown. + let mut known_body = 0xCCu64.to_le_bytes().to_vec(); + known_body.push(1); + match messages::parse_server(messages::opcode::SMSG_TAXINODE_STATUS, &known_body).unwrap() { + ServerPacket::TaxiNodeStatus { guid, known } => assert_eq!((guid, known), (0xCC, true)), + other => panic!("taxi node status, got {}", other.name()), + } + let packet = + messages::parse_server(messages::opcode::SMSG_TAXINODE_STATUS, &known_body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::TaxiNodeStatus { guid, known } => assert_eq!((guid, known), (0xCC, true)), + other => panic!("taxi node status event, got {other:?}"), + } + + let mut unknown_body = 0xDDu64.to_le_bytes().to_vec(); + unknown_body.push(0); + match messages::parse_server(messages::opcode::SMSG_TAXINODE_STATUS, &unknown_body).unwrap() { + ServerPacket::TaxiNodeStatus { guid, known } => assert_eq!((guid, known), (0xDD, false)), + other => panic!("taxi node status (unknown), got {}", other.name()), + } +} + +#[test] +fn activate_taxi_reply_and_new_taxi_path_wire() { + // SMSG_ACTIVATETAXIREPLY (vmangos ActivateTaxiReply::AppendBodyTo): one u32 TaxiError code. + let body = taxi_reply::NOT_ENOUGH_MONEY.to_le_bytes().to_vec(); + match messages::parse_server(messages::opcode::SMSG_ACTIVATETAXIREPLY, &body).unwrap() { + ServerPacket::ActivateTaxiReply { code } => { + assert_eq!(code, taxi_reply::NOT_ENOUGH_MONEY); + } + other => panic!("activate taxi reply, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_ACTIVATETAXIREPLY, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::ActivateTaxiReply { code } => { + assert_eq!(code, taxi_reply::NOT_ENOUGH_MONEY); + } + other => panic!("activate taxi reply event, got {other:?}"), + } + + // TaxiError values (vmangos Objects/Player.h:404-416) — pinned so a future edit can't drift + // them. Codes actually emitted by Player::ActivateTaxiPathTo (this session's grep of + // Player.cpp:18008-18252): OK, UNSPECIFIED_SERVER_ERROR, NO_SUCH_PATH, NOT_ENOUGH_MONEY, + // TOO_FAR, BUSY, ALREADY_MOUNTED, SHAPESHIFTED. The rest are defined but never sent. + // (13-wide, so an array rather than a tuple — Rust's tuple trait impls stop at 12.) + assert_eq!( + [ + taxi_reply::OK, + taxi_reply::UNSPECIFIED_SERVER_ERROR, + taxi_reply::NO_SUCH_PATH, + taxi_reply::NOT_ENOUGH_MONEY, + taxi_reply::TOO_FAR, + taxi_reply::NO_VENDOR_NEARBY, + taxi_reply::NOT_VISITED, + taxi_reply::BUSY, + taxi_reply::ALREADY_MOUNTED, + taxi_reply::SHAPESHIFTED, + taxi_reply::PLAYER_MOVING, + taxi_reply::SAME_NODE, + taxi_reply::NOT_STANDING, + ], + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + ); + + // SMSG_NEW_TAXI_PATH — empty body (vmangos NewTaxiPath::AppendBodyTo writes nothing). + match messages::parse_server(messages::opcode::SMSG_NEW_TAXI_PATH, &[]).unwrap() { + ServerPacket::NewTaxiPath => {} + other => panic!("new taxi path, got {}", other.name()), + } + let packet = messages::parse_server(messages::opcode::SMSG_NEW_TAXI_PATH, &[]).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::NewTaxiPath => {} + other => panic!("new taxi path event, got {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/trainer.rs b/crates/benilla-protocol/tests/trainer.rs new file mode 100644 index 00000000..81b78190 --- /dev/null +++ b/crates/benilla-protocol/tests/trainer.rs @@ -0,0 +1,242 @@ +//! Oracle-free golden tests for the training arc's protocol layer (decision 0237): the +//! `CMSG_TRAINER_LIST`/`CMSG_TRAINER_BUY_SPELL` send verbs, the `SMSG_TRAINER_LIST` service list and +//! the buy succeeded/failed results, and the incremental spell-learn pair +//! (`SMSG_LEARNED_SPELL`/`SMSG_SUPERCEDED_SPELL`). Same idioms as `gossip_vendor.rs` — `hx(...)` +//! golden CMSG bodies, hand-built SMSG bodies round-tripped through `parse_server`. The learn pair +//! also asserts its `decode()` bridge (phase 2 wired those events); the trainer list/buy variants +//! stay consumer-free until phases 3–4 wire the `TrainerState` events + `ClassTrainerFrame`. + +use benilla_protocol::events::{decode, SessionEvent}; +use benilla_protocol::messages::{self, train_fail, trainer_spell_state, TrainerSpell}; +use benilla_protocol::ServerPacket; + +fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() +} + +#[test] +fn trainer_send_bodies_golden() { + // CMSG_TRAINER_LIST (vmangos Npc.cpp, TrainerList::Read): one full trainer guid — same shape as + // CMSG_GOSSIP_HELLO / CMSG_LIST_INVENTORY. + assert_eq!( + messages::trainer_list(0x1234_5678_9abc_def0), + hx("f0debc9a78563412"), + "CMSG_TRAINER_LIST body" + ); + + // CMSG_TRAINER_BUY_SPELL (vmangos Npc.cpp, TrainerBuySpell::Read): u64 trainerGuid, u32 spellId. + assert_eq!( + messages::trainer_buy_spell(0x1234_5678_9abc_def0, 78), + hx(concat!("f0debc9a78563412", "4e000000")), + "CMSG_TRAINER_BUY_SPELL body" + ); +} + +/// Append one 38-byte service record (vmangos SendTrainerSpellHelper, NPCHandler.cpp:97-139) to a +/// SMSG_TRAINER_LIST body: spell u32, state u8, cost u32, canLearnPrimaryProf u32, isPrimaryProfFirstRank +/// u32, reqLevel u8, reqSkill u32, reqSkillValue u32, then three prerequisite-spell u32s. +#[allow(clippy::too_many_arguments)] +fn push_service( + body: &mut Vec, + spell: u32, + state: u8, + cost: u32, + can_learn: u32, + is_prof: u32, + req_level: u8, + req_skill: u32, + req_skill_value: u32, + req_spells: [u32; 3], +) { + body.extend_from_slice(&spell.to_le_bytes()); + body.push(state); + body.extend_from_slice(&cost.to_le_bytes()); + body.extend_from_slice(&can_learn.to_le_bytes()); + body.extend_from_slice(&is_prof.to_le_bytes()); + body.push(req_level); + body.extend_from_slice(&req_skill.to_le_bytes()); + body.extend_from_slice(&req_skill_value.to_le_bytes()); + for s in req_spells { + body.extend_from_slice(&s.to_le_bytes()); + } +} + +#[test] +fn trainer_list_wire() { + // SMSG_TRAINER_LIST (vmangos SendTrainerList, NPCHandler.cpp:141-241): u64 trainerGuid, u32 + // trainerType, u32 count, count x 38-byte service, cstr title. Two services: a green (learnable) + // class spell with a level gate, and a gray (already-known) primary-profession first rank with a + // skill gate + one prerequisite spell — exercising both prof flag fields and the req_spells array. + let mut body = 0xCCu64.to_le_bytes().to_vec(); + body.extend_from_slice(&0u32.to_le_bytes()); // trainerType 0 = class + body.extend_from_slice(&2u32.to_le_bytes()); // count + push_service(&mut body, 78, 0, 100, 0, 0, 10, 0, 0, [0, 0, 0]); + push_service(&mut body, 2018, 2, 1000, 1, 1, 5, 164, 1, [12345, 0, 0]); + body.extend_from_slice(b"I can teach you.\0"); + + match messages::parse_server(messages::opcode::SMSG_TRAINER_LIST, &body).unwrap() { + ServerPacket::TrainerList { + trainer, + trainer_type, + services, + title, + } => { + assert_eq!((trainer, trainer_type), (0xCC, 0)); + assert_eq!(title, "I can teach you."); + assert_eq!( + services, + vec![ + TrainerSpell { + spell: 78, + state: trainer_spell_state::GREEN, + cost: 100, + can_learn_primary_prof: false, + is_primary_prof_first_rank: false, + req_level: 10, + req_skill: 0, + req_skill_value: 0, + req_spells: [0, 0, 0], + }, + TrainerSpell { + spell: 2018, + state: trainer_spell_state::GRAY, + cost: 1000, + can_learn_primary_prof: true, + is_primary_prof_first_rank: true, + req_level: 5, + req_skill: 164, + req_skill_value: 1, + req_spells: [12345, 0, 0], + }, + ] + ); + } + other => panic!("trainer list, got {}", other.name()), + } + + // Empty trainer (count 0): the title still follows immediately — no service records to read. + let mut empty = 0xDDu64.to_le_bytes().to_vec(); + empty.extend_from_slice(&2u32.to_le_bytes()); // trainerType 2 = tradeskill + empty.extend_from_slice(&0u32.to_le_bytes()); // count + empty.extend_from_slice(b"Nothing for you yet.\0"); + match messages::parse_server(messages::opcode::SMSG_TRAINER_LIST, &empty).unwrap() { + ServerPacket::TrainerList { + trainer, + trainer_type, + services, + title, + } => { + assert_eq!((trainer, trainer_type), (0xDD, 2)); + assert!(services.is_empty()); + assert_eq!(title, "Nothing for you yet."); + } + other => panic!("empty trainer list, got {}", other.name()), + } + + // The decode() bridge maps title → greeting and carries the wire services through (phase 3). + let packet = messages::parse_server(messages::opcode::SMSG_TRAINER_LIST, &body).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::TrainerList { + trainer, + trainer_type, + services, + greeting, + } => { + assert_eq!((trainer, trainer_type, services.len()), (0xCC, 0, 2)); + assert_eq!(greeting, "I can teach you."); + } + other => panic!("trainer list event, got {other:?}"), + } +} + +#[test] +fn trainer_buy_result_wire() { + // SMSG_TRAINER_BUY_SUCCEEDED (vmangos TrainerBuySucceeded::AppendBodyTo): u64 trainerGuid, u32 spellId. + let mut ok = 0xCCu64.to_le_bytes().to_vec(); + ok.extend_from_slice(&78u32.to_le_bytes()); + match messages::parse_server(messages::opcode::SMSG_TRAINER_BUY_SUCCEEDED, &ok).unwrap() { + ServerPacket::TrainerBuySucceeded { trainer, spell_id } => { + assert_eq!((trainer, spell_id), (0xCC, 78)); + } + other => panic!("trainer buy succeeded, got {}", other.name()), + } + + // SMSG_TRAINER_BUY_FAILED (vmangos TrainerBuyFailed::AppendBodyTo): u64 trainerGuid, u32 serviceId, + // u32 errorCode. + let mut failed = 0xCCu64.to_le_bytes().to_vec(); + failed.extend_from_slice(&78u32.to_le_bytes()); + failed.extend_from_slice(&train_fail::NOT_ENOUGH_MONEY.to_le_bytes()); + match messages::parse_server(messages::opcode::SMSG_TRAINER_BUY_FAILED, &failed).unwrap() { + ServerPacket::TrainerBuyFailed { + trainer, + spell_id, + error, + } => { + assert_eq!( + (trainer, spell_id, error), + (0xCC, 78, train_fail::NOT_ENOUGH_MONEY) + ); + } + other => panic!("trainer buy failed, got {}", other.name()), + } + + // Wire enum values pinned so a future edit can't drift them (vmangos Player.h:119-122, + // SharedDefines.h:1120-1122). + assert_eq!( + ( + trainer_spell_state::GREEN, + trainer_spell_state::RED, + trainer_spell_state::GRAY, + ), + (0, 1, 2) + ); + assert_eq!( + ( + train_fail::UNAVAILABLE, + train_fail::NOT_ENOUGH_MONEY, + train_fail::NOT_ENOUGH_SKILL, + ), + (0, 1, 2) + ); +} + +#[test] +fn learned_and_superceded_spell_wire() { + // SMSG_LEARNED_SPELL (vmangos LearnedSpell::AppendBodyTo, Spell.cpp:175-179): u16 spellId, u16 + // actionBarSlot. The slot is unused on the client and dropped — a nonzero slot here must not leak + // into spell_id (proving it's read as two u16s, not one u32). + match messages::parse_server(messages::opcode::SMSG_LEARNED_SPELL, &hx("cb19aaaa")).unwrap() { + ServerPacket::LearnedSpell { spell_id } => assert_eq!(spell_id, 6603), + other => panic!("learned spell, got {}", other.name()), + } + // The bridge widens the wire u16 to the store's u32 (phase 2). + let packet = + messages::parse_server(messages::opcode::SMSG_LEARNED_SPELL, &hx("cb19aaaa")).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::SpellLearned { spell_id } => assert_eq!(spell_id, 6603u32), + other => panic!("spell learned event, got {other:?}"), + } + + // SMSG_SUPERCEDED_SPELL (vmangos SupercededSpell::AppendBodyTo, Spell.cpp:169-173): u16 oldSpellId, + // u16 newSpellId. + match messages::parse_server(messages::opcode::SMSG_SUPERCEDED_SPELL, &hx("cb19cc19")).unwrap() + { + ServerPacket::SupercededSpell { + old_spell_id, + new_spell_id, + } => assert_eq!((old_spell_id, new_spell_id), (6603, 6604)), + other => panic!("superceded spell, got {}", other.name()), + } + let packet = + messages::parse_server(messages::opcode::SMSG_SUPERCEDED_SPELL, &hx("cb19cc19")).unwrap(); + match decode(packet).pop().unwrap() { + SessionEvent::SpellSuperceded { + old_spell_id, + new_spell_id, + } => assert_eq!((old_spell_id, new_spell_id), (6603u32, 6604u32)), + other => panic!("spell superceded event, got {other:?}"), + } +} diff --git a/crates/benilla-protocol/tests/update_object.rs b/crates/benilla-protocol/tests/update_object.rs new file mode 100644 index 00000000..b0a55e29 --- /dev/null +++ b/crates/benilla-protocol/tests/update_object.rs @@ -0,0 +1,448 @@ +//! `SMSG_UPDATE_OBJECT` / `SMSG_DESTROY_OBJECT` wire tests (mirrors `src/messages/update_object.rs`): +//! object creates (unit/gameobject/item/container), out-of-range removal, destroy, and the sparse +//! `ObjectFields` Values-update decode (unit state/power/customization, virtual items + sheath, +//! inventory slots). Split out of the former `tests/messages.rs` — see `tests/common` for the shared +//! fixtures and methodology note. + +mod common; + +use benilla_protocol::events::{decode, EntityKind, SessionEvent}; +use benilla_protocol::messages; +use benilla_protocol::wire::write_packed_guid; +use common::hx; + +#[test] +fn update_object_fixture_decodes() { + // A real serialized SMSG_UPDATE_OBJECT body: Unit create (guid 0xAA, displayid 50, scale 1.5, a + // living position) + GameObject create (guid 0xBB, displayid 99, scale 2.0, pos 100/200/300) + + // out-of-range [0x10, 0x20]. + let body = hx("03000000000301aa03200000000000000000cdd70bc6357e04c3f90fa74200000040000000000000803f0000e040000090400000000000000000db0f4940051700000000000000000000000000000008000000aa00000000000000090000000000c03f320000000301bb05000117810700bb000000000000002100000000000040630000000000c84200004843000096430000803f040200000001100120"); + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let events = decode(packet); + + let unit = events + .iter() + .find_map(|e| match e { + SessionEvent::ObjectCreate { + guid: 0xAA, + kind, + display_id, + position, + scale, + speeds, + .. + } => Some((*kind, *display_id, *position, *scale, *speeds)), + _ => None, + }) + .expect("unit create"); + assert_eq!(unit.0, EntityKind::Unit); + assert_eq!(unit.1, Some(50)); + assert_eq!(unit.2, [-8949.95, -132.493, 83.5312]); + assert_eq!(unit.3, 1.5); + assert_eq!(unit.4.map(|s| s.walk), Some(1.0)); // speeds[0] = walk, from the LIVING block + + let go = events + .iter() + .find_map(|e| match e { + SessionEvent::ObjectCreate { + guid: 0xBB, + kind, + display_id, + position, + scale, + speeds, + .. + } => Some((*kind, *display_id, *position, *scale, *speeds)), + _ => None, + }) + .expect("gameobject create"); + assert_eq!(go.0, EntityKind::GameObject); + assert_eq!(go.1, Some(99)); + assert_eq!(go.2, [100.0, 200.0, 300.0]); + assert_eq!(go.3, 2.0); + assert_eq!(go.4, None); // GameObject HAS_POSITION block carries no speeds + + let removed = events + .iter() + .find_map(|e| match e { + SessionEvent::ObjectsRemoved(g) => Some(g.clone()), + _ => None, + }) + .expect("out-of-range"); + assert_eq!(removed, vec![0x10, 0x20]); +} + +#[test] +fn destroy_object_decodes_to_object_destroyed() { + // SMSG_DESTROY_OBJECT: a plain u64 guid (vmangos `DestroyObject::AppendBodyTo` streams the raw + // ObjectGuid) — the corpse-decay / despawn teardown. Its own event, distinct from the OutOfRange + // update block: destroyed objects pop instantly, out-of-range ones fade. + let body = hx("efbeadde01000000"); + let packet = messages::parse_server(messages::opcode::SMSG_DESTROY_OBJECT, &body).unwrap(); + assert_eq!(packet.name(), "SMSG_DESTROY_OBJECT"); + let destroyed = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectDestroyed(guid) => Some(guid), + _ => None, + }) + .expect("destroy"); + assert_eq!(destroyed, 0x1_DEAD_BEEF); +} + +#[test] +fn values_update_decodes_unit_state() { + // A hand-built Values-only update (update_type 0) for guid 0xAA carrying the unit descriptor + // fields HEALTH=22 (=100), MAXHEALTH=28 (=120), LEVEL=34 (=5), BYTES_0=36 (race 1/class 1/gender 0) + // — bits in 2 mask blocks, values in ascending field order. Verifies the `Object::Values` path + // (previously dropped) emits an ObjectValues delta whose fields decode via the named accessors. + let body = hx("01000000000001aa02000040101400000064000000780000000500000001010001"); + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let fields = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields), + _ => None, + }) + .expect("object values"); + assert_eq!( + ( + fields.unit_health(), + fields.unit_max_health(), + fields.unit_level(), + fields.unit_race(), + fields.unit_class(), + fields.unit_gender(), + ), + (Some(100), Some(120), Some(5), Some(1), Some(1), Some(0)) + ); + // A non-player unit carries no PLAYER block, so the customization accessors stay `None`. + assert_eq!(fields.player_skin(), None); + assert_eq!(fields.player_facial_hair(), None); +} + +#[test] +fn values_update_decodes_player_customization() { + // A Values-only update for player guid 0xBB carrying PLAYER_BYTES=193 (skin 0x10 / face 0x11 / + // hairStyle 0x12 / hairColor 0x13, packed b0..b3) and PLAYER_BYTES_2=194 (facialHair 0x14, b0). + // Field 193 lives in mask block 6 (bit 1), 194 in block 6 (bit 2) → 7 blocks, the first six empty. + // Pins the RF-0056 field indices + byte order against the decode. + let body = hx( + "01000000000001bb07000000000000000000000000000000000000000000000000060000001011121314000000", + ); + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let fields = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectValues { guid: 0xBB, fields } => Some(fields), + _ => None, + }) + .expect("player object values"); + assert_eq!( + ( + fields.player_skin(), + fields.player_face(), + fields.player_hair_style(), + fields.player_hair_color(), + fields.player_facial_hair(), + ), + (Some(0x10), Some(0x11), Some(0x12), Some(0x13), Some(0x14)) + ); +} + +#[test] +fn values_update_decodes_unit_power() { + // A Values-only update for guid 0xAA carrying POWER1=23 (40), MAXPOWER1=29 (60), BYTES_0=36 + // (race 1 / class 1 / gender 0 / power type 0 = mana). Pins the 1.12.1 power field indices + // (vmangos UpdateFields_1_12_1.h) + the bytes_0 power-type byte against the decode. + let body = hx("01000000000001aa020000802010000000280000003c00000001010000"); + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let fields = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectValues { guid: 0xAA, fields } => Some(fields), + _ => None, + }) + .expect("object values"); + assert_eq!(fields.unit_power_type(), 0); + assert_eq!(fields.unit_power(0), Some(40)); + assert_eq!(fields.unit_max_power(0), Some(60)); + // Other slots are absent; out-of-range slots are None by contract. + assert_eq!(fields.unit_power(1), None); + assert_eq!(fields.unit_max_power(5), None); +} + +#[test] +fn values_update_decodes_unit_virtual_items_and_sheath() { + // A Values-only update for creature guid 0xCC carrying UNIT_VIRTUAL_ITEM_SLOT_DISPLAY slot 0 + // (field 37) = 1234, UNIT_VIRTUAL_ITEM_INFO slot 0's pair (fields 40/41): dword0 packs + // class=2/subclass=7/material=1/invType=21, dword1 byte0 = sheath 3; and UNIT_FIELD_BYTES_2 + // (field 164) byte0 = sheath state 1 (melee drawn). Field -> (block, bit): 37 -> (1,5), + // 40 -> (1,8), 41 -> (1,9), 164 -> (5,4) -- 6 mask blocks. + let mut body = hx("01000000000001cc06"); // count 1, no transport, VALUES, packed guid 0xCC, 6 blocks + let mut masks = [0u32; 6]; + masks[1] = (1 << 5) | (1 << 8) | (1 << 9); + masks[5] = 1 << 4; + for m in masks { + body.extend_from_slice(&m.to_le_bytes()); + } + let info_dword0 = 2u32 | (7 << 8) | (1 << 16) | (21 << 24); // class/subclass/material/invType + for v in [1234u32, info_dword0, 3u32, 1u32] { + body.extend_from_slice(&v.to_le_bytes()); + } + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let fields = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectValues { guid: 0xCC, fields } => Some(fields), + _ => None, + }) + .expect("object values"); + + assert_eq!(fields.unit_virtual_item_display(0), Some(1234)); + assert_eq!(fields.unit_virtual_item_display(1), None, "slot 1 unsent"); + assert_eq!( + fields.unit_virtual_item_display(3), + None, + "slot out of range" + ); + assert_eq!(fields.unit_virtual_item_info(0), Some((2, 7, 1, 21))); + assert_eq!(fields.unit_virtual_item_info(3), None, "slot out of range"); + assert_eq!(fields.unit_virtual_item_sheath(0), Some(3)); + assert_eq!( + fields.unit_virtual_item_sheath(3), + None, + "slot out of range" + ); + assert_eq!(fields.unit_sheath_state(), Some(1)); +} + +#[test] +fn values_update_decodes_inventory_slots() { + // A Values-only update for guid 1 carrying PLAYER_FIELD_INV_SLOT 16 (the offhand, fields + // 518/519 — live-verified: One's shield guid landed exactly there) = guid 0xAB and + // PLAYER_FIELD_PACK_SLOT 0 (fields 532/533, live-verified) = guid 0xCD. Pins the CORRECTED + // 1.12.1 bases (vmangos enum arithmetic, NOT its stale hex comments: INV_SLOT_HEAD 486, + // PACK 532). 17 mask blocks; block 16 = bits 6,7 (518,519) + 20,21 (532,533). + let mut body = hx("010000000000010111"); // count 1, no transport, VALUES, packed guid 1, 17 blocks + for block in 0..17u32 { + let mask: u32 = if block == 16 { 0x0030_00C0 } else { 0 }; + body.extend_from_slice(&mask.to_le_bytes()); + } + for v in [0xABu32, 0, 0xCD, 0] { + body.extend_from_slice(&v.to_le_bytes()); + } + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let fields = decode(packet) + .into_iter() + .find_map(|e| match e { + SessionEvent::ObjectValues { guid: 1, fields } => Some(fields), + _ => None, + }) + .expect("values event"); + assert_eq!(fields.player_inv_slot(16), Some(0xAB)); + assert_eq!(fields.player_pack_slot(0), Some(0xCD)); + assert_eq!(fields.player_pack_slot(3), None, "unsent slot reads None"); + assert_eq!(fields.player_bank_slot(0), None); + assert_eq!(fields.container_slot(36), None, "out of range"); +} + +#[test] +fn item_and_container_creates_decode() { + // Two position-less creates in one packet — an item and a bag — exactly as vmangos streams our + // inventory at login: CREATE_OBJECT, packed guid, TypeId 1/2, movement block = UPDATEFLAG_ALL + // (0x10) + its constant u32 1 (wire-verified `Object::BuildMovementUpdate`), then the mask. + // These used to be dropped whole by the create placement gate; now they're ItemCreate events. + // Container fields pin the CORRECTED bases (enum arithmetic, not the stale hex comments — + // the same 6-low drift as the PLAYER block): NUM_SLOTS = ITEM_END = 48, SLOT_1 = 50. + let mut body = hx("0200000000"); + // Item guid 0x42, entry 4660 (field 3), stack count 5 (field 14): 1 mask block, bits 3+14. + body.extend_from_slice(&hx("0201420110010000000108400000")); + body.extend_from_slice(&4660u32.to_le_bytes()); + body.extend_from_slice(&5u32.to_le_bytes()); + // Container guid 0x43, entry 828 (field 3), num-slots 6 (field 48), slot-0 guid 0x42 + // (fields 50/51): 2 mask blocks — block0 bit 3, block1 bits 16/18/19. + body.extend_from_slice(&hx("020143021001000000020800000000000d00")); + for v in [828u32, 6, 0x42, 0] { + body.extend_from_slice(&v.to_le_bytes()); + } + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let events = decode(packet); + assert_eq!(events.len(), 2, "both creates decode, nothing spatial"); + match &events[0] { + SessionEvent::ItemCreate { + guid: 0x42, + container: false, + fields, + } => { + assert_eq!(fields.object_entry(), Some(4660)); + assert_eq!(fields.item_stack_count(), Some(5)); + } + other => panic!("item create, got {other:?}"), + } + match &events[1] { + SessionEvent::ItemCreate { + guid: 0x43, + container: true, + fields, + } => { + assert_eq!(fields.object_entry(), Some(828)); + assert_eq!(fields.container_num_slots(), Some(6)); + assert_eq!(fields.container_slot(0), Some(0x42)); + assert_eq!(fields.container_slot(1), None, "unsent slot reads None"); + } + other => panic!("container create, got {other:?}"), + } +} + +/// A transport GameObject's create (decision 0438 "the wire"): `UPDATE_FLAG_HAS_POSITION` (0x40) + +/// `UPDATE_FLAG_TRANSPORT` (0x02), the latter's tail being the server's pathProgress ms anchor +/// (vmangos `Object.cpp:590-605`). The mask carries `GAMEOBJECT_DISPLAYID` only — **no** +/// `GAMEOBJECT_POS_*` fields, exactly what vmangos sends for a boat/zeppelin (it puts the stationary +/// spawn point in the movement block's `HAS_POSITION` slot instead) — pinning both the new +/// `transport_progress` surfacing AND the create-placement fallback to the movement block's position +/// (`create_placement`, `events/decode.rs`) in one fixture. +#[test] +fn gameobject_create_surfaces_transport_progress_and_position_fallback() { + // HIGH_MO_TRANSPORT (0x1FC0) with the Menethil-Theramore ferry's real template entry (176495) + // riding the full low 32 bits (guid.rs `entry()` — MO_TRANSPORT has no 24-bit entry slot). + let guid: u64 = 176_495 | (0x1FC0u64 << 48); + + let mut body = 1u32.to_le_bytes().to_vec(); // amount_of_objects + body.push(0); // has_transport (unused by the parser) + body.push(2); // update_type: CREATE_OBJECT + write_packed_guid(guid, &mut body).unwrap(); + body.push(5); // TypeId::GameObject + + // Movement block: HAS_POSITION | TRANSPORT. + body.push(0x40 | 0x02); + body.extend_from_slice(&5000.0f32.to_le_bytes()); // stationary pos.x + body.extend_from_slice(&6000.0f32.to_le_bytes()); // stationary pos.y + body.extend_from_slice(&15.0f32.to_le_bytes()); // stationary pos.z + body.extend_from_slice(&2.0f32.to_le_bytes()); // orientation + body.extend_from_slice(&123_456u32.to_le_bytes()); // UPDATE_FLAG_TRANSPORT tail: pathProgress ms + + // Mask: 1 block, GAMEOBJECT_DISPLAYID (field 8) only. + body.push(1); + body.extend_from_slice(&(1u32 << 8).to_le_bytes()); + body.extend_from_slice(&3015u32.to_le_bytes()); // transportship.wmo's displayId + + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let events = decode(packet); + match events.as_slice() { + [SessionEvent::ObjectCreate { + guid: g, + kind, + display_id, + position, + orientation, + speeds, + transport_progress, + transport, + .. + }] => { + assert_eq!(*g, guid); + assert_eq!(*kind, EntityKind::GameObject); + assert_eq!(*display_id, Some(3015)); + assert_eq!( + *position, + [5000.0, 6000.0, 15.0], + "falls back to the movement block's HAS_POSITION pose — GAMEOBJECT_POS_* is absent" + ); + assert_eq!(*orientation, 2.0); + assert_eq!( + *speeds, None, + "a GameObject's HAS_POSITION block carries no speeds" + ); + assert_eq!( + *transport_progress, + Some(123_456), + "UPDATE_FLAG_TRANSPORT's u32 tail is the pathProgress ms anchor" + ); + assert_eq!( + *transport, None, + "a HAS_POSITION block (not LIVING) carries no ON_TRANSPORT rider tail" + ); + } + other => panic!("expected one ObjectCreate, got {other:?}"), + } +} + +/// A unit's `LIVING` block with `MOVEFLAG_ON_TRANSPORT` (0x0200_0000): the rider pose tail +/// `[u64 transport guid][local x,y,z][local o]` (`update_object/movement.rs`'s `MovementBlock::read`, +/// mirroring `read_movement_info`'s relay parse). VERIFIES the tail lands in `ObjectCreate::transport` +/// and the 6 movement speeds that follow still parse from the right offset (the alignment the old +/// discard-to-stay-aligned code depended on, now proven by consuming the values instead of dropping +/// them). +#[test] +fn unit_living_block_surfaces_on_transport_rider_pose() { + // The Org-UC zeppelin's template entry (176244), same MO_TRANSPORT composition as the ferry above. + let transport_guid: u64 = 176_244 | (0x1FC0u64 << 48); + + let mut body = 1u32.to_le_bytes().to_vec(); + body.push(0); // has_transport + body.push(2); // update_type: CREATE_OBJECT + write_packed_guid(0xAA, &mut body).unwrap(); + body.push(3); // TypeId::Unit + + body.push(0x20); // movement block: LIVING only + let flags: u32 = 0x0200_0000; // MOVEFLAG_ON_TRANSPORT (1.12 bit 25, vmangos MovementInfo.h) + body.extend_from_slice(&flags.to_le_bytes()); + body.extend_from_slice(&0u32.to_le_bytes()); // timestamp + body.extend_from_slice(&100.0f32.to_le_bytes()); // living pos.x (world, NOT local) + body.extend_from_slice(&200.0f32.to_le_bytes()); // living pos.y + body.extend_from_slice(&5.0f32.to_le_bytes()); // living pos.z + body.extend_from_slice(&1.0f32.to_le_bytes()); // living orientation + body.extend_from_slice(&transport_guid.to_le_bytes()); // ON_TRANSPORT tail: FULL u64 guid + body.extend_from_slice(&2.0f32.to_le_bytes()); // local x + body.extend_from_slice(&3.0f32.to_le_bytes()); // local y + body.extend_from_slice(&0.5f32.to_le_bytes()); // local z + body.extend_from_slice(&0.25f32.to_le_bytes()); // local o + body.extend_from_slice(&0.0f32.to_le_bytes()); // fall_time (not swimming/jumping, no other tails) + for v in [2.5f32, 7.0, 4.5, 4.722_222_3, 2.5, std::f32::consts::PI] { + body.extend_from_slice(&v.to_le_bytes()); // the 6 speeds — must still land right after the tail + } + + body.push(0); // mask: 0 blocks (nothing to interpret beyond the pose) + + let packet = messages::parse_server(messages::opcode::SMSG_UPDATE_OBJECT, &body).unwrap(); + let events = decode(packet); + match events.as_slice() { + [SessionEvent::ObjectCreate { + guid: 0xAA, + kind: EntityKind::Unit, + position, + orientation, + speeds, + transport_progress, + transport, + .. + }] => { + assert_eq!(*position, [100.0, 200.0, 5.0], "the unit's own world pose"); + assert_eq!(*orientation, 1.0); + assert_eq!( + transport, + &Some(benilla_protocol::messages::TransportPose { + guid: transport_guid, + pos: benilla_protocol::wire::Vector3d { + x: 2.0, + y: 3.0, + z: 0.5, + }, + orientation: 0.25, + }), + "the ON_TRANSPORT tail surfaces as the rider's local pose" + ); + assert_eq!( + transport_progress, &None, + "no UPDATE_FLAG_TRANSPORT on a rider create" + ); + assert_eq!( + speeds.map(|s| s.walk), + Some(2.5), + "the 6 speeds still parse right after the transport tail" + ); + } + other => panic!("expected one ObjectCreate, got {other:?}"), + } +} diff --git a/crates/benilla-srp/Cargo.toml b/crates/benilla-srp/Cargo.toml new file mode 100644 index 00000000..2f192d2b --- /dev/null +++ b/crates/benilla-srp/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "benilla-srp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "WoW 1.12.1 (build 5875) SRP6 client + vanilla world-header crypto — in-repo, decision 0021" + +[dependencies] +num-bigint = "0.4.6" +sha1 = "0.10.6" +rand = "0.8.6" diff --git a/crates/benilla-srp/examples/provision_probe.rs b/crates/benilla-srp/examples/provision_probe.rs new file mode 100644 index 00000000..d22587a4 --- /dev/null +++ b/crates/benilla-srp/examples/provision_probe.rs @@ -0,0 +1,90 @@ +//! Provision a slot-keyed probe account for the local vmangos `realmd` DB (decision 0450: a +//! session in `pool-N` probes as `probeN`/`pprobeN`/`Probe`). Growing the worktree +//! pool means provisioning the matching account first; this is that recipe as one command +//! (decision 0530 — the pool once grew without it, leaving a slot with no working identity). +//! +//! No DB access here — it prints the SQL and the char-create probe line; run those yourself. +//! +//! Validate the stored-hex convention against an existing row first (0450's discipline): +//! cargo run -p benilla-srp --example provision_probe -- check +//! then emit the new slot's row: +//! cargo run -p benilla-srp --example provision_probe -- emit +//! +//! Hex convention (validated against PROBE0's stored row, matching vmangos `BigNumber`): the DB +//! stores `v`/`s` as big-endian hex of the number; the salt bytes fed to SHA1 are that number's +//! little-endian bytes. + +use benilla_srp::{generate_account, password_verifier, NormalizedString}; + +/// Stored big-endian hex → the 32 little-endian bytes SRP feeds to SHA1. +fn le_from_hex(hex: &str) -> [u8; 32] { + let hex = hex.trim(); + assert!(hex.len() <= 64, "hex too long for a 32-byte value"); + let padded = format!("{hex:0>64}"); + let mut out = [0u8; 32]; + for (i, chunk) in (0..64).step_by(2).enumerate() { + // big-endian byte i lands at little-endian index 31-i + out[31 - i] = u8::from_str_radix(&padded[chunk..chunk + 2], 16).expect("hex digit"); + } + out +} + +/// Little-endian bytes → the big-endian hex string the DB stores. +fn hex_from_le(le: &[u8; 32]) -> String { + le.iter().rev().map(|b| format!("{b:02X}")).collect() +} + +fn spelled(n: u32) -> &'static str { + [ + "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", + ][n as usize] +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("check") if args.len() == 5 => { + let user = NormalizedString::new(&args[1]).expect("username"); + let pass = NormalizedString::new(&args[2]).expect("password"); + let v = password_verifier(&user, &pass, &le_from_hex(&args[3])); + let got = hex_from_le(&v); + let want = format!("{:0>64}", args[4].trim().to_uppercase()); + if got == want { + println!("MATCH — convention validated against the stored row"); + } else { + println!("MISMATCH\n computed v = {got}\n stored v = {want}"); + std::process::exit(1); + } + } + Some("emit") if args.len() == 2 => { + let n: u32 = args[1].parse().expect("slot number 0-9"); + assert!(n <= 9, "slot number 0-9"); + let user_s = format!("probe{n}"); + let pass_s = format!("pprobe{n}"); + let user = NormalizedString::new(&user_s).expect("username"); + let pass = NormalizedString::new(&pass_s).expect("password"); + let (salt, verifier) = generate_account(&user, &pass); + println!( + "INSERT INTO account (username, gmlevel, v, s) VALUES ('{}', 3, '{}', '{}');", + user_s.to_uppercase(), + hex_from_le(&verifier), + hex_from_le(&salt), + ); + println!("-- then mint the character over the real wire (0423's probe; KEEP it):"); + println!( + "-- WOW_USER={user_s} WOW_PASS={pass_s} WOW_PROBE_CHARCREATE=Probe{} \\", + spelled(n) + ); + println!("-- WOW_PROBE_CHARCREATE_KEEP=1 WOW_DATA= cargo run -q -p benilla"); + println!("-- then GM-on survival for the new character (extra_flags bit 1):"); + println!( + "-- UPDATE characters.characters SET extra_flags = extra_flags | 1 WHERE name = 'Probe{}';", + spelled(n) + ); + } + _ => { + eprintln!("usage: provision_probe check | emit "); + std::process::exit(2); + } + } +} diff --git a/crates/benilla-srp/src/lib.rs b/crates/benilla-srp/src/lib.rs new file mode 100644 index 00000000..b36c003c --- /dev/null +++ b/crates/benilla-srp/src/lib.rs @@ -0,0 +1,516 @@ +//! SRP6 client + vanilla world-header crypto for **WoW 1.12.1 (build 5875)** — in-repo, replacing +//! `wow_srp` (decision 0021). +//! +//! WoW uses a lightly customised SRP6 for logon: a fixed 32-byte safe prime `N`, generator `g = 7`, +//! multiplier `k = 3`, and a bespoke "interleave" that folds the shared secret `S` into the 40-byte +//! session key. We implement only the **client** side (the realmd handshake) plus the world server's +//! header obfuscation — the only pieces benilla needs. +//! +//! All byte arrays are **little endian** on the wire (as the client sends them). +//! +//! Proven by a full SRP6 round-trip against `wow_srp`'s server + a byte-exact header-cipher diff +//! during the decision-0021 migration (oracle test in git history); ongoing regression coverage is +//! the oracle-free known-answer + golden tests in this crate. + +use num_bigint::BigInt; +use rand::{thread_rng, RngCore}; +use sha1::{Digest, Sha1}; + +pub mod vanilla_header; + +pub use vanilla_header::{DecrypterHalf, EncrypterHalf, HeaderCrypto, ProofSeed}; + +/// Session-key length in bytes — always 40 (two concatenated SHA-1 hashes). +pub const SESSION_KEY_LENGTH: usize = 40; +/// Proof (`M1`/`M2`) length in bytes — a SHA-1 hash. +pub const PROOF_LENGTH: usize = 20; +/// Public-key (`A`/`B`) length in bytes. +pub const PUBLIC_KEY_LENGTH: usize = 32; +/// Salt length in bytes. +pub const SALT_LENGTH: usize = 32; +/// Generator `g` — statically 7 for WoW. +pub const GENERATOR: u8 = 7; +/// The WoW safe prime `N`, little endian (as sent in `CMD_AUTH_LOGON_CHALLENGE_Server`). +pub const LARGE_SAFE_PRIME_LITTLE_ENDIAN: [u8; 32] = [ + 0xb7, 0x9b, 0x3e, 0x2a, 0x87, 0x82, 0x3c, 0xab, 0x8f, 0x5e, 0xbf, 0xbf, 0x8e, 0xb1, 0x1, 0x8, + 0x53, 0x50, 0x6, 0x29, 0x8b, 0x5b, 0xad, 0xbd, 0x5b, 0x53, 0xe1, 0x89, 0x5e, 0x64, 0x4b, 0x89, +]; +/// The SRP multiplier `k` — statically 3 in this (pre-SRP6a) flavour. +const K_VALUE: u8 = 3; + +// --- bigint helpers (little-endian, unsigned magnitude) ------------------------------------------- + +fn from_le(bytes: &[u8]) -> BigInt { + BigInt::from_bytes_le(num_bigint::Sign::Plus, bytes) +} + +/// Magnitude of `v` as a zero-padded 32-byte little-endian array (`v` is always `< N < 2^256`). +fn to_padded_32_le(v: &BigInt) -> [u8; 32] { + let (_, bytes) = v.to_bytes_le(); + let mut out = [0u8; 32]; + out[..bytes.len()].copy_from_slice(&bytes); + out +} + +fn sha1(parts: &[&[u8]]) -> [u8; 20] { + let mut h = Sha1::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +// --- normalized string ---------------------------------------------------------------------------- + +/// A username/password normalised the way the 1.12 client does it: ASCII only (no control chars), +/// uppercased, 1..=16 bytes. The SRP6 hashes are computed over this form, and the uppercased account +/// name is what's sent on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NormalizedString { + s: String, +} + +/// Error from [`NormalizedString::new`]. +#[derive(Debug)] +pub enum NormalizedStringError { + /// Empty or longer than 16 bytes. + InvalidLength, + /// Contained a non-ASCII or ASCII-control character. + CharacterNotAllowed(char), +} + +impl std::fmt::Display for NormalizedStringError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidLength => write!(f, "string must be 1..=16 bytes"), + Self::CharacterNotAllowed(c) => write!(f, "character not allowed: {c:?}"), + } + } +} +impl std::error::Error for NormalizedStringError {} + +impl NormalizedString { + /// Validate + uppercase `s`. See [`NormalizedString`]. + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.is_empty() || s.len() > 16 { + return Err(NormalizedStringError::InvalidLength); + } + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if !c.is_ascii() || c.is_ascii_control() { + return Err(NormalizedStringError::CharacterNotAllowed(c)); + } + out.push(c.to_ascii_uppercase()); + } + Ok(Self { s: out }) + } +} + +impl AsRef for NormalizedString { + fn as_ref(&self) -> &str { + &self.s + } +} + +// --- public key ----------------------------------------------------------------------------------- + +/// A validated SRP public key (`A` or `B`), stored little endian. Rejected if it is exactly zero or +/// exactly the safe prime `N` (the only 32-byte values that are `0 mod N`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PublicKey { + key: [u8; 32], +} + +/// Error from [`PublicKey::from_le_bytes`]. +#[derive(Debug)] +pub enum InvalidPublicKeyError { + /// The key is all zeros. + IsZero, + /// The key is `0 mod N` (equal to the safe prime). + ModLargeSafePrimeIsZero, +} + +impl std::fmt::Display for InvalidPublicKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IsZero => write!(f, "public key is zero"), + Self::ModLargeSafePrimeIsZero => write!(f, "public key is 0 mod N"), + } + } +} +impl std::error::Error for InvalidPublicKeyError {} + +impl PublicKey { + /// Build from little-endian bytes, validating the key (see [`PublicKey`]). + pub fn from_le_bytes(key: [u8; 32]) -> Result { + // Valid unless every byte is either 0 or matches N[i] — i.e. the key is exactly 0 or exactly + // N. (A multiple of N ≥ 2N needs 33 bytes, so those are the only two zero residues.) + let only_zero_or_prime = key + .iter() + .zip(LARGE_SAFE_PRIME_LITTLE_ENDIAN.iter()) + .all(|(&k, &n)| k == 0 || k == n); + if only_zero_or_prime { + return Err(if key[0] == 0 { + InvalidPublicKeyError::IsZero + } else { + InvalidPublicKeyError::ModLargeSafePrimeIsZero + }); + } + Ok(Self { key }) + } + + /// The key as little-endian bytes. + pub const fn as_le_bytes(&self) -> &[u8; 32] { + &self.key + } + + fn as_bigint(&self) -> BigInt { + from_le(&self.key) + } +} + +// --- SRP6 client ---------------------------------------------------------------------------------- + +/// `H( SHA1(N) XOR SHA1(g) )` — folded into the client proof `M1`. Computed from the server-supplied +/// `g`/`N` (they're fixed for WoW, but we don't assume it). +fn xor_hash(generator: u8, large_safe_prime: &[u8; 32]) -> [u8; 20] { + let n_hash = sha1(&[large_safe_prime]); + let g_hash = sha1(&[&[generator]]); + let mut out = [0u8; 20]; + for (o, (n, g)) in out.iter_mut().zip(n_hash.iter().zip(g_hash.iter())) { + *o = n ^ g; + } + out +} + +/// `x = SHA1( salt | SHA1( UPPER(user) ":" UPPER(pass) ) )`. +fn calculate_x( + username: &NormalizedString, + password: &NormalizedString, + salt: &[u8; 32], +) -> [u8; 20] { + let inner = sha1(&[ + username.as_ref().as_bytes(), + b":", + password.as_ref().as_bytes(), + ]); + sha1(&[salt, &inner]) +} + +/// `u = SHA1( A | B )` (both little endian). +fn calculate_u(client_public_key: &PublicKey, server_public_key: &PublicKey) -> [u8; 20] { + sha1(&[ + client_public_key.as_le_bytes(), + server_public_key.as_le_bytes(), + ]) +} + +/// Fold the shared secret `S` (32 LE bytes) into the 40-byte session key: split the even / odd bytes +/// (after trimming an even count of low-order zero bytes), SHA-1 each half, then interleave the two +/// digests. This is WoW's specific `SHA1_Interleave`. +fn calculate_interleaved(s: &[u8; 32]) -> [u8; 40] { + // Trim leading (low-order) zero bytes, keeping the offset even so the even/odd split is stable. + let mut lead = 0; + while s[lead] == 0 { + lead += 1; + } + if lead % 2 != 0 { + lead += 1; + } + let s = &s[lead..]; + let half = s.len() / 2; + + let mut e = [0u8; 16]; + for (i, b) in s.iter().step_by(2).enumerate() { + e[i] = *b; + } + let g = sha1(&[&e[..half]]); + + let mut f = [0u8; 16]; + for (i, b) in s.iter().skip(1).step_by(2).enumerate() { + f[i] = *b; + } + let h = sha1(&[&f[..half]]); + + let mut out = [0u8; 40]; + for (i, (gi, hi)) in g.iter().zip(h.iter()).enumerate() { + out[i * 2] = *gi; + out[i * 2 + 1] = *hi; + } + out +} + +/// `M2 = SHA1( A | M1 | K )` — what the server proves back and we verify. +fn calculate_server_proof( + client_public_key: &PublicKey, + client_proof: &[u8; 20], + session_key: &[u8; 40], +) -> [u8; 20] { + sha1(&[client_public_key.as_le_bytes(), client_proof, session_key]) +} + +/// First step of the client logon: given the server's challenge values, computes our public key `A`, +/// the proof `M1`, and the session key. Send `A` + `M1` in `CMD_AUTH_LOGON_PROOF_Client`, then call +/// [`SrpClientChallenge::verify_server_proof`] with the server's `M2`. +#[derive(Debug, Clone)] +pub struct SrpClientChallenge { + username: NormalizedString, + client_proof: [u8; 20], + client_public_key: [u8; 32], + session_key: [u8; 40], +} + +impl SrpClientChallenge { + /// Compute `A`, `M1`, and the session key from the server challenge. Mirrors the real client: + /// random 32-byte private key `a`, `A = g^a mod N`, `S = (B - k·g^x)^(a + u·x) mod N`, session + /// key = interleave(S), `M1 = SHA1( H(N)^H(g) | SHA1(user) | salt | A | B | K )`. + pub fn new( + username: NormalizedString, + password: NormalizedString, + generator: u8, + large_safe_prime: [u8; 32], + server_public_key: PublicKey, + salt: [u8; 32], + ) -> SrpClientChallenge { + let n = from_le(&large_safe_prime); + let g = BigInt::from(generator); + let k = BigInt::from(K_VALUE); + + let mut private_key = [0u8; 32]; + thread_rng().fill_bytes(&mut private_key); + let a = from_le(&private_key); + + // A = g^a mod N + let client_public_key = to_padded_32_le(&g.modpow(&a, &n)); + + let x = from_le(&calculate_x(&username, &password, &salt)); + let client_pk = PublicKey::from_le_bytes(client_public_key) + .expect("generated client public key is valid"); + let u = from_le(&calculate_u(&client_pk, &server_public_key)); + + // S = (B - k·(g^x mod N))^(a + u·x) mod N + let s_int = + (server_public_key.as_bigint() - &k * g.modpow(&x, &n)).modpow(&(&a + &u * &x), &n); + let session_key = calculate_interleaved(&to_padded_32_le(&s_int)); + + // M1 = SHA1( xor_hash | SHA1(username) | salt | A | B | K ) + let username_hash = sha1(&[username.as_ref().as_bytes()]); + let client_proof = sha1(&[ + &xor_hash(generator, &large_safe_prime), + &username_hash, + &salt, + &client_public_key, + server_public_key.as_le_bytes(), + &session_key, + ]); + + SrpClientChallenge { + username, + client_proof, + client_public_key, + session_key, + } + } + + /// Our proof `M1` (little endian) — send in `CMD_AUTH_LOGON_PROOF_Client`. + pub const fn client_proof(&self) -> &[u8; 20] { + &self.client_proof + } + + /// Our public key `A` (little endian) — send in `CMD_AUTH_LOGON_PROOF_Client`. + pub const fn client_public_key(&self) -> &[u8; 32] { + &self.client_public_key + } + + /// Verify the server's proof `M2`. On success the SRP handshake is complete and the session key + /// is held in the returned [`SrpClient`]. + pub fn verify_server_proof( + self, + server_proof: [u8; 20], + ) -> Result { + let expected = calculate_server_proof( + &PublicKey::from_le_bytes(self.client_public_key) + .expect("our own client public key is valid"), + &self.client_proof, + &self.session_key, + ); + if server_proof != expected { + return Err(MatchProofsError { + server_proof, + expected, + }); + } + Ok(SrpClient { + username: self.username, + session_key: self.session_key, + }) + } +} + +/// The server's proof `M2` did not match what we computed (usually a wrong password). +#[derive(Debug)] +pub struct MatchProofsError { + /// The proof the server sent. + pub server_proof: [u8; 20], + /// The proof we expected. + pub expected: [u8; 20], +} +impl std::fmt::Display for MatchProofsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "server proof mismatch (wrong password?)") + } +} +impl std::error::Error for MatchProofsError {} + +/// A completed SRP6 logon: holds the session key carried into the world server. +#[derive(Debug, Clone)] +pub struct SrpClient { + #[allow(dead_code)] + username: NormalizedString, + session_key: [u8; 40], +} + +impl SrpClient { + /// The SRP session key `K` (40 little-endian bytes). + pub const fn session_key(&self) -> &[u8; 40] { + &self.session_key + } +} + +// --- account creation (server-side verifier) ------------------------------------------------------ + +/// The SRP6 **password verifier** `v = g^x mod N` (little endian) for a given salt — the value a +/// server stores alongside the salt so it never holds the raw password. Deterministic in +/// `(username, password, salt)`. See [`generate_account`] for the new-account path. +pub fn password_verifier( + username: &NormalizedString, + password: &NormalizedString, + salt: &[u8; 32], +) -> [u8; 32] { + let n = from_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN); + let g = BigInt::from(GENERATOR); + let x = from_le(&calculate_x(username, password, salt)); + to_padded_32_le(&g.modpow(&x, &n)) +} + +/// Generate a fresh random salt and the matching [`password_verifier`] for a new account. Returns +/// `(salt, verifier)`, both little endian. +pub fn generate_account( + username: &NormalizedString, + password: &NormalizedString, +) -> ([u8; 32], [u8; 32]) { + let mut salt = [0u8; 32]; + thread_rng().fill_bytes(&mut salt); + let verifier = password_verifier(username, password, &salt); + (salt, verifier) +} + +#[cfg(test)] +mod tests { + //! Oracle-free regression tests. The SRP6 *interleave* and `x` derivation are pinned to published + //! WoW SRP6 test vectors (the gtker/wow_srp corpus, MIT — the same values used to byte-validate + //! this crate against `wow_srp` during the decision-0021 migration). The header cipher + password + //! verifier are pinned to golden outputs captured from that validated implementation. + use super::*; + + fn hx(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + fn rev(mut v: Vec) -> Vec { + v.reverse(); + v + } + + #[test] + fn interleave_known_answer() { + // (S little-endian, expected session key little-endian) from the WoW SRP6 vector corpus. + let cases = [ + ( + "8F4CEBD60DFC34E5C007E51BD4F3A4FF2BC1D930E2D3EA770D8D3EEDFF2DCCFC", + "EE144E1AE08DAC891AB63ABC42BF89738003343422E6B58131BEE4C3087A7027E55A7216D18D556C", + ), + ( + "CCC1BDE07FC4FA3182DDEAAB036A88F78AD605AB0D8BFBF6F5EE8ED65CDE4F09", + "2AA23706C3FC3517A4293E2D4944F567E220CC1A227359D70154E5FD3CEE973673130C4AFBAD9E6D", + ), + ]; + for (s_hex, key_hex) in cases { + let s: [u8; 32] = hx(s_hex).try_into().unwrap(); + assert_eq!(calculate_interleaved(&s).to_vec(), hx(key_hex), "S={s_hex}"); + } + } + + #[test] + fn calculate_x_known_answer() { + // Fixed salt (big-endian in the corpus); user/pass → x (big-endian in the corpus). We store + // both little-endian, so reverse the corpus hex. + let salt: [u8; 32] = rev(hx( + "CAC94AF32D817BA64B13F18FDEDEF92AD4ED7EF7AB0E19E9F2AE13C828AEAF57", + )) + .try_into() + .unwrap(); + let cases = [ + ( + "00XD0QOSA9L8KMXC", + "43R4Z35TKBKFW8JI", + "E2F9A0F1E824006C98DA753448E743F7DAA1EAA1", + ), + ( + "01GJDP3DSFHR56JQ", + "9ZK1PFJ9LA0JSHPR", + "553A6123ABCFD539F2E0B77F64860C64675BC0FD", + ), + ]; + for (user, pass, x_hex) in cases { + let x = calculate_x( + &NormalizedString::new(user).unwrap(), + &NormalizedString::new(pass).unwrap(), + &salt, + ); + assert_eq!(x.to_vec(), rev(hx(x_hex)), "user={user}"); + } + } + + #[test] + fn password_verifier_golden() { + let salt: [u8; 32] = std::array::from_fn(|i| (i as u8).wrapping_mul(11).wrapping_add(2)); + let v = password_verifier( + &NormalizedString::new("alice").unwrap(), + &NormalizedString::new("password1").unwrap(), + &salt, + ); + assert_eq!( + v.to_vec(), + hx("28b837075a12b82553921d9095fa3fdcb0151c4bfc860ab97a69d0fa86a3d213") + ); + } + + #[test] + fn header_cipher_golden() { + let sk: [u8; 40] = std::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(3)); + let (_p, crypto) = ProofSeed::new().into_client_header_crypto( + &NormalizedString::new("alice").unwrap(), + sk, + 0xDEAD_BEEF, + ); + let (mut enc, mut dec) = crypto.split(); + assert_eq!( + enc.encrypt_client_header(12, 0x37F).to_vec(), + hx("03097792b1d7") + ); + assert_eq!( + enc.encrypt_client_header(0x1FF, 0xC7).to_vec(), + hx("03ceca0c55a5") + ); + let mut buf: Vec = (0..32u16).map(|i| (i as u8).wrapping_mul(13)).collect(); + dec.decrypt(&mut buf); + assert_eq!( + buf, + hx("03071c15122b2039364f445d5a5368617e778c85829b90a9a6bfb4cdcac3d8d1") + ); + } +} diff --git a/crates/benilla-srp/src/vanilla_header.rs b/crates/benilla-srp/src/vanilla_header.rs new file mode 100644 index 00000000..fab74ffa --- /dev/null +++ b/crates/benilla-srp/src/vanilla_header.rs @@ -0,0 +1,223 @@ +//! Vanilla (1.12) world-packet **header obfuscation**. +//! +//! After `CMSG_AUTH_SESSION`, every world-packet *header* (not the body) is run through a stateful +//! byte cipher keyed by the SRP session key. It is not a real cipher — each byte is XORed with a +//! rotating session-key byte and chained against the previous ciphertext byte: +//! +//! ```text +//! encrypt: c = (p ^ key[i]) + last_c ; decrypt: p = (c - last_c) ^ key[i] +//! ``` +//! +//! with `i` cycling over the 40-byte key and `last_c` the previous ciphertext byte (both start 0). +//! Client headers are 6 bytes (a `u16` BE size and `u32` LE opcode); server headers are **always 4** +//! (a `u16` BE size and `u16` LE opcode). There is no wider-size variant in 1.12: vmangos writes +//! every outgoing header as `ServerPktHeader { uint16 size; uint16 cmd; }` (WorldSocket.cpp — a +//! larger body is *truncated* into the u16, never given a wider field) and routes big object +//! updates through `SMSG_COMPRESSED_UPDATE_OBJECT` instead. The 3-byte large-packet size is a +//! later-expansion convention; nothing in this stack handles it, by design. +//! +//! The handshake: the server's `SMSG_AUTH_CHALLENGE` carries a random seed; the client picks its own +//! seed (a [`ProofSeed`]), proves it knows the session key via +//! [`ProofSeed::into_client_header_crypto`], and from then on encrypts/decrypts headers. + +use std::io::{Read, Write}; + +use rand::{thread_rng, RngCore}; +use sha1::{Digest, Sha1}; + +use crate::{NormalizedString, SESSION_KEY_LENGTH}; + +/// Client world-header length: `u16` size + `u32` opcode. +pub const CLIENT_HEADER_LENGTH: usize = 6; +/// Server world-header length: `u16` size + `u16` opcode. +pub const SERVER_HEADER_LENGTH: usize = 4; + +/// `world_server_proof = SHA1( user | 0u32 | client_seed | server_seed | K )`. +fn world_server_proof( + username: &NormalizedString, + session_key: &[u8; SESSION_KEY_LENGTH], + server_seed: u32, + client_seed: u32, +) -> [u8; 20] { + let mut h = Sha1::new(); + h.update(username.as_ref().as_bytes()); + h.update(0u32.to_le_bytes()); + h.update(client_seed.to_le_bytes()); + h.update(server_seed.to_le_bytes()); + h.update(session_key); + h.finalize().into() +} + +/// Encryption half of the header cipher — kept with the write half of a split connection. +#[derive(Debug, Clone)] +pub struct EncrypterHalf { + session_key: [u8; SESSION_KEY_LENGTH], + index: u8, + previous: u8, +} + +impl EncrypterHalf { + /// Encrypt `data` in place, advancing the cipher state. + pub fn encrypt(&mut self, data: &mut [u8]) { + for byte in data { + let encrypted = + (*byte ^ self.session_key[self.index as usize]).wrapping_add(self.previous); + self.index = (self.index + 1) % SESSION_KEY_LENGTH as u8; + *byte = encrypted; + self.previous = encrypted; + } + } + + /// Encrypt a client header (`u16` BE size + `u32` LE opcode). + pub fn encrypt_client_header(&mut self, size: u16, opcode: u32) -> [u8; CLIENT_HEADER_LENGTH] { + let size = size.to_be_bytes(); + let opcode = opcode.to_le_bytes(); + let mut header = [size[0], size[1], opcode[0], opcode[1], opcode[2], opcode[3]]; + self.encrypt(&mut header); + header + } + + /// Write an encrypted client header to `w`. + pub fn write_encrypted_client_header( + &mut self, + mut w: W, + size: u16, + opcode: u32, + ) -> std::io::Result<()> { + let header = self.encrypt_client_header(size, opcode); + w.write_all(&header) + } +} + +/// Decryption half of the header cipher — kept with the read half of a split connection. +#[derive(Debug, Clone)] +pub struct DecrypterHalf { + session_key: [u8; SESSION_KEY_LENGTH], + index: u8, + previous: u8, +} + +impl DecrypterHalf { + /// Decrypt `data` in place, advancing the cipher state. + pub fn decrypt(&mut self, data: &mut [u8]) { + for byte in data { + let encrypted = *byte; + let decrypted = + encrypted.wrapping_sub(self.previous) ^ self.session_key[self.index as usize]; + self.index = (self.index + 1) % SESSION_KEY_LENGTH as u8; + self.previous = encrypted; + *byte = decrypted; + } + } + + /// Decrypt a 4-byte server header into `(size, opcode)`. (1.12 server headers are always 4 + /// bytes — see the module doc; there is no wider-size variant to handle.) + pub fn decrypt_server_header(&mut self, mut data: [u8; SERVER_HEADER_LENGTH]) -> ServerHeader { + self.decrypt(&mut data); + ServerHeader { + size: u16::from_be_bytes([data[0], data[1]]), + opcode: u16::from_le_bytes([data[2], data[3]]), + } + } + + /// Read + decrypt a 4-byte server header from `r`. + pub fn read_and_decrypt_server_header( + &mut self, + mut r: R, + ) -> std::io::Result { + let mut buf = [0u8; SERVER_HEADER_LENGTH]; + r.read_exact(&mut buf)?; + Ok(self.decrypt_server_header(buf)) + } +} + +/// A decrypted server-packet header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServerHeader { + /// Body size in bytes, *including* the opcode field but not the size field. + pub size: u16, + /// Server opcode. + pub opcode: u16, +} + +/// Both halves of the header cipher. Split with [`Self::split`] for separate read/write paths. +#[derive(Debug, Clone)] +pub struct HeaderCrypto { + encrypt: EncrypterHalf, + decrypt: DecrypterHalf, +} + +impl HeaderCrypto { + /// Mutable access to the decryption half. + pub fn decrypter(&mut self) -> &mut DecrypterHalf { + &mut self.decrypt + } + + /// Mutable access to the encryption half. + pub fn encrypter(&mut self) -> &mut EncrypterHalf { + &mut self.encrypt + } + + /// Split into independent encrypt / decrypt halves (one per direction of a split socket). + pub fn split(self) -> (EncrypterHalf, DecrypterHalf) { + (self.encrypt, self.decrypt) + } + + /// The cipher built straight from a session key, skipping the proof step — the *peer* side of + /// [`ProofSeed::into_client_header_crypto`]. Both ends run the same state machine with inverse + /// operations from (index 0, previous 0), so a server's [`EncrypterHalf`] is exactly what a + /// client's [`DecrypterHalf`] inverts. Lets a test harness stand up a fake world server that + /// speaks real encrypted headers. + pub fn from_session_key(session_key: [u8; SESSION_KEY_LENGTH]) -> Self { + Self::new(session_key) + } + + fn new(session_key: [u8; SESSION_KEY_LENGTH]) -> Self { + Self { + encrypt: EncrypterHalf { + session_key, + index: 0, + previous: 0, + }, + decrypt: DecrypterHalf { + session_key, + index: 0, + previous: 0, + }, + } + } +} + +/// The client's random seed for the world handshake. Created before `CMSG_AUTH_SESSION`; turned into +/// the proof + [`HeaderCrypto`] once we know the server's seed. +#[derive(Debug, Clone, Copy)] +pub struct ProofSeed { + seed: u32, +} + +impl ProofSeed { + /// A new random client seed. + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + seed: thread_rng().next_u32(), + } + } + + /// The client seed to send in `CMSG_AUTH_SESSION`. + pub const fn seed(&self) -> u32 { + self.seed + } + + /// Compute the client proof (binds the session key to both seeds) and the [`HeaderCrypto`] used + /// for all subsequent headers. Valid once the server replies with a successful `SMSG_AUTH_RESPONSE`. + pub fn into_client_header_crypto( + self, + username: &NormalizedString, + session_key: [u8; SESSION_KEY_LENGTH], + server_seed: u32, + ) -> ([u8; 20], HeaderCrypto) { + let proof = world_server_proof(username, &session_key, server_seed, self.seed); + (proof, HeaderCrypto::new(session_key)) + } +} diff --git a/crates/benilla-ui/Cargo.toml b/crates/benilla-ui/Cargo.toml new file mode 100644 index 00000000..af1f704e --- /dev/null +++ b/crates/benilla-ui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "benilla-ui" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Engine-free WoW UI engine core: TOC/FrameXML parsing, templates, anchors, z-order — decision 0068" + +[dependencies] +roxmltree = "0.20" +# The embedded Lua runtime for the script host (decision 0068). `lua51` = Classic Era's Lua +# version; `vendored` builds PUC-Rio Lua from source (no system dependency). 0.11 matches the +# runtime that decision 0068's probe A verified (mlua + the LUAI_MAXCSTACK=8000 finding). +mlua = { version = "0.11", features = ["lua51", "vendored"] } diff --git a/crates/benilla-ui/src/framexml.rs b/crates/benilla-ui/src/framexml.rs new file mode 100644 index 00000000..520d818a --- /dev/null +++ b/crates/benilla-ui/src/framexml.rs @@ -0,0 +1,658 @@ +//! The FrameXML **document** layer (decision 0068): turns a `.xml` file's text into an owned, +//! order-preserving tree, resolves `virtual`/`inherits` template expansion, and rewrites `$parent` +//! name tokens. This is XML-tree semantics only — no widget instantiation (frame factories, anchor +//! resolution, script compilation) and no Lua; those live in the layer above, which walks the tree +//! this module produces. +//! +//! Ground truth is wow-5875-re's verified transcription of the real client's loader +//! (`0x6edc00–0x6f3000`, closed VERIFIED ORCHESTRATION region): +//! - `system/ui/ui.md` §"The FrameXML XML loader — schema → frame-op interpretation (RF-0024)" +//! - `system/ui/scratch/rf24-framexml-loader.md` — the full top-level + per-element op map +//! - `system/ui/scratch/rf26-nested-frames.md` — `` / load-order (widget-layer concern; cited +//! here only for the "spliced before the instance's own nodes" ordering that also governs template +//! `` children, which this module's generic child-concat already gets right) +//! - `system/ui/scratch/rf27-parent-name-token.md` — the `$parent` substitution rule (RF-0027) +//! +//! XML *parsing* is DELEGATED plumbing per the RE spec ("XML parsing is DELEGATED; the schema→op map +//! is owned") — we use `roxmltree` rather than reproduce a tokenizer; only the schema→op +//! interpretation below is transcribed from the spec. + +use std::collections::{HashMap, HashSet}; +use std::fmt; + +/// An owned XML element: tag, attributes (order preserved — Blizzard XML attribute order is +/// sometimes meaningful for authoring tools, and always useful for round-tripping/debugging), and +/// element children. Case-insensitive attribute lookup: "attrs via `GetAttribute 0x6f2cf0`" and +/// element-name compares are case-insensitive in the real loader (`0x64a4c0`, +/// rf24-framexml-loader.md, header), and Blizzard's own shipped XML is inconsistent about attribute +/// casing (e.g. both `relativeTo` and stray all-lowercase variants appear in the wild), so lookups +/// here fold case rather than assume a canonical spelling. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Element { + pub tag: String, + attrs: Vec<(String, String)>, + pub children: Vec, + /// The node's own direct text content (the XML node struct's `body-text @ +0xc`, per + /// rf24-framexml-loader.md's node-struct note). Populated for text-bearing elements such as + /// `` handler bodies (`lua…`) and inline ``). Both run in document order relative to everything else at the top +/// level ("` + + + "#, + ) + .unwrap(); + + let kinds: Vec<&str> = doc + .items + .iter() + .map(|item| match item { + TopLevel::Include(_) => "include", + TopLevel::Script(ScriptRef::File(_)) => "script-file", + TopLevel::Script(ScriptRef::Inline(_)) => "script-inline", + TopLevel::Font(_) => "font", + TopLevel::Template(_) => "template", + TopLevel::Instance(_) => "instance", + }) + .collect(); + assert_eq!( + kinds, + vec![ + "script-file", + "instance", + "script-inline", + "include", + "instance" + ], + "document order must be preserved, not grouped by kind" + ); + + match &doc.items[0] { + TopLevel::Script(ScriptRef::File(f)) => assert_eq!(f, "A.lua"), + other => panic!("expected file script, got {other:?}"), + } + assert!(doc.warnings.is_empty()); + } + + #[test] + fn inline_vs_file_script() { + let doc = parse( + r#" + + "#, + ) + .unwrap(); + assert_eq!( + doc.items[0], + TopLevel::Script(ScriptRef::File("Foo.lua".to_string())) + ); + match &doc.items[1] { + TopLevel::Script(ScriptRef::Inline(body)) => { + assert!(body.contains("local x = 1")); + assert!(body.contains("print(x)")); + } + other => panic!("expected inline script, got {other:?}"), + } + } + + #[test] + fn virtual_registers_as_template_non_virtual_as_instance() { + let doc = parse( + r#" + + + + "#, + ) + .unwrap(); + assert!(matches!(doc.items[0], TopLevel::Template(_))); + assert!(matches!(doc.items[1], TopLevel::Instance(_))); + assert!( + matches!(doc.items[2], TopLevel::Template(_)), + "virtual attribute name/value compare is case-insensitive" + ); + let templates = doc.templates(); + assert!(templates.contains_key("MyTemplate")); + assert!(templates.contains_key("CasedTemplate")); + assert_eq!(templates.len(), 2); + } + + #[test] + fn unnamed_virtual_warns_but_does_not_fail() { + let doc = parse(r#""#).unwrap(); + assert!(matches!(doc.items[0], TopLevel::Template(_))); + assert_eq!(doc.warnings.len(), 1); + assert!( + doc.warnings[0].contains("Unnamed virtual node") + || doc.warnings[0].contains("without a name") + ); + assert!( + doc.templates().is_empty(), + "an unnamed template cannot be inherited" + ); + } + + #[test] + fn unknown_top_level_tag_is_tolerated_as_generic_element() { + let doc = parse(r#""#).unwrap(); + match &doc.items[0] { + TopLevel::Instance(el) => { + assert_eq!(el.tag, "TotallyMadeUpTag"); + assert_eq!(el.attr("foo"), Some("bar")); + } + other => panic!("expected a generic instance, got {other:?}"), + } + assert!(doc.warnings.is_empty()); + } + + #[test] + fn multi_inherits_merges_attrs_override_and_children_concat_in_order() { + let doc = parse( + r#" + + + + + + + + + + "#, + ) + .unwrap(); + let templates = doc.templates(); + let TopLevel::Instance(inst) = &doc.items[2] else { + panic!("expected instance") + }; + let mut warnings = Vec::new(); + let expanded = expand(inst, &templates, &mut warnings); + assert!(warnings.is_empty()); + + // Instance's own `alpha` overrides B's (which would otherwise have overridden A's absence). + assert_eq!(expanded.attr("alpha"), Some("1.0")); + + // Children: A's first, then B's , then the instance's own — + // inherited-first, instance-own-last (rf24-framexml-loader.md splice order). + assert_eq!(expanded.children.len(), 3); + fn texture_name(el: &Element) -> &str { + el.children[0].children[0].attr("name").unwrap() + } + assert_eq!(texture_name(&expanded.children[0]), "FromA"); + assert_eq!(texture_name(&expanded.children[1]), "FromB"); + assert_eq!(texture_name(&expanded.children[2]), "FromInst"); + } + + #[test] + fn inheritance_chain_recurses() { + let doc = parse( + r#" + + + + + + + + "#, + ) + .unwrap(); + let templates = doc.templates(); + let TopLevel::Instance(leaf) = &doc.items[2] else { + panic!("expected instance") + }; + let mut warnings = Vec::new(); + let expanded = expand(leaf, &templates, &mut warnings); + assert!(warnings.is_empty()); + // Mid's own frameStrata (it overrode Root's) wins, since chain-expanding Mid resolves Root + // first and Mid's own attrs on top, before Leaf (which sets nothing) merges on top of that. + assert_eq!(expanded.attr("frameStrata"), Some("MEDIUM")); + assert_eq!( + expanded.children.len(), + 2, + "Root's then Mid's , in order" + ); + } + + #[test] + fn inheritance_cycle_warns_and_terminates() { + let doc = parse( + r#" + + + + "#, + ) + .unwrap(); + let templates = doc.templates(); + let TopLevel::Instance(inst) = &doc.items[2] else { + panic!("expected instance") + }; + let mut warnings = Vec::new(); + // Must terminate (not infinitely recurse) and report the cycle. + let _expanded = expand(inst, &templates, &mut warnings); + assert!( + warnings.iter().any(|w| w.contains("cycle")), + "expected a cycle warning, got {warnings:?}" + ); + } + + #[test] + fn parent_name_substitution_basic_and_case_insensitive() { + assert_eq!( + resolve_name("$parentHealthBar", "PlayerFrame"), + "PlayerFrameHealthBar" + ); + assert_eq!( + resolve_name("$PARENThealthbar", "PlayerFrame"), + "PlayerFramehealthbar" + ); + assert_eq!(resolve_name("$Parent", "PlayerFrame"), "PlayerFrame"); + assert_eq!( + resolve_name("NotAParentName", "PlayerFrame"), + "NotAParentName" + ); + // No named ancestor: caller passes the literal "Top" fallback. + assert_eq!(resolve_name("$parentFoo", DEFAULT_PARENT_NAME), "TopFoo"); + } + + #[test] + fn parent_name_substitution_composes_through_nested_children() { + // A named parent, a child using $parent, and a grandchild using $parent against the + // child's *resolved* name — not the child's raw literal "$parent..." text (rf27 rule 4). + let parent_name = "PlayerFrame"; + let child_name = resolve_name("$parentHealthBar", parent_name); + assert_eq!(child_name, "PlayerFrameHealthBar"); + let grandchild_name = resolve_name("$parentText", &child_name); + assert_eq!(grandchild_name, "PlayerFrameHealthBarText"); + } + + /// Parses a REAL shipped FrameXML file when `BENILLA_FRAMEXML` points at one (never committed — + /// extract e.g. `Interface\FrameXML\PlayerFrame.xml` with benilla-extract). Skips silently + /// otherwise, matching toc.rs's `real_manifest_when_available` pattern so CI/gates don't depend + /// on client data. + #[test] + fn real_framexml_when_available() { + let Ok(path) = std::env::var("BENILLA_FRAMEXML") else { + return; + }; + let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML"); + let doc = parse(&text).unwrap_or_else(|e| panic!("{path}: {e}")); + assert!( + !doc.items.is_empty(), + "{path}: a real FrameXML file has at least one top-level item" + ); + for w in &doc.warnings { + eprintln!("{path}: warning: {w}"); + } + } +} diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs new file mode 100644 index 00000000..01824f1b --- /dev/null +++ b/crates/benilla-ui/src/layout.rs @@ -0,0 +1,1359 @@ +//! The frame anchor/layout resolver — anchor graph → resolved rects (decision 0068). +//! +//! This is a faithful transcription of the WoW 1.12.1 client's `CLayoutFrame` geometry resolver, +//! whose math is reverse-engineered bit-exact against `WoW.exe` in the sibling repo `wow-5875-re` +//! (`system/ui/scratch/layout.md`, `.../rf32-composed-resolver.md`, `.../frame-model.md`, +//! `.../propagation.md`). The leaf float kernels and the recursion-guarded per-edge composition are +//! transcribed from that repo's own verified `w5875-ui::layout` crate +//! (`wow-5875-re/crates/ui/src/layout.rs`), which is gated bit-exact under a Unicorn/`WoW.exe` +//! difftest harness (per-function diffs + the composed `assemble` over 6006 synthesized cases). +//! Matching that crate's operations and their order — not "improving" the arithmetic — is the goal. +//! +//! ## Precision — why `f64` intermediates, not naive `f32` +//! +//! The client is an x87 binary: every intermediate lives in an FPU register at PC_53 (53-bit +//! mantissa = IEEE `f64` precision) and is narrowed to `f32` *only* at the documented `fstp m32` +//! store sites (each sub-edge result before a `combine_*`; each final edge into the out-rect; the +//! `width·scale` span). A leaf like `(right + left) * 0.5 + xOff·scale` therefore rounds **once** +//! (at the final store), whereas doing every operation in `f32` would round at each step and +//! diverge. So "the client is f32" is true only at the storage boundaries: to be bit-faithful the +//! chain of `+`/`-`/`*0.5` runs in `f64` (host-exact — every operand is `f32`-derived, so each op is +//! exact in `f64`) with an explicit `as f32` at exactly the sites the RE pins. This mirrors +//! `w5875-ui::layout` verbatim. +//! +//! ## Coordinate space +//! +//! Rects are stored in the client's own convention: `[bottom, left, top, right]` (frame-absolute +//! `+0x64/+0x68/+0x6c/+0x70`; VERIFIED via `GetRect 0x768320` + the Lua `GetBottom/Left/Top/Right` +//! readers, `layout.md`). WoW UI space is **y-up**: `top > bottom`, the screen root's rect is the +//! physical screen (the anchor chain bottoms out at the `CSimpleTop` root), and `width = right − +//! left`, `height = top − bottom`. Coordinates are screen pixels throughout; `scale` (layoutScale = +//! effective scale, `propagation.md`) scales only the per-anchor offsets and the `width/height` span. + +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Constants (objdump-VERIFIED in layout.md "Constants") +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// UNSET coordinate sentinel: `+Infinity` (the `.data` slot `0xcf550c`, runtime-initialized from +/// `[0x81d56c] = 0x7f800000`, `layout.md`). "coordinate is set" ⇔ `x != +Inf`. Intermediates carry +/// this in `f64`; the `f32` narrowing preserves it (`+Inf as f32 == +Inf`). +const UNSET: f64 = f64::INFINITY; + +/// `OnSizeChanged` trigger ε — `_DAT_008029d4` = `0x34800000` ≈ 2.384e-7 (`layout.md` Constants; +/// applied in `ApplyRect 0x76b580`). `OnSizeChanged(width,height)` fires iff `|Δwidth| ≥ ε` OR +/// `|Δheight| ≥ ε` between the old and new resolved rect. See [`size_changed`]. +pub const SIZE_EPS: f64 = 2.384_185_791_015_625e-7; + +/// Recompute change threshold ε — `_DAT_0080c5c8` = `0x3727c5ac` ≈ 9.96e-6 (`layout.md`; applied in +/// `recompute 0x768d20`): an edge must move at least this far vs the cached rect for a re-store + +/// `ApplyRect`. Benilla owns its own caching (see [`LayoutCache`]); this const records the client's +/// documented threshold for fidelity work that needs it. +pub const RESOLVE_EPS: f64 = f32::from_bits(0x3727_c5ac) as f64; + +/// point-id → X column (VERIFIED table `0x81c3b8`.., `layout.md` "Point-id → coordinate"): +/// `{0,3,6}` (LEFT column) → 0, `{1,4,7}` (center) → 1, `{2,5,8}` (RIGHT column) → 2. +const X_COL: [u8; 9] = [0, 1, 2, 0, 1, 2, 0, 1, 2]; +/// point-id → Y row: `{0,1,2}` (TOP row) → 0, `{3,4,5}` (center) → 1, `{6,7,8}` (BOTTOM row) → 2. +const Y_ROW: [u8; 9] = [0, 0, 0, 1, 1, 1, 2, 2, 2]; + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Public types +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// The nine anchor points, with the client's exact discriminants (VERIFIED static tables +/// `0x81c3b8–0x81c3fc`, `layout.md`): the id is the slot index in the client's `anchorPoints[9]`. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Point { + TopLeft = 0, + Top = 1, + TopRight = 2, + Left = 3, + Center = 4, + Right = 5, + BottomLeft = 6, + Bottom = 7, + BottomRight = 8, +} + +impl Point { + /// The point-id (0..=8), which is also the `anchorPoints[9]` slot index. + #[inline] + pub const fn id(self) -> u8 { + self as u8 + } + + /// Build a [`Point`] from its client id, `None` if out of range. + pub const fn from_id(id: u8) -> Option { + Some(match id { + 0 => Point::TopLeft, + 1 => Point::Top, + 2 => Point::TopRight, + 3 => Point::Left, + 4 => Point::Center, + 5 => Point::Right, + 6 => Point::BottomLeft, + 7 => Point::Bottom, + 8 => Point::BottomRight, + _ => return None, + }) + } +} + +/// An opaque handle to an anchor target. This crate does not own the frame arena; the caller maps +/// handles to frames / external rects. (In the client this is the `CAnchor+0xc relativeTo` pointer.) +pub type Handle = u32; + +/// A resolved rect in the client's `[bottom, left, top, right]` convention (screen pixels, y-up). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub bottom: f32, + pub left: f32, + pub top: f32, + pub right: f32, +} + +impl Rect { + /// Construct from the four edges in client order. + pub const fn new(bottom: f32, left: f32, top: f32, right: f32) -> Rect { + Rect { + bottom, + left, + top, + right, + } + } + + /// `[bottom, left, top, right]` — the client's cached-rect layout (`+0x64..0x70`). + #[inline] + pub const fn to_array(self) -> [f32; 4] { + [self.bottom, self.left, self.top, self.right] + } + + /// From `[bottom, left, top, right]`. + #[inline] + pub const fn from_array(a: [f32; 4]) -> Rect { + Rect::new(a[0], a[1], a[2], a[3]) + } + + /// `right − left` (`GetWidth` semantics, `layout.md`). + #[inline] + pub fn width(self) -> f32 { + self.right - self.left + } + + /// `top − bottom` (`GetHeight` semantics; y-up). + #[inline] + pub fn height(self) -> f32 { + self.top - self.bottom + } +} + +/// One anchor of a frame — a `CAnchor` record (`{xOff@4, yOff@8, relativeTo@0xc, relativePoint@0x10}`, +/// `frame-model.md`). `point` is the point *on this frame* being pinned (its `anchorPoints[9]` slot); +/// `relative_point` is the point *on the target* it pins to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Anchor { + /// The point on *this* frame that is being anchored (its `anchorPoints[9]` slot index). + pub point: Point, + /// The target frame/region this anchor is relative to (`CAnchor+0xc`). + pub relative_to: Handle, + /// The point on the *target* this anchor pins to (`CAnchor+0x10 relativePoint`). + pub relative_point: Point, + /// X offset (`CAnchor+4`), scaled by the requesting frame's `scale`. + pub x_off: f32, + /// Y offset (`CAnchor+8`), scaled by `scale`. + pub y_off: f32, +} + +impl Anchor { + /// Convenience constructor. + pub fn new( + point: Point, + relative_to: Handle, + relative_point: Point, + x_off: f32, + y_off: f32, + ) -> Anchor { + Anchor { + point, + relative_to, + relative_point, + x_off, + y_off, + } + } +} + +/// A frame's full layout input: its anchors, its optional explicit size, its scale, and the +/// clamp-to-screen flag + extents. Mirrors the client's geometry subobject fields consulted by the +/// resolver (`frame-model.md`, `rf32-composed-resolver.md`): `anchorPoints[9]` (`G+0x28`), `width` +/// (`G+0x50`), `height` (`G+0x54`), `layoutScale` (`G+0x58`), flags bit4 = clamp (`G+0x3c`). +#[derive(Clone, Debug, PartialEq)] +pub struct LayoutInput { + /// The frame's anchors. Placed into the nine `anchorPoints` slots by their `point`; if two share + /// a `point`, the *last* wins (the client's `SetPoint 0x767c70` overwrites the slot). + pub anchors: Vec, + /// Explicit width (`G+0x50`); **`0.0` ⇒ derive from opposing anchors** (the no-explicit-size + /// sentinel `DAT_007ffd74`, `layout.md`). + pub width: f32, + /// Explicit height (`G+0x54`); `0.0` ⇒ derive from opposing anchors. + pub height: f32, + /// `layoutScale` = effective scale (`parentScale · ownScale`, ε-gated, `propagation.md`); the + /// px↔coord multiplier applied to offsets and the size span. Default `1.0`. + pub scale: f32, + /// Clamp the assembled rect into `[0, extent]` per axis (`G` flags bit4, `layout.md` Assemble). + pub clamp: bool, + /// Screen X extent, only consulted when `clamp` (client `0x41ae60` return, default `0.8`). + pub extent_x: f32, + /// Screen Y extent, only consulted when `clamp` (client `0x41ae70` return, default `0.6`). + pub extent_y: f32, +} + +impl Default for LayoutInput { + fn default() -> LayoutInput { + LayoutInput { + anchors: Vec::new(), + width: 0.0, + height: 0.0, + scale: 1.0, + clamp: false, + extent_x: 0.8, + extent_y: 0.6, + } + } +} + +impl LayoutInput { + /// A frame with the given anchors and explicit size, scale `1.0`, no clamp. + pub fn sized(anchors: Vec, width: f32, height: f32) -> LayoutInput { + LayoutInput { + anchors, + width, + height, + ..LayoutInput::default() + } + } +} + +/// The outcome of resolving one frame's rect. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResolveOutcome { + /// Every edge resolved → the cached rect `[bottom, left, top, right]` (`assemble` returned 1). + Resolved(Rect), + /// At least one edge stayed `+Inf` — under-constrained (`assemble 0x767a20` returns 0, caller + /// sets flags bit3 "resolution-failed"). + Unresolvable, +} + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Leaf float kernels — the owned-fidelity math (layout.md). Transcribed from w5875-ui::layout, +// binary-diffed. Each returns its coordinate in `f64` (the un-narrowed x87 `st0` observable); +// callers narrow to `f32` at the documented store sites. +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// `combineEdge 0x767440(center, opp, span)` (`layout.md`): resolve one edge from the opposite +/// edge, the axis center, and the signed `size·scale` span, in that fallback order. +/// +/// - opposite known and non-zero span → `opp + span` (edge = opposite ± size·scale); +/// - else center known: non-zero span → `span·0.5 + center` (edge = center ± half-size); +/// else opposite known → `(center − opp) + center` (= 2·center − opposite, the mirror); +/// - else → `+Inf` (unresolvable). +/// +/// The `+Inf`/`0.0` sentinels are compared with IEEE equality, matching the binary's `fcomp` +/// against the `.data` slots (a NaN operand is unordered ⇒ "not equal to the sentinel"). +#[allow(clippy::float_cmp)] +pub fn combine_edge(center: f32, opp: f32, span: f32) -> f64 { + let (c, o, s) = (f64::from(center), f64::from(opp), f64::from(span)); + if o != UNSET && s != 0.0 { + return o + s; + } + if c != UNSET { + if s != 0.0 { + return s * 0.5 + c; + } + if o != UNSET { + return (c - o) + c; + } + } + UNSET +} + +/// `combineCenter 0x7672d0(lo, hi, span)` (`layout.md`): resolve an axis center from the low edge, +/// the high edge, and the signed `size·scale` span. +/// +/// - low known: high known → `(lo+hi)·0.5`; else non-zero span → `span·0.5 + lo`; +/// - else high known and non-zero span → `hi − span·0.5`; +/// - else → `+Inf`. +#[allow(clippy::float_cmp)] +pub fn combine_center(lo: f32, hi: f32, span: f32) -> f64 { + let (l, h, s) = (f64::from(lo), f64::from(hi), f64::from(span)); + if l != UNSET { + if h != UNSET { + return (l + h) * 0.5; + } + if s != 0.0 { + return s * 0.5 + l; + } + } + if h != UNSET && s != 0.0 { + return h - s * 0.5; + } + UNSET +} + +/// `CAnchor::ResolveX 0x7a2f90(scale)` (`layout.md`): resolve this anchor's X coordinate against its +/// `relativeTo`'s cached rect. +/// +/// `rect` = the target's cached rect `[bottom, left, top, right]` when `GetRect` succeeded (cache +/// valid), `None` when it failed ⇒ `+Inf`. `mirror` = the target's geometry-vtable `+0x24` +/// normalize predicate (RTL/local-origin): when set, X edges are translated so `left → 0`, +/// `right → right − left` (each stored back as `f32`, the binary's `fstp m32`). For an ordinary +/// frame the predicate returns 0, so `mirror` is `false` (VERIFIED `0x46ff60 = xor eax,eax; ret`, +/// `rf32-composed-resolver.md`). `coord = xOff·scale + edge` for the relativePoint's column +/// (center = mid-X + xOff·scale). `rel_point > 8` ⇒ `+Inf`. +pub fn anchor_resolve_x( + scale: f32, + x_off: f32, + rel_point: u32, + rect: Option<[f32; 4]>, + mirror: bool, +) -> f64 { + let Some([_bottom, mut left, _top, mut right]) = rect else { + return UNSET; + }; + if mirror { + right = (-f64::from(left) + f64::from(right)) as f32; + left = 0.0; + } + if rel_point > 8 { + return UNSET; + } + let (s, xo) = (f64::from(scale), f64::from(x_off)); + let (l, r) = (f64::from(left), f64::from(right)); + match X_COL[rel_point as usize] { + 0 => s * xo + l, // relLeft + 1 => (r + l) * 0.5 + s * xo, // center + _ => s * xo + r, // relRight + } +} + +/// `CAnchor::ResolveY 0x7a3070(scale)` — the Y twin of [`anchor_resolve_x`] (`layout.md`). `mirror` +/// normalizes the Y edges (`bottom → 0`, `top → top − bottom`); `coord = yOff·scale + edge` for the +/// relativePoint's row (center = mid-Y + yOff·scale). `rect` = `[bottom, left, top, right]`. +pub fn anchor_resolve_y( + scale: f32, + y_off: f32, + rel_point: u32, + rect: Option<[f32; 4]>, + mirror: bool, +) -> f64 { + let Some([mut bottom, _left, mut top, _right]) = rect else { + return UNSET; + }; + if mirror { + top = (-f64::from(bottom) + f64::from(top)) as f32; + bottom = 0.0; + } + if rel_point > 8 { + return UNSET; + } + let (s, yo) = (f64::from(scale), f64::from(y_off)); + let (b, t) = (f64::from(bottom), f64::from(top)); + match Y_ROW[rel_point as usize] { + 0 => s * yo + t, // relTop + 1 => (t + b) * 0.5 + s * yo, // center + _ => s * yo + b, // relBottom + } +} + +/// The assembled (and possibly clamped) rect + whether every edge resolved. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AssembleRect { + pub ok: bool, + /// `[bottom, left, top, right]` (meaningful only when `ok`). + pub rect: [f32; 4], +} + +/// `assemble 0x767a20` (`layout.md`): given the four resolved edge coordinates `[bottom, left, top, +/// right]` (each `+Inf` ⇒ unresolvable), fail if any is `+Inf`; else optionally clamp into +/// `[0, extent]` per axis. +/// +/// Clamp (geom flags bit4): a low edge `< 0` shifts to 0 carrying the span to the high edge; a high +/// edge past the screen extent shifts back carrying to the low edge, high edge = extent. Each store +/// is narrowed to `f32`. +#[allow(clippy::float_cmp)] +pub fn assemble_rect(edges: [f32; 4], clamp: bool, extent_x: f32, extent_y: f32) -> AssembleRect { + let [mut bottom, mut left, mut top, mut right] = edges; + let inf = f32::INFINITY; + if bottom == inf || left == inf || top == inf || right == inf { + return AssembleRect { + ok: false, + rect: edges, + }; + } + if clamp { + if (left as f64) < 0.0 { + right = (f64::from(right) - f64::from(left)) as f32; + left = 0.0; + } + if (bottom as f64) < 0.0 { + top = (f64::from(top) - f64::from(bottom)) as f32; + bottom = 0.0; + } + if (right as f64) > (extent_x as f64) { + let over = f64::from(right) - f64::from(extent_x); + left = (f64::from(left) - over) as f32; + right = extent_x; + } + if (top as f64) > (extent_y as f64) { + let over = f64::from(top) - f64::from(extent_y); + bottom = (f64::from(bottom) - over) as f32; + top = extent_y; + } + } + AssembleRect { + ok: true, + rect: [bottom, left, top, right], + } +} + +/// `OnSizeChanged` fire test (`ApplyRect 0x76b580`, `layout.md`): fires iff `|Δwidth| ≥ [`SIZE_EPS`]` +/// OR `|Δheight| ≥ SIZE_EPS`, with `width = right − left`, `height = top − bottom`. Deltas are `f64` +/// of the `f32` edges (the binary's `fsub` of the `fld`'d rect components). +pub fn size_changed(old: Rect, new: Rect) -> bool { + let dw = + (f64::from(new.right) - f64::from(new.left)) - (f64::from(old.right) - f64::from(old.left)); + let dh = + (f64::from(new.top) - f64::from(new.bottom)) - (f64::from(old.top) - f64::from(old.bottom)); + dw.abs() >= SIZE_EPS || dh.abs() >= SIZE_EPS +} + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// The composed resolver — recursion-guarded per-edge sequencing over the leaves above. +// Transcribed from w5875-ui::layout (RF-0032, rf32-composed-resolver.md): bit-exact over the +// binary's `assemble 0x767a20` → six per-edge resolvers → anchor scans → leaf kernels. Adds NO new +// arithmetic — only integer control flow + the documented `fstp m32` `f32` narrowing at each site. +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// A resolved anchor for the internal edge tree: like [`Anchor`] but with the target's rect already +/// looked up (`None` ⇒ target had no valid rect ⇒ `GetRect`-fail leg). +#[derive(Clone, Copy)] +struct AnchorRel { + x_off: f32, + y_off: f32, + rel_point: u32, + rel_rect: Option<[f32; 4]>, + mirror: bool, +} + +/// Internal per-edge resolver input: the nine `anchorPoints` slots (by point-id) with rects +/// resolved, plus the size/scale/clamp fields. +struct EdgeInput { + anchors: [Option; 9], + width: f32, + height: f32, + scale: f32, + clamp: bool, + extent_x: f32, + extent_y: f32, +} + +// recursion-guard bits (the binary's `G+0x28` bitfield, layout.md). +const G_LEFT: u32 = 0x01; +const G_TOP: u32 = 0x02; +const G_RIGHT: u32 = 0x04; +const G_BOTTOM: u32 = 0x08; +const G_XC: u32 = 0x10; +const G_YC: u32 = 0x20; + +// per-edge point-id scan tables (`.rdata` 0x81c3b8..0x81c3f4, rf32-composed-resolver.md). +const IDS_LEFT: [usize; 3] = [0, 3, 6]; +const IDS_RIGHT: [usize; 3] = [2, 5, 8]; +const IDS_TOP: [usize; 3] = [0, 1, 2]; +const IDS_BOTTOM: [usize; 3] = [6, 7, 8]; +const IDS_XC: [usize; 3] = [1, 4, 7]; +const IDS_YC: [usize; 3] = [3, 4, 5]; + +/// `anchorScanX 0x7671a0`: over the edge's three point-ids, the first present anchor whose +/// `ResolveX` is non-`+Inf` wins; else `+Inf`. +#[allow(clippy::float_cmp)] +fn anchor_scan_x(ids: &[usize; 3], f: &EdgeInput) -> f64 { + for &id in ids { + if let Some(a) = f.anchors[id] { + let v = anchor_resolve_x(f.scale, a.x_off, a.rel_point, a.rel_rect, a.mirror); + if v != UNSET { + return v; + } + } + } + UNSET +} + +/// `anchorScanY 0x7671f0` — the Y twin. +#[allow(clippy::float_cmp)] +fn anchor_scan_y(ids: &[usize; 3], f: &EdgeInput) -> f64 { + for &id in ids { + if let Some(a) = f.anchors[id] { + let v = anchor_resolve_y(f.scale, a.y_off, a.rel_point, a.rel_rect, a.mirror); + if v != UNSET { + return v; + } + } + } + UNSET +} + +/// `width·scale` / `height·scale` — the `layout_767_span` PRIMITIVE (`rf32-composed-resolver.md`): +/// operands are `f32` fields, the product narrowed to `f32` at the `fstp [esp]` site, negated for +/// LEFT/BOTTOM. +fn size_span(size: f32, scale: f32, negate: bool) -> f32 { + let span = (f64::from(size) * f64::from(scale)) as f32; + if negate { + -span + } else { + span + } +} + +/// `LEFT 0x7673d0` — direct anchor scan, else `combineEdge(Xcenter, RIGHT, −width·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_left(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_LEFT != 0 { + return UNSET; + } + *guard |= G_LEFT; + let v = anchor_scan_x(&IDS_LEFT, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.width, f.scale, true); + let opp = resolve_right(f, guard) as f32; + let center = resolve_xcenter(f, guard) as f32; + combine_edge(center, opp, span) + }; + *guard &= !G_LEFT; + r +} + +/// `RIGHT 0x767540` — direct anchor scan, else `combineEdge(Xcenter, LEFT, +width·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_right(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_RIGHT != 0 { + return UNSET; + } + *guard |= G_RIGHT; + let v = anchor_scan_x(&IDS_RIGHT, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.width, f.scale, false); + let opp = resolve_left(f, guard) as f32; + let center = resolve_xcenter(f, guard) as f32; + combine_edge(center, opp, span) + }; + *guard &= !G_RIGHT; + r +} + +/// `TOP 0x7674d0` — direct anchor scan, else `combineEdge(Ycenter, BOTTOM, +height·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_top(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_TOP != 0 { + return UNSET; + } + *guard |= G_TOP; + let v = anchor_scan_y(&IDS_TOP, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.height, f.scale, false); + let opp = resolve_bottom(f, guard) as f32; + let center = resolve_ycenter(f, guard) as f32; + combine_edge(center, opp, span) + }; + *guard &= !G_TOP; + r +} + +/// `BOTTOM 0x7675b0` — direct anchor scan, else `combineEdge(Ycenter, TOP, −height·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_bottom(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_BOTTOM != 0 { + return UNSET; + } + *guard |= G_BOTTOM; + let v = anchor_scan_y(&IDS_BOTTOM, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.height, f.scale, true); + let opp = resolve_top(f, guard) as f32; + let center = resolve_ycenter(f, guard) as f32; + combine_edge(center, opp, span) + }; + *guard &= !G_BOTTOM; + r +} + +/// `Xcenter 0x767260` — direct anchor scan, else `combineCenter(LEFT, RIGHT, +width·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_xcenter(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_XC != 0 { + return UNSET; + } + *guard |= G_XC; + let v = anchor_scan_x(&IDS_XC, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.width, f.scale, false); + let lo = resolve_left(f, guard) as f32; + let hi = resolve_right(f, guard) as f32; + combine_center(lo, hi, span) + }; + *guard &= !G_XC; + r +} + +/// `Ycenter 0x767360` — direct anchor scan, else `combineCenter(BOTTOM, TOP, +height·scale)`. +#[allow(clippy::float_cmp)] +fn resolve_ycenter(f: &EdgeInput, guard: &mut u32) -> f64 { + if *guard & G_YC != 0 { + return UNSET; + } + *guard |= G_YC; + let v = anchor_scan_y(&IDS_YC, f); + let r = if v != UNSET { + v + } else { + let span = size_span(f.height, f.scale, false); + let lo = resolve_bottom(f, guard) as f32; + let hi = resolve_top(f, guard) as f32; + combine_center(lo, hi, span) + }; + *guard &= !G_YC; + r +} + +/// `assemble 0x767a20` composed: resolve all four edges (each `fst`'d to the `f32` out-rect), +/// returning [`ResolveOutcome::Unresolvable`] if any stayed `+Inf`, else the assembled (and, when +/// `clamp`, screen-clamped) rect. The four top-level resolves share a fresh guard; the binary +/// clears each edge's bit on return, so order is immaterial — we keep the binary's LEFT→BOTTOM→ +/// RIGHT→TOP order. The `+Inf` test is on the un-narrowed `f64` edge (the binary's `fcomp` before +/// the `fst`). +#[allow(clippy::float_cmp)] +fn resolve_edges(f: &EdgeInput) -> ResolveOutcome { + let mut guard = 0u32; + let left = resolve_left(f, &mut guard); + let bottom = resolve_bottom(f, &mut guard); + let right = resolve_right(f, &mut guard); + let top = resolve_top(f, &mut guard); + if left == UNSET || bottom == UNSET || right == UNSET || top == UNSET { + return ResolveOutcome::Unresolvable; + } + let edges = [bottom as f32, left as f32, top as f32, right as f32]; + let a = assemble_rect(edges, f.clamp, f.extent_x, f.extent_y); + ResolveOutcome::Resolved(Rect::from_array(a.rect)) +} + +/// Resolve one frame's rect. `target_rect` supplies each anchor target's already-resolved rect +/// (`None` ⇒ the target has no valid rect ⇒ that anchor contributes `+Inf`, the `GetRect`-fail +/// leg). Anchors are placed into the nine `anchorPoints` slots by their `point`; on a duplicate the +/// later anchor wins (the client's `SetPoint` overwrite). +/// +/// This is the fidelity surface: it builds the client's `anchorPoints[9]` view and runs the +/// verified composed `assemble` (`rf32-composed-resolver.md`). +pub fn resolve_rect( + frame: &LayoutInput, + target_rect: impl FnMut(Handle) -> Option, +) -> ResolveOutcome { + resolve_edges(&build_edge_input(frame, target_rect)) +} + +/// Build the client's `anchorPoints[9]` view + size/scale/clamp fields for `frame`, resolving each +/// anchor target's rect through `target_rect`. Shared by [`resolve_rect`] (all-or-nothing) and +/// [`resolve_rect_edges`] (per-edge). +fn build_edge_input( + frame: &LayoutInput, + mut target_rect: impl FnMut(Handle) -> Option, +) -> EdgeInput { + let mut slots: [Option; 9] = [None; 9]; + for a in &frame.anchors { + slots[a.point.id() as usize] = Some(AnchorRel { + x_off: a.x_off, + y_off: a.y_off, + rel_point: a.relative_point.id() as u32, + rel_rect: target_rect(a.relative_to).map(Rect::to_array), + // Ordinary frames' normalize predicate returns 0 (rf32-composed-resolver.md); RTL/mirror + // frames are not modeled here (the leaf `anchor_resolve_*` covers that leg). + mirror: false, + }); + } + EdgeInput { + anchors: slots, + width: frame.width, + height: frame.height, + scale: frame.scale, + clamp: frame.clamp, + extent_x: frame.extent_x, + extent_y: frame.extent_y, + } +} + +/// Resolve one frame's four edges, returning each as `Some(edge)` when the anchor graph + explicit +/// size determined it, or `None` when it stayed `+Inf` (unset). The per-edge view **regions** need: +/// a region inherits any edge its anchors + size don't pin from its owner frame (decision 0068's v1 +/// region model — see [`crate::script`]'s region resolution), so it must see *which* edges resolved, +/// not the all-or-nothing verdict [`resolve_rect`] gives. Order matches [`Rect`]: `[bottom, left, +/// top, right]`. No `assemble`/clamp is applied (regions never clamp to screen in v1). +#[allow(clippy::float_cmp)] +pub fn resolve_rect_edges( + frame: &LayoutInput, + target_rect: impl FnMut(Handle) -> Option, +) -> [Option; 4] { + let input = build_edge_input(frame, target_rect); + let mut guard = 0u32; + let left = resolve_left(&input, &mut guard); + let bottom = resolve_bottom(&input, &mut guard); + let right = resolve_right(&input, &mut guard); + let top = resolve_top(&input, &mut guard); + let cvt = |v: f64| if v == UNSET { None } else { Some(v as f32) }; + [cvt(bottom), cvt(left), cvt(top), cvt(right)] +} + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Graph driver — resolve a set of frames in dependency order (anchor targets before dependents), +// detecting cycles. This is benilla's orchestration, not client fidelity math: the client resolves +// lazily-on-read + per-frame-batched via a dependency-ordered deferred queue (layout.md "Resolve / +// dirty / cache flow"); we express the same dependency ordering as an explicit topological pass. +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// A frame-anchor graph: frames keyed by [`Handle`], plus *external* pre-resolved rects (the screen +/// root — the client's `CSimpleTop`, whose rect is the physical screen — and any fixed targets). +#[derive(Clone, Debug, Default)] +pub struct LayoutGraph { + frames: BTreeMap, + externals: BTreeMap, +} + +/// The result of resolving a [`LayoutGraph`]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Resolution { + /// Every known rect: the externals echoed back, plus each resolvable frame's resolved rect. + pub rects: BTreeMap, + /// Frames whose `assemble` returned unresolvable (under-constrained, or depending on an + /// unresolvable/missing target). May overlap [`Resolution::cycle`]. + pub unresolvable: BTreeSet, + /// Frames on (or behind) an anchor dependency cycle — no valid topological position. Resolved + /// best-effort against whatever rects were available; a cycle member that could not resolve + /// also appears in `unresolvable`. + pub cycle: BTreeSet, +} + +impl LayoutGraph { + /// A new empty graph. + pub fn new() -> LayoutGraph { + LayoutGraph::default() + } + + /// Insert/replace a frame at `handle`. + pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) { + self.frames.insert(handle, frame); + } + + /// Remove a frame; returns it if present. + pub fn remove_frame(&mut self, handle: Handle) -> Option { + self.frames.remove(&handle) + } + + /// Register an external (pre-resolved) rect — typically the screen root that top-level frames + /// anchor to. + pub fn set_external(&mut self, handle: Handle, rect: Rect) { + self.externals.insert(handle, rect); + } + + /// The frames map. + pub fn frames(&self) -> &BTreeMap { + &self.frames + } + + /// The externals map. + pub fn externals(&self) -> &BTreeMap { + &self.externals + } + + /// Resolve every frame in dependency order (each anchor target before its dependents), + /// detecting cycles. Cycles are reported and everything resolvable is still resolved. + pub fn resolve(&self) -> Resolution { + // Kahn's algorithm over frame→frame anchor dependencies. A dependency on an external or an + // unknown handle does not constrain ordering (its rect is fixed / missing, not produced by + // this pass); only frame→frame edges do. A self-anchor counts as an edge, so a + // self-referential frame is never emitted ⇒ reported as a cycle. + let mut indeg: BTreeMap = self.frames.keys().map(|&h| (h, 0)).collect(); + let mut dependents: BTreeMap> = BTreeMap::new(); + for (&h, frame) in &self.frames { + let mut seen = BTreeSet::new(); + for a in &frame.anchors { + let d = a.relative_to; + if self.frames.contains_key(&d) && seen.insert(d) { + *indeg.get_mut(&h).expect("frame in indeg") += 1; + dependents.entry(d).or_default().push(h); + } + } + } + + let mut queue: VecDeque = indeg + .iter() + .filter(|&(_, &d)| d == 0) + .map(|(&h, _)| h) + .collect(); + let mut order: Vec = Vec::with_capacity(self.frames.len()); + while let Some(h) = queue.pop_front() { + order.push(h); + if let Some(deps) = dependents.get(&h) { + for &dep in deps { + let e = indeg.get_mut(&dep).expect("dependent in indeg"); + *e -= 1; + if *e == 0 { + queue.push_back(dep); + } + } + } + } + + // rects accumulates externals + resolved frames; it is the target-rect lookup. + let mut rects: BTreeMap = self.externals.clone(); + let mut unresolvable: BTreeSet = BTreeSet::new(); + + let resolve_one = |h: Handle, rects: &mut BTreeMap| -> bool { + let frame = &self.frames[&h]; + match resolve_rect(frame, |t| rects.get(&t).copied()) { + ResolveOutcome::Resolved(r) => { + rects.insert(h, r); + true + } + ResolveOutcome::Unresolvable => false, + } + }; + + for &h in &order { + if !resolve_one(h, &mut rects) { + unresolvable.insert(h); + } + } + + // Frames never emitted are on (or behind) a cycle; resolve them best-effort. + let emitted: BTreeSet = order.iter().copied().collect(); + let cycle: BTreeSet = self + .frames + .keys() + .copied() + .filter(|h| !emitted.contains(h)) + .collect(); + for &h in &cycle { + if !resolve_one(h, &mut rects) { + unresolvable.insert(h); + } + } + + Resolution { + rects, + unresolvable, + cycle, + } + } +} + +// ───────────────────────────────────────────────────────────────────────────────────────────── +// Dirty-tracking wrapper — a thin recompute-on-demand cache over LayoutGraph. The math above is the +// fidelity surface; this caching strategy is benilla's own (the client uses a lazy-on-read + +// per-frame-batched deferred queue — layout.md — which we may later mirror more closely). +// ───────────────────────────────────────────────────────────────────────────────────────────── + +/// A [`LayoutGraph`] with a memoized [`Resolution`]. Any mutation marks the whole cache dirty; the +/// next [`LayoutCache::resolve`] recomputes. (Per-frame incremental invalidation — dirtying only a +/// changed frame and its transitive dependents — is a future optimization; correctness lives in the +/// resolver, not the cache.) +#[derive(Clone, Debug, Default)] +pub struct LayoutCache { + graph: LayoutGraph, + cached: Option, +} + +impl LayoutCache { + /// Wrap a graph (starts dirty). + pub fn new(graph: LayoutGraph) -> LayoutCache { + LayoutCache { + graph, + cached: None, + } + } + + /// Insert/replace a frame and invalidate the cache. + pub fn set_frame(&mut self, handle: Handle, frame: LayoutInput) { + self.graph.set_frame(handle, frame); + self.cached = None; + } + + /// Remove a frame and invalidate the cache. + pub fn remove_frame(&mut self, handle: Handle) -> Option { + let removed = self.graph.remove_frame(handle); + if removed.is_some() { + self.cached = None; + } + removed + } + + /// Set an external rect and invalidate the cache. + pub fn set_external(&mut self, handle: Handle, rect: Rect) { + self.graph.set_external(handle, rect); + self.cached = None; + } + + /// Whether the next [`LayoutCache::resolve`] will recompute. + pub fn is_dirty(&self) -> bool { + self.cached.is_none() + } + + /// The underlying graph. + pub fn graph(&self) -> &LayoutGraph { + &self.graph + } + + /// The resolved layout, recomputing if dirty. + pub fn resolve(&mut self) -> &Resolution { + if self.cached.is_none() { + self.cached = Some(self.graph.resolve()); + } + self.cached.as_ref().expect("just populated") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Screen-root handle used across tests; its rect is the physical screen (y-up). + const SCREEN: Handle = 0; + fn screen_rect() -> Rect { + // [bottom, left, top, right] + Rect::new(0.0, 0.0, 600.0, 800.0) + } + + fn bits(r: Rect) -> [u32; 4] { + [ + r.bottom.to_bits(), + r.left.to_bits(), + r.top.to_bits(), + r.right.to_bits(), + ] + } + fn assert_rect(got: ResolveOutcome, want: Rect) { + match got { + ResolveOutcome::Resolved(r) => { + assert_eq!(bits(r), bits(want), "got {r:?} want {want:?}") + } + ResolveOutcome::Unresolvable => panic!("unresolvable, wanted {want:?}"), + } + } + + // ── Leaf kernels ───────────────────────────────────────────────────────────────────────── + // Input probes transcribed from wow-5875-re/crates/ui/tests/difftest/layout.rs (the explicit + // per-leg probes; binary-diffed there). Expected outputs are hand-computed from the layout.md + // formulas — this crate has no WoW.exe emulator, so these are spec-derived, not binary-diffed. + + #[test] + fn combine_edge_legs() { + // (center, opp, span) + assert_eq!(combine_edge(10.0, 50.0, 7.0), 57.0); // opp+span + assert_eq!(combine_edge(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+center + assert_eq!(combine_edge(10.0, 50.0, 0.0), (10.0 - 50.0) + 10.0); // 2c-opp = -30 + assert_eq!( + combine_edge(f32::INFINITY, f32::INFINITY, 0.0), + f64::INFINITY + ); + assert_eq!(combine_edge(f32::INFINITY, 50.0, 0.0), f64::INFINITY); + assert_eq!(combine_edge(10.0, f32::INFINITY, 0.0), f64::INFINITY); + // -0.0 span == 0.0 (IEEE) -> the 2c-opp leg + assert_eq!(combine_edge(10.0, 50.0, -0.0), (10.0 - 50.0) + 10.0); + } + + #[test] + fn combine_center_legs() { + assert_eq!(combine_center(10.0, 50.0, 7.0), (10.0 + 50.0) * 0.5); // both -> midpoint + assert_eq!(combine_center(10.0, f32::INFINITY, 7.0), 7.0 * 0.5 + 10.0); // span/2+lo + assert_eq!(combine_center(f32::INFINITY, 50.0, 7.0), 50.0 - 7.0 * 0.5); // hi-span/2 + assert_eq!( + combine_center(f32::INFINITY, f32::INFINITY, 7.0), + f64::INFINITY + ); + assert_eq!(combine_center(10.0, f32::INFINITY, 0.0), f64::INFINITY); + // both finite -> midpoint regardless of span + assert_eq!(combine_center(10.0, 50.0, -0.0), 30.0); + } + + #[test] + fn anchor_resolve_x_columns() { + let r = [3.0f32, 100.0, 480.0, 260.0]; // [bottom, left, top, right] + // relLeft, center, relRight (point-ids 0,1,2), scale 1, xOff 5 + assert_eq!(anchor_resolve_x(1.0, 5.0, 0, Some(r), false), 5.0 + 100.0); + assert_eq!( + anchor_resolve_x(1.0, 5.0, 1, Some(r), false), + (260.0 + 100.0) * 0.5 + 5.0 + ); + assert_eq!(anchor_resolve_x(1.0, 5.0, 2, Some(r), false), 5.0 + 260.0); + // scale multiplies the offset + assert_eq!( + anchor_resolve_x(2.0, 5.0, 0, Some(r), false), + 2.0 * 5.0 + 100.0 + ); + // GetRect-fail (None) -> +Inf; out-of-range point -> +Inf + assert_eq!(anchor_resolve_x(1.0, 5.0, 0, None, false), f64::INFINITY); + assert_eq!(anchor_resolve_x(1.0, 5.0, 9, Some(r), false), f64::INFINITY); + // mirror: left->0, right->right-left; center = (r'+0)/2 + xOff + let rp = (260.0f32 - 100.0f32) as f64; + assert_eq!( + anchor_resolve_x(1.0, 0.0, 1, Some(r), true), + (rp + 0.0) * 0.5 + ); + } + + #[test] + fn anchor_resolve_y_rows() { + let r = [3.0f32, 100.0, 480.0, 260.0]; + // relTop, center, relBottom (rows: pt 0->top, 3->center, 6->bottom) + assert_eq!(anchor_resolve_y(1.0, 5.0, 0, Some(r), false), 5.0 + 480.0); + assert_eq!( + anchor_resolve_y(1.0, 5.0, 3, Some(r), false), + (480.0 + 3.0) * 0.5 + 5.0 + ); + assert_eq!(anchor_resolve_y(1.0, 5.0, 6, Some(r), false), 5.0 + 3.0); + } + + // ── assemble / clamp ──────────────────────────────────────────────────────────────────── + #[test] + fn assemble_passthrough_and_fail() { + let a = assemble_rect([3.0, 100.0, 480.0, 260.0], false, 0.8, 0.6); + assert!(a.ok); + assert_eq!(a.rect, [3.0, 100.0, 480.0, 260.0]); + let bad = assemble_rect([3.0, f32::INFINITY, 480.0, 260.0], false, 0.8, 0.6); + assert!(!bad.ok); + } + + #[test] + fn assemble_clamp_low_edge() { + // left < 0 -> shift to 0, carry span to right + let a = assemble_rect([0.1, -0.2, 0.5, 0.3], true, 0.8, 0.6); + assert!(a.ok); + assert_eq!(a.rect[1], 0.0); // left + // right = 0.3 - (-0.2) computed via f64 then narrowed + let want_right = (f64::from(0.3f32) - f64::from(-0.2f32)) as f32; + assert_eq!(a.rect[3].to_bits(), want_right.to_bits()); + } + + #[test] + fn assemble_clamp_high_edge() { + // right > extent_x -> shift back, right = extent + let a = assemble_rect([0.1, 0.1, 0.5, 1.5], true, 0.8, 0.6); + assert!(a.ok); + assert_eq!(a.rect[3], 0.8); + let over = f64::from(1.5f32) - f64::from(0.8f32); + let want_left = (f64::from(0.1f32) - over) as f32; + assert_eq!(a.rect[1].to_bits(), want_left.to_bits()); + } + + // ── Composed resolver ───────────────────────────────────────────────────────────────────── + + /// VERIFIED numeric vector from wow-5875-re/system/ui/scratch/rf32-composed-resolver.md + /// "Worked oracle (probe 1)" — binary-diffed there against WoW.exe's `assemble 0x767a20`. + #[test] + fn oracle_single_topleft_explicit_size() { + let f = LayoutInput::sized( + vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 10.0, + -5.0, + )], + 200.0, + 50.0, + ); + // screen root [0,0,600,800] (probe uses root top=600, right=800) + let got = resolve_rect(&f, |_| Some(Rect::new(0.0, 0.0, 600.0, 800.0))); + assert_rect(got, Rect::new(545.0, 10.0, 595.0, 210.0)); + } + + #[test] + fn single_anchor_all_nine_points_explicit_size() { + // For each own-point p, anchor p -> the same point on screen, xOff/yOff 0, w100 h40. + // The anchored corner/edge pins directly; the rest derive from size. We assert the pinned + // coordinate and the resulting width/height, computed by hand from layout.md. + let screen = screen_rect(); + for &p in &[ + Point::TopLeft, + Point::Top, + Point::TopRight, + Point::Left, + Point::Center, + Point::Right, + Point::BottomLeft, + Point::Bottom, + Point::BottomRight, + ] { + let f = LayoutInput::sized(vec![Anchor::new(p, SCREEN, p, 0.0, 0.0)], 100.0, 40.0); + let out = resolve_rect(&f, |_| Some(screen)); + let r = match out { + ResolveOutcome::Resolved(r) => r, + ResolveOutcome::Unresolvable => panic!("{p:?} unresolvable"), + }; + // width/height always preserved + assert_eq!(r.width().to_bits(), 100.0f32.to_bits(), "{p:?} width"); + assert_eq!(r.height().to_bits(), 40.0f32.to_bits(), "{p:?} height"); + } + } + + #[test] + fn two_edge_derivation_no_size() { + // TOPLEFT + BOTTOMRIGHT to screen corners -> all edges direct, w/h ignored. + let f = LayoutInput::sized( + vec![ + Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -10.0), + Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, -20.0, 30.0), + ], + 0.0, // no explicit width + 0.0, // no explicit height + ); + let got = resolve_rect(&f, |_| Some(screen_rect())); + // left=0+10, top=600-10, right=800-20, bottom=0+30 + assert_rect(got, Rect::new(30.0, 10.0, 590.0, 780.0)); + } + + #[test] + fn center_anchor_derives_edges() { + let f = LayoutInput::sized( + vec![Anchor::new(Point::Center, SCREEN, Point::Center, 0.0, 0.0)], + 100.0, + 40.0, + ); + let got = resolve_rect(&f, |_| Some(screen_rect())); + // centered at (400, 300): [bottom 280, left 350, top 320, right 450] + assert_rect(got, Rect::new(280.0, 350.0, 320.0, 450.0)); + } + + #[test] + fn scale_interaction() { + // scale 2 -> offsets and size span doubled. + let f = LayoutInput { + anchors: vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 10.0, + -5.0, + )], + width: 200.0, + height: 50.0, + scale: 2.0, + ..LayoutInput::default() + }; + let got = resolve_rect(&f, |_| Some(screen_rect())); + // left=2*10=20, top=600+2*(-5)=590, right=20+200*2=420, bottom=590-50*2=490 + assert_rect(got, Rect::new(490.0, 20.0, 590.0, 420.0)); + } + + #[test] + fn chained_frames_via_graph() { + const A: Handle = 1; + const B: Handle = 2; + let mut g = LayoutGraph::new(); + g.set_external(SCREEN, screen_rect()); + // Insert B before A to prove topological ordering, not insertion ordering. + g.set_frame( + B, + LayoutInput::sized( + vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)], + 20.0, + 20.0, + ), + ); + g.set_frame( + A, + LayoutInput::sized( + vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 100.0, + -100.0, + )], + 50.0, + 50.0, + ), + ); + let res = g.resolve(); + assert!(res.cycle.is_empty()); + assert!(res.unresolvable.is_empty()); + // A: left 100, top 500, right 150, bottom 450 + assert_eq!( + bits(res.rects[&A]), + bits(Rect::new(450.0, 100.0, 500.0, 150.0)) + ); + // B hangs off A's bottom-right corner (150, 450), size 20 + assert_eq!( + bits(res.rects[&B]), + bits(Rect::new(430.0, 150.0, 450.0, 170.0)) + ); + } + + #[test] + fn no_anchor_is_unresolvable() { + // A frame with no SetPoint cannot resolve (every edge derives circularly to +Inf). + let f = LayoutInput::sized(vec![], 100.0, 40.0); + assert_eq!( + resolve_rect(&f, |_| Some(screen_rect())), + ResolveOutcome::Unresolvable + ); + } + + #[test] + fn unset_sentinel_propagates() { + // Anchor to a target with no valid rect (GetRect fail) -> that edge is +Inf -> frame + // unresolvable (single anchor, so opposite edges can't derive). + let f = LayoutInput::sized( + vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 0.0, + 0.0, + )], + 100.0, + 40.0, + ); + assert_eq!(resolve_rect(&f, |_| None), ResolveOutcome::Unresolvable); + } + + #[test] + fn dependent_of_unresolvable_is_unresolvable() { + const A: Handle = 1; // no anchors -> unresolvable + const B: Handle = 2; // anchored to A + let mut g = LayoutGraph::new(); + g.set_external(SCREEN, screen_rect()); + g.set_frame(A, LayoutInput::sized(vec![], 100.0, 40.0)); + g.set_frame( + B, + LayoutInput::sized( + vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)], + 20.0, + 20.0, + ), + ); + let res = g.resolve(); + assert!(res.unresolvable.contains(&A)); + assert!(res.unresolvable.contains(&B)); + assert!(!res.rects.contains_key(&A)); + assert!(!res.rects.contains_key(&B)); + } + + #[test] + fn cycle_detected() { + const A: Handle = 1; + const B: Handle = 2; + let mut g = LayoutGraph::new(); + g.set_frame( + A, + LayoutInput::sized( + vec![Anchor::new(Point::TopLeft, B, Point::TopLeft, 0.0, 0.0)], + 20.0, + 20.0, + ), + ); + g.set_frame( + B, + LayoutInput::sized( + vec![Anchor::new(Point::TopLeft, A, Point::TopLeft, 0.0, 0.0)], + 20.0, + 20.0, + ), + ); + let res = g.resolve(); + assert!(res.cycle.contains(&A)); + assert!(res.cycle.contains(&B)); + // neither can resolve (each depends on the other's rect, unavailable) + assert!(res.unresolvable.contains(&A)); + assert!(res.unresolvable.contains(&B)); + } + + #[test] + fn self_anchor_is_a_cycle() { + const A: Handle = 1; + let mut g = LayoutGraph::new(); + g.set_frame( + A, + LayoutInput::sized( + vec![Anchor::new(Point::TopLeft, A, Point::BottomRight, 0.0, 0.0)], + 20.0, + 20.0, + ), + ); + let res = g.resolve(); + assert!(res.cycle.contains(&A)); + } + + #[test] + fn size_changed_epsilon() { + let r = Rect::new(0.0, 0.0, 40.0, 100.0); + // sub-epsilon width change -> not fired + let tiny = Rect::new(0.0, 0.0, 40.0, 100.0 + 1e-7); + assert!(!size_changed(r, tiny)); + // supra-epsilon -> fired + let big = Rect::new(0.0, 0.0, 40.0, 100.001); + assert!(size_changed(r, big)); + } + + #[test] + fn cache_recomputes_on_dirty() { + let mut g = LayoutGraph::new(); + g.set_external(SCREEN, screen_rect()); + g.set_frame( + 1, + LayoutInput::sized( + vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 0.0, + 0.0, + )], + 100.0, + 40.0, + ), + ); + let mut cache = LayoutCache::new(g); + assert!(cache.is_dirty()); + let first = cache.resolve().rects[&1]; + assert!(!cache.is_dirty()); + // mutate -> dirty -> recompute reflects the change + cache.set_frame( + 1, + LayoutInput::sized( + vec![Anchor::new( + Point::TopLeft, + SCREEN, + Point::TopLeft, + 50.0, + 0.0, + )], + 100.0, + 40.0, + ), + ); + assert!(cache.is_dirty()); + let second = cache.resolve().rects[&1]; + assert_eq!(second.left - first.left, 50.0); + } +} diff --git a/crates/benilla-ui/src/lib.rs b/crates/benilla-ui/src/lib.rs new file mode 100644 index 00000000..d442defa --- /dev/null +++ b/crates/benilla-ui/src/lib.rs @@ -0,0 +1,24 @@ +//! The engine-free core of benilla's UI engine (decision 0068): the WoW interface is data + a +//! runtime — TOC manifests, FrameXML documents, and Lua — and this crate owns everything about +//! that runtime which needs neither Bevy nor a GPU: parsing, template expansion, the anchor/layout +//! resolver, and the draw-order key. The Bevy side (rendering, input, the game-state API bindings) +//! lives in the app; the Lua VM is embedded by the app and calls back into this crate's model. +//! +//! Built currently: [`toc`] — the addon/FrameXML manifest parser; [`framexml`] — the FrameXML +//! document parser (order-preserving tree, template expansion, `$parent` substitution); [`layout`] +//! — the anchor/layout resolver (anchor graph → resolved rects), transcribed bit-faithfully from +//! wow-5875-re's binary-verified spec; [`widget`] — the frame arena and its show/hide/strata/level/ +//! scale/alpha propagation mutations; [`order`] — the strata/draw-layer vocabulary, the packed +//! total-order key, and the visible-tree traversal that realizes the client's painter order; +//! [`script`] — the engine-free Lua host (mlua 5.1) binding the arena/layout/order model to the +//! FrameScript object model + WoW stdlib so FrameXML/addon Lua runs against it (decision 0068); +//! [`loader`] — the FrameXML loader that joins the two, walking a parsed document to materialize live +//! frames in a running [`script::UiScript`] by driving the object model exactly as an addon does. + +pub mod framexml; +pub mod layout; +pub mod loader; +pub mod order; +pub mod script; +pub mod toc; +pub mod widget; diff --git a/crates/benilla-ui/src/loader/backdrop.rs b/crates/benilla-ui/src/loader/backdrop.rs new file mode 100644 index 00000000..ea6c70d8 --- /dev/null +++ b/crates/benilla-ui/src/loader/backdrop.rs @@ -0,0 +1,68 @@ +use mlua::Table; + +use crate::framexml::Element; + +use super::{abs_value, children_named, color_of, Loader}; + +impl Loader<'_> { + /// `` + children ``/``/``/ + /// ``/`` (rf24 LoadXML `0x77e6c0`; backdrop-mechanism.md §1). Builds the same + /// table Lua `SetBackdrop` reads and installs it, then applies the two colors — the XML `` + /// parser defaults a *present* element's missing channels to **black** (r=g=b=0, a=1 via + /// [`color_of`]), distinct from the ctor white a table `SetBackdrop` leaves. + pub(super) fn apply_backdrop(&mut self, el: &Element, wrapper: &Table, dbg: &str) { + let Some(bd) = children_named(el, "Backdrop").next() else { + return; + }; + let table = match self.build_backdrop_table(bd) { + Ok(t) => t, + Err(e) => { + self.report.errors.push(format!("{dbg}: : {e}")); + return; + } + }; + self.call(wrapper, "SetBackdrop", table, dbg); + if let Some(c) = children_named(bd, "Color").next() { + let [r, g, b, a] = color_of(c); + self.call(wrapper, "SetBackdropColor", (r, g, b, a), dbg); + } + if let Some(c) = children_named(bd, "BorderColor").next() { + let [r, g, b, a] = color_of(c); + self.call(wrapper, "SetBackdropBorderColor", (r, g, b, a), dbg); + } + } + + /// Build the `SetBackdrop` table from a `` element: `bgFile`/`edgeFile`/`tile` attrs, + /// ``/`` scalars ([`abs_value`]), and `` (an `` + /// child or inline `left`/`right`/`top`/`bottom` attrs). Missing keys are simply absent — the + /// object-model `SetBackdrop` leaves the ctor default (tileSize 0, edgeSize 32, insets 0). + pub(super) fn build_backdrop_table(&self, bd: &Element) -> mlua::Result { + let t = self.lua().create_table()?; + if let Some(f) = bd.attr("bgFile") { + t.set("bgFile", f)?; + } + if let Some(f) = bd.attr("edgeFile") { + t.set("edgeFile", f)?; + } + if bd.attr_bool("tile") { + t.set("tile", true)?; + } + if let Some(v) = children_named(bd, "EdgeSize").next().and_then(abs_value) { + t.set("edgeSize", v)?; + } + if let Some(v) = children_named(bd, "TileSize").next().and_then(abs_value) { + t.set("tileSize", v)?; + } + if let Some(ins) = children_named(bd, "BackgroundInsets").next() { + let src = children_named(ins, "AbsInset").next().unwrap_or(ins); + let it = self.lua().create_table()?; + for k in ["left", "right", "top", "bottom"] { + if let Some(v) = src.attr(k).and_then(|v| v.trim().parse::().ok()) { + it.set(k, v)?; + } + } + t.set("insets", it)?; + } + Ok(t) + } +} diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs new file mode 100644 index 00000000..5deda8ae --- /dev/null +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -0,0 +1,128 @@ +use mlua::Table; + +use crate::framexml::{self, Element}; + +use super::{abs_dim, children_named, Loader}; + +impl Loader<'_> { + /// LoadXML attribute handling (rf24 `0x769820`): the subset the v1 object model exposes, plus a + /// warn-once for the attributes whose methods don't exist yet (a next-phase gap, not a workaround). + pub(super) fn apply_attrs(&mut self, el: &Element, wrapper: &Table, dbg: &str) { + if el.attr_bool("hidden") { + self.call(wrapper, "Hide", (), dbg); + } + if let Some(strata) = el.attr("frameStrata") { + self.call(wrapper, "SetFrameStrata", strata.to_string(), dbg); + } + if let Some(level) = el.attr("frameLevel") { + if let Ok(n) = level.parse::() { + self.call(wrapper, "SetFrameLevel", n, dbg); + } + } + if let Some(alpha) = el.attr("alpha") { + if let Ok(a) = alpha.parse::() { + self.call(wrapper, "SetAlpha", a, dbg); + } + } + if let Some(id) = el.attr("id") { + if let Ok(n) = id.parse::() { + self.call(wrapper, "SetID", n, dbg); + } + } + // `clampedToScreen="true"` → SetClampedToScreen (rf24 `0x768cc0` — geometry flags bit4, + // the layout resolve's screen clamp; GameTooltip frames carry it by construction). + if el.attr_bool("clampedToScreen") { + self.call(wrapper, "SetClampedToScreen", true, dbg); + } + // `enableMouse` lands as the real EnableMouse call (the hit test gates on it — + // pointer.rs): a ref frame authored mouse-blocking (StaticPopup, WorldMapBlackout, the + // minimap cluster) must actually capture. The loader ignored it for a while after the + // native landed — the stale "v1 gap" note here — which left those frames click-through + // and the minimap deaf to its ping click (0434 phase 6c's root cause). + if el.attr_bool("enableMouse") { + self.call(wrapper, "EnableMouse", true, dbg); + } + // Still genuine gaps — the window-behaviour surface (input arbitration, decision 0068). + // Flag them rather than silently dropping or faking them. + for (attr, api) in [ + ("enableKeyboard", "EnableKeyboard"), + ("toplevel", "SetToplevel"), + ("movable", "SetMovable"), + ("resizable", "SetResizable"), + ] { + if el.attr_bool(attr) { + self.warn_once( + api, + format!("`{attr}` ignored: {api} is not in the v1 object model (gap)"), + ); + } + } + } + + /// `` → SetWidth/SetHeight (rf24 `0x767800`). Accepts either `` + /// or the inline `` form; a dimension that's absent is left untouched (the client's + /// "0 = derive"). ALL `` children apply, in document order — template expansion appends the + /// instance's children after the template's ([`crate::framexml::expand`]), and the client simply + /// processes each child in turn, so an instance's own `` overwrites its template's. (Taking + /// only `.next()` here silently pinned every templated frame to the TEMPLATE's size; caught on + /// the quest log's Abandon button — 125×21 in the instance XML, 80×22 on screen.) + pub(super) fn apply_size(&mut self, el: &Element, wrapper: &Table, dbg: &str) { + for size in children_named(el, "Size") { + let (x, y) = abs_dim(size); + if let Some(w) = x { + self.call(wrapper, "SetWidth", w, dbg); + } + if let Some(h) = y { + self.call(wrapper, "SetHeight", h, dbg); + } + } + } + + /// `` → SetPoint per `` + /// (rf24 `0x767800`). `relativePoint` defaults to `point`; `relativeTo` is `$parent`-substituted + /// (rf27) and passed by name (the object model resolves it, falling back to the parent when + /// absent/unresolved); a missing `point` is skipped with a warning ("Invalid anchor point"). + /// The `setAllPoints="true"` shorthand applies first, like the region path — a frame carrying + /// only the attribute (WorldMapFrame's chrome layers) pins TOPLEFT+BOTTOMRIGHT to its parent. + pub(super) fn apply_anchors( + &mut self, + el: &Element, + wrapper: &Table, + parent_name: &str, + dbg: &str, + ) { + if el.attr_bool("setAllPoints") { + self.call(wrapper, "SetAllPoints", (), dbg); + } + for anchors in children_named(el, "Anchors") { + for anchor in children_named(anchors, "Anchor") { + let Some(point) = anchor.attr("point") else { + self.report + .warnings + .push(format!("{dbg}: without a point; skipped")); + continue; + }; + let rel_point = anchor.attr("relativePoint").unwrap_or(point).to_string(); + let rel_to: Option = anchor + .attr("relativeTo") + .map(|r| framexml::resolve_name(r, parent_name)); + let (x, y) = children_named(anchor, "Offset") + .next() + .map(abs_dim) + .unwrap_or((None, None)); + self.call( + wrapper, + "SetPoint", + ( + point.to_string(), + rel_to, + rel_point, + x.unwrap_or(0.0), + y.unwrap_or(0.0), + ), + dbg, + ); + } + } + } +} diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs new file mode 100644 index 00000000..1d51c91e --- /dev/null +++ b/crates/benilla-ui/src/loader/mod.rs @@ -0,0 +1,424 @@ +//! The FrameXML **loader** (decision 0068): the join between the document layer ([`crate::framexml`]) +//! and the runtime ([`crate::script`]). It walks a parsed FrameXML document and *materializes* it — +//! turning ``/` + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaActionButton_OnClick(self) + BenillaActionButton_OnDragStart(self) + BenillaActionButton_OnReceiveDrag(self) + BenillaActionButton_OnUpdate(self, arg1) + BenillaActionButton_OnEnter(self) + if GameTooltip then GameTooltip:Hide() end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaExpBar_OnLoad(self) + BenillaExpBar_OnEvent(self) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaActionButton_OnLoad(self, 1) + + + + BenillaActionButton_OnLoad(self, 2) + + + + BenillaActionButton_OnLoad(self, 3) + + + + BenillaActionButton_OnLoad(self, 4) + + + + BenillaActionButton_OnLoad(self, 5) + + + + BenillaActionButton_OnLoad(self, 6) + + + + BenillaActionButton_OnLoad(self, 7) + + + + BenillaActionButton_OnLoad(self, 8) + + + + BenillaActionButton_OnLoad(self, 9) + + + + BenillaActionButton_OnLoad(self, 10) + + + + BenillaActionButton_OnLoad(self, 11) + + + + BenillaActionButton_OnLoad(self, 12) + + + + + + + + + + BenillaActionBar_OnLoad(self) + BenillaActionBar_OnEvent(self) + + + diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml new file mode 100644 index 00000000..b62f45bf --- /dev/null +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -0,0 +1,1275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + BenillaBagBar_SeatAboveActionBar(self) + BenillaBagToggle_OnClick() + + + + + + + + + + + + + + + + + + + BenillaBagBarSlot_OnLoad(self, 1) + BenillaBagBarSlot_OnClick(self) + BenillaBagBarSlot_OnEvent(self) + + BenillaBagBarSlot_Place(self) + BenillaBagBarSlot_Place(self) + + + + + + + + + + + + + + + + BenillaBagBarSlot_OnLoad(self, 2) + BenillaBagBarSlot_OnClick(self) + BenillaBagBarSlot_OnEvent(self) + + BenillaBagBarSlot_Place(self) + BenillaBagBarSlot_Place(self) + + + + + + + + + + + + + + + + BenillaBagBarSlot_OnLoad(self, 3) + BenillaBagBarSlot_OnClick(self) + BenillaBagBarSlot_OnEvent(self) + + BenillaBagBarSlot_Place(self) + BenillaBagBarSlot_Place(self) + + + + + + + + + + + + + + + + BenillaBagBarSlot_OnLoad(self, 4) + BenillaBagBarSlot_OnClick(self) + BenillaBagBarSlot_OnEvent(self) + + BenillaBagBarSlot_Place(self) + BenillaBagBarSlot_Place(self) + + + diff --git a/crates/benilla/assets/ui/BankFrame.xml b/crates/benilla/assets/ui/BankFrame.xml new file mode 100644 index 00000000..6da86bcd --- /dev/null +++ b/crates/benilla/assets/ui/BankFrame.xml @@ -0,0 +1,1147 @@ + + + + + + + + + + + + + + + + + + + + BenillaBankBagButton_OnClick(self) + BenillaBankBagButton_OnEnter(self) + GameTooltip:Hide() + BenillaBankBagButton_Place(self) + BenillaBankBagButton_Place(self) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBankBagButton_OnLoad(self, 1) + + + + BenillaBankBagButton_OnLoad(self, 2) + + + + BenillaBankBagButton_OnLoad(self, 3) + + + + BenillaBankBagButton_OnLoad(self, 4) + + + + BenillaBankBagButton_OnLoad(self, 5) + + + + BenillaBankBagButton_OnLoad(self, 6) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBankFrame_OnLoad(self) + BenillaBankFrame_OnEvent(self) + + OpenBackpack(); PlaySound("igMainMenuOpen") + BenillaCloseBankBagFrames(); CloseBankFrame(); CloseBackpack(); PlaySound("igMainMenuClose") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBagFrame_OnLoad(self) + BenillaBagFrame_OnEvent(self) + BenillaBagFrame_OnShow() + BenillaBagFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/BuffFrame.xml b/crates/benilla/assets/ui/BuffFrame.xml new file mode 100644 index 00000000..6e98834f --- /dev/null +++ b/crates/benilla/assets/ui/BuffFrame.xml @@ -0,0 +1,410 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaBuffFrame_OnLoad(this) + BenillaBuffFrame_OnUpdate(arg1) + + + diff --git a/crates/benilla/assets/ui/CastingBar.xml b/crates/benilla/assets/ui/CastingBar.xml new file mode 100644 index 00000000..6fe53476 --- /dev/null +++ b/crates/benilla/assets/ui/CastingBar.xml @@ -0,0 +1,239 @@ + + + + + + diff --git a/crates/benilla/assets/ui/CharacterFrame.xml b/crates/benilla/assets/ui/CharacterFrame.xml new file mode 100644 index 00000000..a47677e7 --- /dev/null +++ b/crates/benilla/assets/ui/CharacterFrame.xml @@ -0,0 +1,1771 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:EnableMouse(true) + + if self.tooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.tooltip) + GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + end + + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:SetFrameLevel(self:GetFrameLevel() + 1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -- A plain Frame isn't mouse-enabled by construction (this file's + -- header comment) — needed THIS phase (1b) so the pane hit-tests + -- as an OnMouseUp/OnReceiveDrag drop target (ref l.226-231). + self:EnableMouse(true) + BenillaPaperDollModel_OnLoad(self) + BenillaSetBoothTexture(getglobal(self:GetName() .. "Texture"), "paperdoll") + + BenillaPaperDollModel_OnMouseUp(self, arg1) + AutoEquipCursorItem() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:EnableMouse(true) + BenillaPaperDollStatTooltip(self, "STRENGTH") + GameTooltip:Hide() + + + + + + self:EnableMouse(true) + BenillaPaperDollStatTooltip(self, "AGILITY") + GameTooltip:Hide() + + + + + + self:EnableMouse(true) + BenillaPaperDollStatTooltip(self, "STAMINA") + GameTooltip:Hide() + + + + + + self:EnableMouse(true) + BenillaPaperDollStatTooltip(self, "INTELLECT") + GameTooltip:Hide() + + + + + + self:EnableMouse(true) + BenillaPaperDollStatTooltip(self, "SPIRIT") + GameTooltip:Hide() + + + + + + + self:EnableMouse(true) + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.tooltip) + GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + + GameTooltip:Hide() + + + + + + + self:EnableMouse(true) + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(ATTACK_TOOLTIP) + GameTooltip:AddLine(ATTACK_TOOLTIP_SUBTEXT, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + + GameTooltip:Hide() + + + + + + + self:EnableMouse(true); self:SetWidth(99) + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.tooltip) + GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + + GameTooltip:Hide() + + + + + + + self:EnableMouse(true); self:SetWidth(99) + BenillaCharacterDamageFrame_OnEnter(self) + GameTooltip:Hide() + + + + + + + self:EnableMouse(true) + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(RANGED_ATTACK_TOOLTIP) + GameTooltip:AddLine(ATTACK_TOOLTIP_SUBTEXT, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + + GameTooltip:Hide() + + + + + + + self:EnableMouse(true); self:SetWidth(99) + + if self.tooltip then + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText(self.tooltip) + GameTooltip:AddLine(self.tooltipSubtext, NORMAL_FONT_COLOR.r, NORMAL_FONT_COLOR.g, NORMAL_FONT_COLOR.b, 1) + GameTooltip:Show() + end + + GameTooltip:Hide() + + + + + + + self:EnableMouse(true); self:SetWidth(99) + BenillaCharacterRangedDamageFrame_OnEnter(self) + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaPaperDollFrame_OnLoad(self) + BenillaPaperDollFrame_OnEvent(self) + BenillaPaperDollFrame_OnShow() + + + + + BenillaCharacterFrame_OnLoad(self) + BenillaCharacterFrame_OnShow() + BenillaCharacterFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/ChatFrame.xml b/crates/benilla/assets/ui/ChatFrame.xml new file mode 100644 index 00000000..405ec980 --- /dev/null +++ b/crates/benilla/assets/ui/ChatFrame.xml @@ -0,0 +1,554 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaFCF_OnUpdate(arg1) + BenillaChatFrame_OnUpdate(this, arg1) + + + -- WoW wheel convention: +1 per notch up, -1 down. Up = older messages. + if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end + + + SetItemRef(arg1, arg2, arg3) + + + + + + + + + + + + + + + + + + diff --git a/crates/benilla/assets/ui/CombatText.xml b/crates/benilla/assets/ui/CombatText.xml new file mode 100644 index 00000000..1942c724 --- /dev/null +++ b/crates/benilla/assets/ui/CombatText.xml @@ -0,0 +1,608 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CombatText_OnLoad() + CombatText_OnEvent(event) + CombatText_OnUpdate(arg1) + + + diff --git a/crates/benilla/assets/ui/Cooldown.xml b/crates/benilla/assets/ui/Cooldown.xml new file mode 100644 index 00000000..1c9e4c49 --- /dev/null +++ b/crates/benilla/assets/ui/Cooldown.xml @@ -0,0 +1,21 @@ + + + + diff --git a/crates/benilla/assets/ui/CraftFrame.xml b/crates/benilla/assets/ui/CraftFrame.xml new file mode 100644 index 00000000..bbdd681c --- /dev/null +++ b/crates/benilla/assets/ui/CraftFrame.xml @@ -0,0 +1,931 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaCraftFrame_OnLoad(self) + BenillaCraftFrame_OnEvent(self) + BenillaCraftFrame_OnMouseWheel(arg1) + PlaySound("igCharacterInfoOpen") + CloseCraft(); PlaySound("igCharacterInfoClose") + + + diff --git a/crates/benilla/assets/ui/DeathFrame.xml b/crates/benilla/assets/ui/DeathFrame.xml new file mode 100644 index 00000000..d28321b2 --- /dev/null +++ b/crates/benilla/assets/ui/DeathFrame.xml @@ -0,0 +1,265 @@ + + + + + + + + + + self:RegisterEvent("PLAYER_DEAD") + self:RegisterEvent("PLAYER_ALIVE") + self:RegisterEvent("PLAYER_UNGHOST") + self:RegisterEvent("RESURRECT_REQUEST") + self:RegisterEvent("CONFIRM_XP_LOSS") + self:RegisterEvent("CORPSE_IN_RANGE") + self:RegisterEvent("CORPSE_IN_INSTANCE") + self:RegisterEvent("CORPSE_OUT_OF_RANGE") + + BenillaDeathDriver_OnEvent() + + + diff --git a/crates/benilla/assets/ui/DurabilityFrame.xml b/crates/benilla/assets/ui/DurabilityFrame.xml new file mode 100644 index 00000000..eec8af0f --- /dev/null +++ b/crates/benilla/assets/ui/DurabilityFrame.xml @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:SetFrameLevel(this:GetFrameLevel() - 1) + this:RegisterEvent("UPDATE_INVENTORY_ALERTS") + this:RegisterEvent("PLAYER_ENTERING_WORLD") + + + DurabilityFrame_SetAlerts() + -- benilla re-manages the HUD right-stack on every alert recompute. The ref keeps + -- the durability seat (and the quest tracker stacked under it) fresh because it + -- fires UIParent_ManageFramePositions from dozens of OnShow/OnHide sites; benilla + -- fires it from only a handful, so a showSeparate glyph that arrives AFTER the + -- frame is already shown — equipment streams in per-slot at login, the weapon + -- often breaking before the shield's slot lands — would otherwise leave + -- durabilityOffset stale and hang the shield off the screen's right edge + -- (director-caught). SetAlerts's own Show()/Hide() only re-manages on a + -- shown-state transition, which a mere glyph-set change is not. + UIParent_ManageFramePositions() + + + UIParent_ManageFramePositions() + + + UIParent_ManageFramePositions() + + + + diff --git a/crates/benilla/assets/ui/ErrorsFrame.xml b/crates/benilla/assets/ui/ErrorsFrame.xml new file mode 100644 index 00000000..e86b0cd6 --- /dev/null +++ b/crates/benilla/assets/ui/ErrorsFrame.xml @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaErrorsFrame_OnLoad(self) + BenillaErrorsFrame_OnEvent(self) + BenillaErrorsFrame_OnUpdate(self, arg1) + + + diff --git a/crates/benilla/assets/ui/Fonts.xml b/crates/benilla/assets/ui/Fonts.xml new file mode 100644 index 00000000..5e1d8fc1 --- /dev/null +++ b/crates/benilla/assets/ui/Fonts.xml @@ -0,0 +1,290 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/benilla/assets/ui/GameTooltip.xml b/crates/benilla/assets/ui/GameTooltip.xml new file mode 100644 index 00000000..d500d451 --- /dev/null +++ b/crates/benilla/assets/ui/GameTooltip.xml @@ -0,0 +1,398 @@ + + + + + + + + + + diff --git a/crates/benilla/assets/ui/GossipFrame.xml b/crates/benilla/assets/ui/GossipFrame.xml new file mode 100644 index 00000000..b826eede --- /dev/null +++ b/crates/benilla/assets/ui/GossipFrame.xml @@ -0,0 +1,401 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaGossipFrame_OnLoad(self) + BenillaGossipFrame_OnEvent(self) + + PlaySound("igQuestListOpen") + CloseGossip(); PlaySound("igQuestListClose") + + + diff --git a/crates/benilla/assets/ui/GroupLootFrame.xml b/crates/benilla/assets/ui/GroupLootFrame.xml new file mode 100644 index 00000000..f366194e --- /dev/null +++ b/crates/benilla/assets/ui/GroupLootFrame.xml @@ -0,0 +1,478 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaGroupLootFrame_Timer_OnUpdate(self) + + + + + + + + + + + + + BenillaGroupLootFrame_OnLoad(self) + BenillaGroupLootFrame_OnShow(self) + BenillaGroupLootFrame_OnEvent(self) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:RegisterEvent("START_LOOT_ROLL") + self:RegisterEvent("CONFIRM_LOOT_ROLL") + + + if event == "START_LOOT_ROLL" then + BenillaGroupLootFrame_OpenNewFrame(arg1, arg2) + elseif event == "CONFIRM_LOOT_ROLL" then + -- Ref UIParent.lua:517-524 verbatim, minus the central router: a Need/Greed on a + -- BIND-ON-PICKUP roll sends nothing and asks first (decision 0594). arg1 = rollID, + -- arg2 = rollType; both ride the dialog so OnAccept can re-enter ConfirmLootRoll. + local dialog = StaticPopup_Show("CONFIRM_LOOT_ROLL") + if dialog then + dialog.data = arg1 + dialog.data2 = arg2 + end + end + + + + diff --git a/crates/benilla/assets/ui/ItemRef.xml b/crates/benilla/assets/ui/ItemRef.xml new file mode 100644 index 00000000..e2d9e434 --- /dev/null +++ b/crates/benilla/assets/ui/ItemRef.xml @@ -0,0 +1,136 @@ + + + + + + + + + diff --git a/crates/benilla/assets/ui/ItemTextFrame.xml b/crates/benilla/assets/ui/ItemTextFrame.xml new file mode 100644 index 00000000..ac9d1b0e --- /dev/null +++ b/crates/benilla/assets/ui/ItemTextFrame.xml @@ -0,0 +1,337 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaItemTextFrame_OnLoad() + BenillaItemTextFrame_OnEvent(event) + BenillaItemTextFrame_OnUpdate(arg1) + PlaySound("igMainMenuOpen") + + CloseItemText() + PlaySound("igMainMenuClose") + + + + diff --git a/crates/benilla/assets/ui/LootFrame.xml b/crates/benilla/assets/ui/LootFrame.xml new file mode 100644 index 00000000..40610a8d --- /dev/null +++ b/crates/benilla/assets/ui/LootFrame.xml @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaLootFrame_OnLoad(self) + BenillaLootFrame_OnEvent(self) + BenillaLootFrame_OnShow() + BenillaLootFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/MailFrame.xml b/crates/benilla/assets/ui/MailFrame.xml new file mode 100644 index 00000000..15f1b9a5 --- /dev/null +++ b/crates/benilla/assets/ui/MailFrame.xml @@ -0,0 +1,1535 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSendMailBodyEditBox:SetFocus() + if ( CursorHasItem() ) then BenillaSendMailPackageButton_OnClick() end + + this:ClearFocus() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSendMailSubjectEditBox:SetFocus() + this:HighlightText(0, 0) + BenillaSendMailSubjectEditBox:SetFocus() + BenillaSendMailNameEditBox:ClearFocus() + BenillaSendMailFrame_CanSend() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSendMailBodyEditBox:SetFocus() + BenillaSendMailBodyEditBox:SetFocus() + BenillaSendMailSubjectEditBox:ClearFocus() + BenillaSendMailFrame_CanSend() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSendMailErrorText:SetPoint("BOTTOMLEFT", "BenillaSendMailMoneyText", "TOPLEFT", 0, 2) + + + + BenillaStationeryBackgroundLeft:SetTexture(BENILLA_STATIONERY_PATH.."STATIONERYTEST1") + BenillaStationeryBackgroundRight:SetTexture(BENILLA_STATIONERY_PATH.."STATIONERYTEST2") + BenillaSendMailNameEditBox:SetFocus() + BenillaSendMailRadioButton_OnClick(1) + + + if ( CursorHasItem() ) then BenillaSendMailPackageButton_OnClick() end + + + + + + + + + + + + BenillaMailFrame_OnLoad(self) + BenillaMailFrame_OnEvent(self) + + OpenBackpack(); PlaySound("igCharacterInfoOpen") + + CloseMail() + CloseBackpack() + HideUIPanel(BenillaOpenMailFrame) + BenillaSendMailBodyEditBox:SetText("") + BenillaSendMailNameEditBox:SetText("") + BenillaSendMailSubjectEditBox:SetText("") + PlaySound("igCharacterInfoClose") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaOpenMailFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/MerchantFrame.xml b/crates/benilla/assets/ui/MerchantFrame.xml new file mode 100644 index 00000000..455aa167 --- /dev/null +++ b/crates/benilla/assets/ui/MerchantFrame.xml @@ -0,0 +1,1275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaMerchantFrame_OnLoad(self) + BenillaMerchantFrame_OnEvent(self) + + + if BenillaMerchantFrame.itemHover then + if IsControlKeyDown() then + ShowInspectCursor() + else + ShowMerchantSellCursor(BenillaMerchantFrame.itemHover) + end + end + + + OpenBackpack(); PlaySound("igCharacterInfoOpen") + + CloseMerchant(); CloseBackpack(); HideRepairCursor(); ResetCursor(); BenillaMerchantFrame.itemHover = nil; PlaySound("igCharacterInfoClose") + + + diff --git a/crates/benilla/assets/ui/MinimapCluster.xml b/crates/benilla/assets/ui/MinimapCluster.xml new file mode 100644 index 00000000..3bafc78b --- /dev/null +++ b/crates/benilla/assets/ui/MinimapCluster.xml @@ -0,0 +1,441 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("PLAYER_AURAS_CHANGED"); + + + if ( event == "PLAYER_AURAS_CHANGED" ) then + local icon = GetTrackingTexture(); + if ( icon ) then + MiniMapTrackingIcon:SetTexture(icon); + MiniMapTrackingFrame:Show(); + else + MiniMapTrackingFrame:Hide(); + end + end + + + if ( arg1 == "RightButton" ) then + CancelTrackingBuff(); + end + + + GameTooltip:SetOwner(this, "ANCHOR_BOTTOMLEFT"); + GameTooltip:SetTrackingSpell(); + + + GameTooltip:Hide(); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("UPDATE_PENDING_MAIL"); + this:SetFrameLevel(this:GetFrameLevel()+1) + + + MiniMapMailFrame_OnEvent(); + + + MiniMapMailFrame_OnEnter(); + + + GameTooltip:Hide(); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Minimap_Update(); + -- Seed the +/- state from the live zoom (default index 3 ⇒ both enabled) rather than + -- hardcoding a zoom-0 "can't zoom out" — the client's default is 3, not the widest. + Minimap_UpdateZoom(); + this:RegisterEvent("MINIMAP_ZONE_CHANGED"); + this:RegisterEvent("MINIMAP_UPDATE_ZOOM"); + + + if ( event == "MINIMAP_UPDATE_ZOOM" ) then + Minimap_UpdateZoom(); + else + Minimap_Update(); + end + + + + diff --git a/crates/benilla/assets/ui/MultiBars.xml b/crates/benilla/assets/ui/MultiBars.xml new file mode 100644 index 00000000..074b4ffc --- /dev/null +++ b/crates/benilla/assets/ui/MultiBars.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE) + + + + + + + + + + + + + + BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE) + + + + diff --git a/crates/benilla/assets/ui/PartyFrame.xml b/crates/benilla/assets/ui/PartyFrame.xml new file mode 100644 index 00000000..3da4539c --- /dev/null +++ b/crates/benilla/assets/ui/PartyFrame.xml @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PartyFrameDropDown_OnLoad() + + + + + + + + + + + + + + + + + + + + + + + + + BenillaPartyFrame_OnLoad(this) + BenillaPartyFrame_OnEvent(this) + BenillaPartyFrame_OnUpdate(this, arg1) + BenillaPartyFrame_OnEnter(this) + GameTooltip:FadeOut() + BenillaPartyFrame_OnClick(this, arg1) + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("PARTY_INVITE_REQUEST") + this:RegisterEvent("PARTY_INVITE_CANCEL") + + BenillaPartyInviteDriver_OnEvent() + + + diff --git a/crates/benilla/assets/ui/QuestFrame.xml b/crates/benilla/assets/ui/QuestFrame.xml new file mode 100644 index 00000000..a4f14876 --- /dev/null +++ b/crates/benilla/assets/ui/QuestFrame.xml @@ -0,0 +1,1350 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:SetScrollChild(getglobal(self:GetName() .. "Child")) + BenillaQuestScroll_OnMouseWheel(this, arg1) + BenillaQuestScroll_SyncBar(this) + BenillaQuestScroll_SyncBar(this) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaQuestGreetingPanel_OnShow() + + + + + + + + + + + + self:SetScrollChild(getglobal(self:GetName() .. "Child")) + BenillaQuestScroll_OnMouseWheel(this, arg1) + BenillaQuestScroll_SyncBar(this) + BenillaQuestScroll_SyncBar(this) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaQuestDetailPanel_OnShow() + BenillaQuestDetailPanel_OnUpdate(this, arg1) + + + + + + + + + + + + + self:SetScrollChild(getglobal(self:GetName() .. "Child")) + BenillaQuestScroll_OnMouseWheel(this, arg1) + BenillaQuestScroll_SyncBar(this) + BenillaQuestScroll_SyncBar(this) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaQuestProgressPanel_OnShow() + + + + + + + + + + + + self:SetScrollChild(getglobal(self:GetName() .. "Child")) + BenillaQuestScroll_OnMouseWheel(this, arg1) + BenillaQuestScroll_SyncBar(this) + BenillaQuestScroll_SyncBar(this) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaQuestRewardPanel_OnShow() + + + + BenillaQuestFrame_OnLoad(self) + BenillaQuestFrame_OnEvent(self) + BenillaQuestFrame_OnUpdate() + + PlaySound("igQuestListOpen") + + getglobal("BenillaQuestGreetingPanel"):Hide() + getglobal("BenillaQuestDetailPanel"):Hide() + getglobal("BenillaQuestProgressPanel"):Hide() + getglobal("BenillaQuestRewardPanel"):Hide() + CloseQuest() + PlaySound("igQuestListClose") + + + + diff --git a/crates/benilla/assets/ui/QuestLogFrame.xml b/crates/benilla/assets/ui/QuestLogFrame.xml new file mode 100644 index 00000000..ae9f1764 --- /dev/null +++ b/crates/benilla/assets/ui/QuestLogFrame.xml @@ -0,0 +1,1760 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getglobal("BenillaQuestLogTrackTracking"):SetVertexColor(1.0, 0, 0) + + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + GameTooltip:SetText("Shift-click a quest to add or remove a quest from your quest watch list.", nil, nil, nil, nil, 1) + + + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:SetScrollChild(getglobal("BenillaQuestLogDetailChild")) + + if arg1 > 0 then + BenillaQuestLogDetailScroll_Step(-BENILLA_QUESTLOG_SCROLL_STEP) + else + BenillaQuestLogDetailScroll_Step(BENILLA_QUESTLOG_SCROLL_STEP) + end + + BenillaQuestLogDetailScroll_SyncBar() + BenillaQuestLogDetailScroll_SyncBar() + + BenillaQuestLogDetail_OnUpdate() + + + + + + + + + + + BenillaQuestLogFrame_OnLoad(self) + BenillaQuestLogFrame_OnEvent(self) + + BenillaQuestLogFrame_OnMouseWheel(arg1) + + PlaySound("igQuestLogOpen") BenillaQuestLogFrame_Update() + PlaySound("igQuestLogClose") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaQuestWatchFrame_OnLoad(self) + BenillaQuestWatchFrame_OnEvent(self) + BenillaQuestWatchFrame_OnUpdate(self, arg1) + + + diff --git a/crates/benilla/assets/ui/ScrollTemplates.xml b/crates/benilla/assets/ui/ScrollTemplates.xml new file mode 100644 index 00000000..b6fc8a9b --- /dev/null +++ b/crates/benilla/assets/ui/ScrollTemplates.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + BenillaFauxScrollBar_OnValueChanged(self, arg1) + + + + + + + + + + + + + + + + + + + BenillaFauxScrollFrame_OnLoad(self) + + + diff --git a/crates/benilla/assets/ui/SkillFrame.xml b/crates/benilla/assets/ui/SkillFrame.xml new file mode 100644 index 00000000..f9af0e89 --- /dev/null +++ b/crates/benilla/assets/ui/SkillFrame.xml @@ -0,0 +1,627 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSkillFrame_OnLoad(self) + BenillaSkillFrame_OnShow() + BenillaSkillFrame_OnEvent(self) + BenillaSkillFrame_OnMouseWheel(arg1) + + + diff --git a/crates/benilla/assets/ui/SpellBookFrame.xml b/crates/benilla/assets/ui/SpellBookFrame.xml new file mode 100644 index 00000000..c99d0e52 --- /dev/null +++ b/crates/benilla/assets/ui/SpellBookFrame.xml @@ -0,0 +1,657 @@ + + + + + + + + + + + + + + + + + + + + + + BenillaSpellBookSkillLineTab_OnClick() + + GameTooltip:SetOwner(self, "ANCHOR_RIGHT") + if self.tooltip then + GameTooltip:SetText(self.tooltip) + GameTooltip:Show() + end + + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + BenillaSpellButton_OnLoad(self) + BenillaSpellButton_OnEvent(self) + BenillaSpellButton_OnClick(self) + BenillaSpellButton_UpdateButton(self) + + BenillaSpellButton_OnClick(self, 1) + BenillaSpellButton_OnClick(self, 1) + BenillaSpellButton_OnEnter(self) + GameTooltip:Hide() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaSpellBookFrame_OnLoad(self) + BenillaSpellBookFrame_OnEvent(self) + BenillaSpellBookFrame_OnShow() + BenillaSpellBookFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/StackSplit.xml b/crates/benilla/assets/ui/StackSplit.xml new file mode 100644 index 00000000..b0ed462d --- /dev/null +++ b/crates/benilla/assets/ui/StackSplit.xml @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaStackSplitFrame_OnHide() + + + diff --git a/crates/benilla/assets/ui/StanceBar.xml b/crates/benilla/assets/ui/StanceBar.xml new file mode 100644 index 00000000..1dc805f1 --- /dev/null +++ b/crates/benilla/assets/ui/StanceBar.xml @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaShapeshiftButton_OnClick(self) + BenillaShapeshiftButton_OnEnter(self) + BenillaShapeshiftButton_OnLeave(self) + + + + + + + + + + + + BenillaShapeshiftButton_OnLoad(self, 1) + + + + BenillaShapeshiftButton_OnLoad(self, 2) + + + + BenillaShapeshiftButton_OnLoad(self, 3) + + + + BenillaShapeshiftButton_OnLoad(self, 4) + + + + BenillaShapeshiftButton_OnLoad(self, 5) + + + + BenillaShapeshiftButton_OnLoad(self, 6) + + + + BenillaShapeshiftButton_OnLoad(self, 7) + + + + BenillaShapeshiftButton_OnLoad(self, 8) + + + + BenillaShapeshiftButton_OnLoad(self, 9) + + + + BenillaShapeshiftButton_OnLoad(self, 10) + + + + BenillaShapeshiftBar_OnLoad(self) + BenillaShapeshiftBar_OnEvent(self) + + UIParent_ManageFramePositions() + UIParent_ManageFramePositions() + + + diff --git a/crates/benilla/assets/ui/TalentFrame.xml b/crates/benilla/assets/ui/TalentFrame.xml new file mode 100644 index 00000000..e934bd0c --- /dev/null +++ b/crates/benilla/assets/ui/TalentFrame.xml @@ -0,0 +1,1191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTalentFrameScrollBar_OnValueChanged(self, arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getglobal(this:GetParent():GetName().."Silver"):SetFocus() + getglobal(this:GetParent():GetName().."Silver"):SetFocus() + this:ClearFocus() + if ( BenillaMoneyInput_Consume("BenillaTradePlayerInputMoney") ) then BenillaTradeFrame_UpdateMoney() end + this:HighlightText(0, 0) + this:HighlightText() + + + + + + + + + + + + + + + + getglobal(this:GetParent():GetName().."Copper"):SetFocus() + getglobal(this:GetParent():GetName().."Copper"):SetFocus() + this:ClearFocus() + if ( BenillaMoneyInput_Consume("BenillaTradePlayerInputMoney") ) then BenillaTradeFrame_UpdateMoney() end + this:HighlightText(0, 0) + this:HighlightText() + + + + + + + + + + + + + + + + this:ClearFocus() + if ( BenillaMoneyInput_Consume("BenillaTradePlayerInputMoney") ) then BenillaTradeFrame_UpdateMoney() end + this:HighlightText(0, 0) + this:HighlightText() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTradeFrame_OnLoad(self) + BenillaTradeFrame_OnEvent(self) + BenillaTradeFrame_OnShow(); PlaySound("igCharacterInfoOpen") + BenillaTradeFrame_OnHide(); PlaySound("igCharacterInfoClose") + + + diff --git a/crates/benilla/assets/ui/TradeSkillFrame.xml b/crates/benilla/assets/ui/TradeSkillFrame.xml new file mode 100644 index 00000000..a2f2ca6f --- /dev/null +++ b/crates/benilla/assets/ui/TradeSkillFrame.xml @@ -0,0 +1,1286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTradeSkillInvSlotDropDown_OnLoad() + BenillaTradeSkillInvSlotDropDown_OnShow() + + + + + + BenillaTradeSkillSubClassDropDown_OnLoad() + BenillaTradeSkillSubClassDropDown_OnShow() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self:ClearFocus() + self:ClearFocus() + + if self:GetText() == "0" then + self:SetText("1") + end + + self:HighlightText(0, 0) + self:HighlightText() + + + + + + + + BenillaTradeSkillFrame_OnLoad(self) + BenillaTradeSkillFrame_OnEvent(self) + BenillaTradeSkillFrame_OnMouseWheel(arg1) + PlaySound("igCharacterInfoOpen") + CloseTradeSkill(); PlaySound("igCharacterInfoClose") + + + diff --git a/crates/benilla/assets/ui/TrainerFrame.xml b/crates/benilla/assets/ui/TrainerFrame.xml new file mode 100644 index 00000000..22ec07f2 --- /dev/null +++ b/crates/benilla/assets/ui/TrainerFrame.xml @@ -0,0 +1,786 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTrainerFilterDropDown_OnLoad() + BenillaTrainerFilterDropDown_OnLoad() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BenillaTrainerFrame_OnLoad(self) + BenillaTrainerFrame_OnEvent(self) + BenillaTrainerFrame_OnMouseWheel(arg1) + PlaySound("igCharacterInfoOpen") + CloseTrainer(); PlaySound("igCharacterInfoClose") + + + diff --git a/crates/benilla/assets/ui/UIDropDownMenu.xml b/crates/benilla/assets/ui/UIDropDownMenu.xml new file mode 100644 index 00000000..af0cb1c1 --- /dev/null +++ b/crates/benilla/assets/ui/UIDropDownMenu.xml @@ -0,0 +1,980 @@ + + + + + + + + + + + + UIDropDownMenuButton_OnClick(); + + + if ( this.hasArrow ) then + ToggleDropDownMenu(this:GetParent():GetID() + 1, this.value); + else + CloseDropDownMenus(this:GetParent():GetID() + 1); + end + getglobal(this:GetName().."Highlight"):Show(); + UIDropDownMenu_StopCounting(this:GetParent()); + + + getglobal(this:GetName().."Highlight"):Hide(); + UIDropDownMenu_StartCounting(this:GetParent()); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CloseDropDownMenus(); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StaticPopup_OnShow() + StaticPopup_OnHide() + StaticPopup_OnUpdate(this, arg1) + + + + + + + + + + + + + + + + self:RegisterEvent("DELETE_ITEM_CONFIRM") + + BenillaDeleteItemConfirmDriver_OnEvent(self) + + + diff --git a/crates/benilla/assets/ui/UnitFrames.xml b/crates/benilla/assets/ui/UnitFrames.xml new file mode 100644 index 00000000..00e88da3 --- /dev/null +++ b/crates/benilla/assets/ui/UnitFrames.xml @@ -0,0 +1,958 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PlayerFrameDropDown_OnLoad() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TargetFrameDropDown_OnLoad() + + + diff --git a/crates/benilla/assets/ui/UnitPopup.xml b/crates/benilla/assets/ui/UnitPopup.xml new file mode 100644 index 00000000..abbd18c2 --- /dev/null +++ b/crates/benilla/assets/ui/UnitPopup.xml @@ -0,0 +1,553 @@ + + + + + + + + + + UnitPopup_OnUpdate(arg1) + + + diff --git a/crates/benilla/assets/ui/WorldMapFrame.xml b/crates/benilla/assets/ui/WorldMapFrame.xml new file mode 100644 index 00000000..361bed3c --- /dev/null +++ b/crates/benilla/assets/ui/WorldMapFrame.xml @@ -0,0 +1,630 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WorldMapContinentsDropDown_OnLoad(); + + + + + + + + + + + + + + + + + + + + + WorldMapZoneDropDown_OnLoad(); + this:RegisterEvent("WORLD_MAP_UPDATE"); + + + if ( this:IsVisible() ) then + WorldMapContinentsDropDown_OnLoad(); + WorldMap_UpdateContinentDropDownText(); + WorldMapZoneDropDown_OnLoad(); + WorldMap_UpdateZoneDropDownText(); + end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + this:RegisterEvent("WORLD_MAP_UPDATE"); + -- The hover area label inherits WorldMapTextFont (nominal 102). 33 was the + -- director's taste call (0235) — which the one-to-one raster cap (the fold-back + -- record; wow-re fontstring-overflow.md) now clamps to the byte law's 32, one + -- unit under that call and closer to it than the 36 the atlas snap used to + -- deliver. The real client draws this SetFont(33) at 32 too. Still a taste dial: + -- any value ≤ 32 is honored exactly. + WorldMapFrameAreaLabel:SetFont("Fonts\\FRIZQT__.TTF", 33); + WorldMapFrame_Update(); + + + WorldMapBlackout:Show(); + SetupFullscreenScale(); + SetMapToCurrentZone(); + WorldMapFrame_Update(); + PlaySound("igQuestLogOpen"); + CloseDropDownMenus(); + + + WorldMapBlackout:Hide(); + PlaySound("igQuestLogClose"); + + + if ( WorldMapFrame:IsVisible() ) then + WorldMapFrame_Update(); + end + + + + diff --git a/crates/benilla/assets/ui/ZoneText.xml b/crates/benilla/assets/ui/ZoneText.xml new file mode 100644 index 00000000..235ba703 --- /dev/null +++ b/crates/benilla/assets/ui/ZoneText.xml @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FadingFrame_OnLoad(); + FadingFrame_SetFadeInTime(this, ZoneFadeInDuration); + FadingFrame_SetHoldTime(this, ZoneHoldDuration); + FadingFrame_SetFadeOutTime(this, ZoneFadeOutDuration); + this:RegisterEvent("ZONE_CHANGED"); + this:RegisterEvent("ZONE_CHANGED_INDOORS"); + this:RegisterEvent("ZONE_CHANGED_NEW_AREA"); + + + local newText = GetZoneText(); + if ( event == "ZONE_CHANGED" ) then + this.zoneText = newText; + return; + end + if ( newText ~= this.zoneText ) then + this.zoneText = newText; + if ( event == "ZONE_CHANGED_NEW_AREA" ) then + SetZoneText(1); + else + SetZoneText(); + end + FadingFrame_Show(); + end + + + FadingFrame_OnUpdate(); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FadingFrame_OnLoad(); + FadingFrame_SetFadeInTime(this, ZoneFadeInDuration); + FadingFrame_SetHoldTime(this, ZoneHoldDuration); + FadingFrame_SetFadeOutTime(this, ZoneFadeOutDuration); + this:RegisterEvent("ZONE_CHANGED"); + this:RegisterEvent("ZONE_CHANGED_INDOORS"); + this:RegisterEvent("ZONE_CHANGED_NEW_AREA"); + PVPArenaTextString:SetTextColor(1.0, 0.1, 0.1); + SetZoneText(1); + SubZoneTextString:SetText(GetSubZoneText()); + + + local text = SubZoneTextString:GetText(); + local newText = GetSubZoneText(); + if(text ~= newText) then + SubZoneTextString:SetText(newText); + SetZoneText(1); + FadingFrame_Show(); + end + + + FadingFrame_OnUpdate(); + + + + diff --git a/crates/benilla/src/area.rs b/crates/benilla/src/area.rs new file mode 100644 index 00000000..8bd523cd --- /dev/null +++ b/crates/benilla/src/area.rs @@ -0,0 +1,432 @@ +//! The player's area identity, published to the UI — the real 1.12 `ZONE_CHANGED` event family +//! and the zone-text host globals behind `GetZoneText`/`GetSubZoneText`/`GetRealZoneText`/ +//! `GetMinimapZoneText`/`GetZonePVPInfo` (decision 0287; corrected to the bytes by its fold-back +//! record). +//! +//! Byte-verified model (wow-re ui `zonetext-pvpinfo.md`; `0x494780` is the one updater all three +//! zone events fire from, on the per-update area resolve): +//! +//! - The client caches `(zoneId, zoneText, subzoneText)` and compares per update: **zone id +//! changed → `ZONE_CHANGED_NEW_AREA` fires alone**; else **either text changed → +//! `ZONE_CHANGED_INDOORS`** when the resolver's indoor bit is set, else **`ZONE_CHANGED`**. +//! Mutually exclusive; the first world-enter is a zone-id change from the zeroed cache. +//! - **Indoors, the zone-text slot takes the WMO interior's name and the subzone slot nulls** +//! (`0x67e670`) — entering a named inn splashes the inn's name. `GetRealZoneText` reads the +//! pre-override (WMO-immune) zone name; `GetMinimapZoneText` reads subzone-else-zone +//! (`0xb4da28`, its own change event `MINIMAP_ZONE_CHANGED` from `0x494970`). +//! - The "zone" is the leaf's **single-hop parent** (AreaTable field 2; itself when 0) — equal +//! to the defensive `top_zone` walk on 5875 data (chains are 1–2 deep), which the world map +//! keeps using. +//! - `GetZonePVPInfo` (`0x48d540`): `isArena` = the **leaf** row's Flags bit `0x80` (the FFA-pit +//! flag); ownership = the **zone** row's FactionGroupMask vs the player template's +//! friend-then-enemy group masks → "friendly"/"hostile", else **"contested" — never nil for an +//! ownerless zone** (nil is structural failure only); `factionName` = FactionGroup.dbc's +//! localized Name for the zone's mask bit; pvpType is never "arena"; realm type never enters. +//! - **The indoor bit** is the player's faces-only down-ray verdict +//! ([`crate::wmo_portal::CurrentAreaInterior`] — wow-re `zonetext-indoor-bit.md`, the CGLight +//! node's `+0x90` bit 0): indoors ⇔ the nearest surface below is a WMO face whose group lacks +//! MOGP `0x8` EXTERIOR. The abbey yard is terrain-below ⇒ outdoors; the flip is the doorway. +//! - **The indoor naming** (`0x67e670` (d), byte-pinned by (d-ii)): while indoors and the hit +//! GROUP is unchanged, the WHOLE updater is skipped (the dedup `[0x868608]`/`[0x86860c]`). On +//! an indoor change: **query A** (the whole-WMO −1 row, exact key, `0x69d830`) overrides the +//! ZONE slot — only when its resolved name is non-empty AND differs from the current subzone +//! (the inn splash; skipped at the abbey, whose default name equals its yard subzone) — +//! nulling the subzone; an existing-but-unnamed −1 row resolves through its `AreaTableID`'s +//! AreaTable name (Ironforge's unnamed city WMO), a missing row never overrides. Then +//! **query B** (the hit group's own row, exact key, `0x69d8f0`) re-populates the subzone +//! ("Main Hall"). No name-set retry, no cross-row fallback anywhere. +//! +//! The area *authority* stays `terrain_stream::CurrentArea` (decision 0232 — the MCNK `areaId` +//! with the WMO-interior override, now off the same faces-only claim). Host globals are written +//! before the event fires, so a handler's `GetZoneText()` already sees the new state. +//! +//! Named divergence: the dedup keys `(WMOID, MOGP uniqueID)` where the client stores the raw +//! per-WMO group INDEX — ours changes exactly when the hit group does but never aliases across +//! adjacent buildings the way the raw index can. INTERIM residue: the `[0x88272c]` PvP display +//! gate (an unnamed cached boolean, verified NOT realm type) ships open. +//! +//! [`AreaTableRes`] is also the one shared `AreaTable.dbc` projection (0287's consolidation): +//! the world map and this module consume it. (`area_sound.rs` keeps its own audio-FK fold — +//! different columns, different concern.) + +use bevy::ecs::system::NonSendMut; +use bevy::prelude::*; + +use benilla_formats::AreaTableCatalog; +use benilla_ui::script::UiScript; + +use crate::assets::{AssetSet, LockRecover, WorldAssets}; +use crate::net::{ObjectStore, SelfPlayer}; +use crate::target::Factions; +use crate::terrain_stream::CurrentArea; +use crate::wmo_portal::CurrentAreaInterior; + +/// The shared `AreaTable.dbc` catalog: id → (name, parent zone, flags, faction mask). Loaded once +/// at Startup; absent if the DBC failed to read (consumers take `Option` and go quiet). +#[derive(Resource)] +pub(crate) struct AreaTableRes(pub(crate) AreaTableCatalog); + +/// Startup: load the shared area catalog off the patch chain. +fn load_area_table(mut commands: Commands, assets: Option>) { + let Some(assets) = assets else { return }; + let loaded = { + let mut chain = assets.chain.lock_recover(); + benilla_formats::load_area_table_catalog(&mut chain) + }; + match loaded { + Ok(cat) => { + info!("area: {} rows in the shared AreaTable catalog", cat.len()); + commands.insert_resource(AreaTableRes(cat)); + } + Err(e) => warn!("area: AreaTable catalog failed to load: {e:#}"), + } +} + +/// What the client's zone-text cache holds — the compare targets of `0x494780` (zone id + the +/// two display strings), the minimap line's own cache (`0x494970`), and the indoor dedup pair +/// (`[0x868608]` prev-indoor / `[0x86860c]` prev WMO-area row id). +#[derive(Default)] +struct ZoneCache { + /// `None` = never resolved (the zeroed BSS cache: the first resolve is a NEW_AREA). + zone_id: Option, + zone_text: String, + subzone_text: String, + minimap_text: String, + /// The previous update's indoor bit (`[0x868608]`). + indoor: bool, + /// The previous hit group's identity (`[0x86860c]` — the client stores the group index, + /// (d-ii); we scope the MOGP uniqueID by WMO): same group while indoors ⇒ the whole updater + /// is skipped. + wmo_id: u32, + wmo_group: u32, +} + +/// The texts + indoor bit one resolve produces (the updater's compare inputs). +struct ZoneSignal { + zone_id: u32, + zone_text: String, + subzone_text: String, + indoor: bool, +} + +/// The event election, verbatim from `0x494780`: zone id changed → NEW_AREA **alone**; else any +/// text changed → INDOORS/CHANGED by the indoor bit; else nothing. +fn elect_event(cache: &ZoneCache, next: &ZoneSignal) -> Option<&'static str> { + if cache.zone_id != Some(next.zone_id) { + return Some("ZONE_CHANGED_NEW_AREA"); + } + if cache.zone_text != next.zone_text || cache.subzone_text != next.subzone_text { + return Some(if next.indoor { + "ZONE_CHANGED_INDOORS" + } else { + "ZONE_CHANGED" + }); + } + None +} + +/// The zone-splash data plane (module doc). Per resolve: compute the display texts (with the +/// indoor override), the PvP tuple, write the host globals, then fire the elected zone event +/// and — independently, like the client's second site — `MINIMAP_ZONE_CHANGED` when the +/// subzone-else-zone line changed. +#[allow(clippy::too_many_arguments)] +fn feed_zone_events( + script: Option>, + area: Res, + interior: Res, + wmo_areas: Option>, + areas: Option>, + factions: Option>, + self_store: Query<&ObjectStore, With>, + mut cache: Local, +) { + let (Some(mut script), Some(areas)) = (script, areas) else { + return; + }; + let Some(leaf) = area.0 else { return }; + let Some(leaf_row) = areas.0.get(leaf) else { + // An id the catalog doesn't know stays unpublished (keep the last real state, like the + // resolver itself does for areaId 0). + return; + }; + // The zone: the leaf's single-hop parent (field 2), itself when 0 (`0x494780`'s stored row; + // == `top_zone` on 5875's 1–2-deep chains). + let zone = if leaf_row.zone_id == 0 { + leaf + } else { + leaf_row.zone_id + }; + let real_zone_text = areas.0.name(zone).unwrap_or_default().to_string(); + let indoor = interior.0.is_some(); + + // The outdoor texts — the locals `0x67e670` starts from. + let mut zone_text = real_zone_text.clone(); + let mut subzone_text = if leaf == zone { + String::new() + } else { + leaf_row.name.clone() + }; + + // The indoor naming (`0x67e670` (d)/(d-ii), module doc): dedup-skip while the hit group + // holds; else the default-row name (query A) may override the zone slot and the group-row + // name (query B) re-populates the subzone — both EXACT-key lookups (no name-set retry). + let mut wmo_group = 0u32; + if let Some((k, cat)) = interior.0.zip(wmo_areas.as_ref()) { + // The dedup key: the hit GROUP's identity (the client's `[groupRec+0x7c]` group index, + // nonzero-gated — (d-ii)). We key the MOGP uniqueID scoped by WMO id: it changes exactly + // when the hit group does, and — a named divergence — never aliases across two adjacent + // buildings the way the client's raw per-WMO index can. + wmo_group = k.group_area_id; + if cache.indoor + && k.group_area_id != 0 + && (cache.wmo_id, cache.wmo_group) == (k.wmo_id, wmo_group) + { + return; // the dedup: same WMO group ⇒ the whole updater is skipped + } + cache.wmo_id = k.wmo_id; + let group = cat.0.group_row(k.wmo_id, k.name_set, k.group_area_id); + let default = cat.0.default_row(k.wmo_id, k.name_set); + // Query A (the whole-WMO −1 row): override the zone name + null the subzone — only when + // the resolved name is non-empty and differs from the current subzone (the abbey skip). + // An EXISTING-but-unnamed row falls back to an AreaTable name — the row's own + // `AreaTableID`, else the current leaf (how Ironforge's unnamed city WMO splashes + // "Ironforge"; the client's area-0 arm reads the raw terrain areaId at the node — the + // current leaf equals it in that arm, since an area-0 row also never overrode the leaf). + // A MISSING row never overrides (the client's `""` sentinel bypass). + let a_name = default.map(|d| { + if !d.name.is_empty() { + d.name.clone() + } else if d.area_table_id != 0 { + areas + .0 + .name(d.area_table_id) + .unwrap_or_default() + .to_string() + } else { + leaf_row.name.clone() + } + }); + if let Some(a) = a_name.filter(|n| !n.is_empty()) { + if a != subzone_text { + zone_text = a; + subzone_text = String::new(); + } + } + // Query B (the hit group's own row): re-populate the subzone (empty/missing = leave it). + if let Some(b) = group.map(|r| r.name.as_str()).filter(|n| !n.is_empty()) { + subzone_text = b.to_string(); + } + } + let signal = ZoneSignal { + zone_id: zone, + zone_text, + subzone_text, + indoor, + }; + // GetMinimapZoneText's own line: subzone-else-zone (`0xb4da28`). + let minimap_text = if signal.subzone_text.is_empty() { + signal.zone_text.clone() + } else { + signal.subzone_text.clone() + }; + + let event = elect_event(&cache, &signal); + let minimap_changed = cache.minimap_text != minimap_text; + if event.is_none() && !minimap_changed { + return; + } + + // GetZonePVPInfo (`0x48d540`): leaf row's 0x80 for isArena; zone row's mask vs the player + // template's friend-then-enemy masks; factionName from FactionGroup.dbc. Never "arena" as a + // type; "contested" (not nil) for an ownerless zone; nil only on structural failure. The + // `[0x88272c]` display gate ships open (module doc). + let is_arena = leaf_row.flags & 0x80 != 0; + let zone_mask = areas.0.get(zone).map_or(0, |r| r.faction_group_mask); + let pvp = factions + .as_ref() + .zip(self_store.single().ok()) + .and_then(|(f, store)| { + let tpl = f.catalog().template(store.0.unit_faction_template()?)?; + let ty = if zone_mask & tpl.friend_group_mask != 0 { + "friendly" + } else if zone_mask & tpl.enemy_group_mask != 0 { + "hostile" + } else { + "contested" + }; + Some((ty, f.catalog().faction_group_name(zone_mask).unwrap_or(""))) + }); + let (pvp_type, pvp_faction) = pvp.unwrap_or(("", "")); + + let globals = script.lua().globals(); + let pushed = globals + .set("__benilla_zone_name", signal.zone_text.clone()) + .and_then(|()| globals.set("__benilla_real_zone_name", real_zone_text)) + .and_then(|()| globals.set("__benilla_subzone_name", signal.subzone_text.clone())) + .and_then(|()| globals.set("__benilla_zone_text", minimap_text.clone())) + .and_then(|()| globals.set("__benilla_pvp_type", pvp_type)) + .and_then(|()| globals.set("__benilla_pvp_faction", pvp_faction)) + .and_then(|()| globals.set("__benilla_pvp_arena", is_arena)); + if let Err(e) = pushed { + warn!("area: zone host globals: {e}"); + return; + } + + if let Some(event) = event { + script.fire_event(event, vec![]); + debug!( + "area: {event} → zone {:?} / sub {:?} ({pvp_type})", + signal.zone_text, signal.subzone_text + ); + } + if minimap_changed { + script.fire_event("MINIMAP_ZONE_CHANGED", vec![]); + debug!("area: minimap zone text → {minimap_text:?}"); + } + cache.zone_id = Some(signal.zone_id); + cache.zone_text = signal.zone_text; + cache.subzone_text = signal.subzone_text; + cache.minimap_text = minimap_text; + cache.indoor = signal.indoor; + cache.wmo_group = wmo_group; +} + +/// The shared area catalog + the zone-event feed (decision 0287 + fold-back). +pub(crate) struct AreaPlugin; + +impl Plugin for AreaPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, load_area_table.after(AssetSet::Open)) + .add_systems( + Update, + // After the script tick, like the other feeds: the event dispatches on the next + // tick — a frame of latency the splash can't perceive. MINIMAP_ZONE_CHANGED's + // handler reads the PvP globals written above in the same call. ALSO after the + // leaf authority (which orders after the interior claim): leaf, indoor bit, and + // names must come from ONE coherent frame — the client's single-pass resolve + // (`0x67e510`); a fresh claim against a stale leaf fired the abbey login's + // spurious big splash. + feed_zone_events + .after(crate::ui_script::UiInput) + .after(crate::terrain_stream::AreaAuthoritySet), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sig(zone_id: u32, zone: &str, sub: &str, indoor: bool) -> ZoneSignal { + ZoneSignal { + zone_id, + zone_text: zone.into(), + subzone_text: sub.into(), + indoor, + } + } + + fn cache(zone_id: Option, zone: &str, sub: &str) -> ZoneCache { + ZoneCache { + zone_id, + zone_text: zone.into(), + subzone_text: sub.into(), + ..ZoneCache::default() + } + } + + /// The `0x494780` election domain: first-enter and zone hops take NEW_AREA alone; same-zone + /// text changes split INDOORS/CHANGED on the indoor bit; no change fires nothing. + #[test] + fn election_matches_the_byte_law() { + // First world-enter: the zeroed cache is a zone-id change. + let c = ZoneCache::default(); + assert_eq!( + elect_event(&c, &sig(12, "Elwynn Forest", "Northshire Valley", false)), + Some("ZONE_CHANGED_NEW_AREA") + ); + + // Zone hop: NEW_AREA alone — even though the texts changed too. + let c = cache(Some(12), "Elwynn Forest", ""); + assert_eq!( + elect_event(&c, &sig(40, "Westfall", "The Jansen Stead", false)), + Some("ZONE_CHANGED_NEW_AREA") + ); + + // Outdoor subzone hop: same zone, subzone text changed → ZONE_CHANGED. + let c = cache(Some(12), "Elwynn Forest", ""); + assert_eq!( + elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)), + Some("ZONE_CHANGED") + ); + + // Inn entry: same zone, the ZONE text changed by the indoor override, indoor set → + // INDOORS (the named-interior splash). + let c = cache(Some(12), "Elwynn Forest", "Goldshire"); + assert_eq!( + elect_event(&c, &sig(12, "Lion's Pride Inn", "", true)), + Some("ZONE_CHANGED_INDOORS") + ); + + // Inn exit: texts revert, outdoors again → plain ZONE_CHANGED (the frames re-cache + // silently and only the subzone line splashes — the consumer law). + let c = cache(Some(12), "Lion's Pride Inn", ""); + assert_eq!( + elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)), + Some("ZONE_CHANGED") + ); + + // No change → nothing. + let c = cache(Some(12), "Elwynn Forest", "Goldshire"); + assert_eq!( + elect_event(&c, &sig(12, "Elwynn Forest", "Goldshire", false)), + None + ); + } + + /// The indoor naming law (`0x67e670` (d)) against the REAL abbey/inn data shapes — the + /// override-skip (whole-WMO name == yard subzone) and the override-fire (inn) branches, + /// run through the same WmoAreaCatalog the live feed reads. Skips without client data. + #[test] + fn indoor_naming_matches_the_byte_law_on_real_data() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present"); + return; + } + let mut chain = benilla_formats::open_chain(&data).expect("chain"); + let cat = benilla_formats::load_wmo_area_catalog(&mut chain).expect("WMOAreaTable"); + + // The abbey (WMO 59, Northshire placement name-set 1): the default row's name equals the + // yard subzone ⇒ the zone override SKIPS; group 1934's own name re-fills the subzone. + let default = cat.default_row(59, 1).expect("abbey default row"); + assert_eq!(default.name, "Northshire Abbey"); + let group = cat.group_row(59, 1, 1934).expect("abbey main hall row"); + assert_eq!(group.name, "Main Hall"); + assert_eq!( + group.area_table_id, 24, + "the leaf stays Northshire Abbey (24)" + ); + // The dedup rides the hit-group identity ((d-ii): the client's group index; our MOGP + // uniqueID) — distinct per room, so a room hop re-fires and standing still skips. + let library = cat.group_row(59, 1, 1943).expect("library row"); + assert_ne!(group.id, library.id, "distinct rows exist per room"); + + // An unnamed room (group 1935): query B empty ⇒ the subzone keeps what the override + // left — with the override skipped, that's the terrain leaf name. + let unnamed = cat.group_row(59, 1, 1935).expect("unnamed room row"); + assert!(unnamed.name.is_empty()); + + // The Goldshire inn (WMO 53, name-set 2 — its ONLY row is the whole-WMO one): the + // default name differs from the street subzone ("Goldshire") ⇒ the override FIRES (the + // big inn splash), and with no named group row the subzone stays nulled — the un-doubled + // inn entry. Its AreaTableID is 0, so the leaf stays the terrain chunk's (Goldshire). + let inn = cat.default_row(53, 2).expect("the inn's whole-WMO row"); + assert_eq!(inn.name, "Lion's Pride Inn"); + assert_eq!(inn.area_table_id, 0); + assert!( + cat.group_row(53, 2, 0).is_none_or(|g| g.name.is_empty()), + "no named group rows — query B leaves the nulled subzone alone" + ); + } +} diff --git a/crates/benilla/src/assets/images.rs b/crates/benilla/src/assets/images.rs new file mode 100644 index 00000000..a9fd9959 --- /dev/null +++ b/crates/benilla/src/assets/images.rs @@ -0,0 +1,287 @@ +//! Texture/image helpers: turn decoded BLP mip chains + raw RGBA into Bevy `Image`s (correct format, +//! sampler, mip layout) plus the small solid-colour / cursor / liquid-frame textures. Pure helpers the +//! asset foundation (`super`) and other subsystems call. Split out of `assets/mod.rs`. + +use bevy::asset::RenderAssetUsages; +use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; +use bevy::prelude::*; +use bevy::render::render_resource::{ + Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension, +}; + +use benilla_formats::BlpMipChain; + +/// Build a full mip chain for an RGBA texture (mip 0 = `mip0`), halving each level to 1×1 with a +/// triangle (box-ish) downsample. Returns the concatenated mip data + the level count. For a single +/// layer this is the `LayerMajor`/`MipMajor`-agnostic order (mip0, mip1, …). +/// Color (albedo) texture format: **non-sRGB `Rgba8Unorm`**, so the GPU does NOT linearize albedo on +/// sample. Vanilla's fixed-function pipeline multiplied gamma (sRGB) texture bytes by gamma light +/// bytes in **gamma/byte space**; loading albedo as `…Unorm` keeps our shader math in that same space. +/// This is the RE'd faithful **invariant** (no sRGB↔linear round-trip anywhere). Used for model + +/// terrain albedo. +pub(crate) fn color_texture_format() -> TextureFormat { + TextureFormat::Rgba8Unorm +} + +/// A repeating, **mipmapped + anisotropically-filtered** texture built from the BLP's **authored** +/// mip pyramid — the levels the real 1.12 client uploads verbatim (`WoW.exe` has no GLU / +/// `glGenerateMipmap`; it cannot regenerate mips, only upload the stored ones). Using the authored +/// levels instead of CPU-resampling mip 0 with a box/Triangle filter in gamma byte space is the +/// model-side of the C2 fix: gamma-byte averaging darkens mid-tones and, on alpha-cutout foliage, +/// skews the leaf colour cool (it averages the dark *transparent* texels' RGB into the leaves, 5–18 +/// LSB darker in R/G) and drifts coverage with distance. The authored mips already encode WoW's +/// coverage-correct, leaf-coloured downsample, so we lay them in as-is under the alpha test — no +/// re-filter, no alpha-to-coverage (1.12 does neither). Mips + anisotropy give the no-shimmer look +/// at distance without softening the up-close art. +pub(crate) fn repeat_texture_authored(chain: BlpMipChain) -> Image { + let levels = chain.mips.len() as u32; + let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below + let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum()); + for mip in &chain.mips { + data.extend_from_slice(mip); + } + let mut image = Image::new( + Extent3d { + width: chain.width, + height: chain.height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + mip0, + color_texture_format(), + RenderAssetUsages::default(), + ); + image.data = Some(data); + image.texture_descriptor.mip_level_count = levels; + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + // Trilinear + anisotropic: smooth, sharp-where-it-should-be, no shimmer — the vanilla look. + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: 8, + ..default() + }); + image +} + +/// Stack the animated liquid frames into one **mipmapped + anisotropic** `texture_2d_array` from each +/// frame's BLP **authored** mip pyramid — same fidelity rule as [`repeat_texture_authored`]: the real +/// 1.12 client uploads the BLP's stored mips verbatim (no client-side regeneration). Without mips, the +/// 256² ripple tiled over ~16 yd aliases into dense sparkle "noise" at distance; the authored mips + +/// anisotropy give the reference's smooth, streaky wave highlights instead. Data is laid out +/// **LayerMajor** (`frame0[mip0..mipN], frame1[mip0..], …`, the wgpu/Bevy default) with a full +/// `size→1` chain; a frame whose authored chain is shorter/odd is filled by `NEAREST` from its nearest +/// authored level (gamma-safe, no averaging — mirrors [`chain_to_layer`]). All frames must share mip-0 +/// `size` (the caller enforces this). Returns a `D2Array`-viewed image with `mip_level_count` set. +pub(crate) fn liquid_frame_array(frames: Vec) -> Image { + let size = frames[0].width; + // Full mip chain down to 1×1 (square 1.12 frames): levels = log2(size) + 1. + let mip_level_count = { + let (mut n, mut s) = (1u32, size); + while s > 1 { + s >>= 1; + n += 1; + } + n + }; + let mut data = Vec::with_capacity(frames.len() * mip_chain_byte_size(size, mip_level_count)); + for blp in &frames { + for level in 0..mip_level_count { + let lw = (size >> level).max(1); + // Match the array level to the authored mip of the same size (normally identity — water + // frames carry a full chain); fall back to the nearest authored level via NEAREST. + let src_level = if blp.width >= lw { + let ratio = (blp.width / lw).max(1); + (ratio.trailing_zeros() as usize).min(blp.mips.len() - 1) + } else { + 0 + }; + let (sw, sh) = blp.mip_size(src_level as u32); + extend_nearest(&mut data, &blp.mips[src_level], sw, sh, lw, lw); + } + } + let mut image = Image::new_uninit( + Extent3d { + width: size, + height: size, + depth_or_array_layers: frames.len() as u32, + }, + TextureDimension::D2, + color_texture_format(), + RenderAssetUsages::default(), + ); + image.data = Some(data); + image.texture_descriptor.mip_level_count = mip_level_count; + image.texture_view_descriptor = Some(TextureViewDescriptor { + dimension: Some(TextureViewDimension::D2Array), + ..default() + }); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + // Trilinear + 16× anisotropic — VERIFIED the reference ripple sampler (apitrace WoW.20: tex 442, + // LINEAR_MIPMAP_LINEAR + MAX_ANISOTROPY 16). This is what turns the unmipped sparkle into the + // reference's sparse, smooth, elongated wave-streaks at the grazing water surface. + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + anisotropy_clamp: 16, + ..default() + }); + image +} + +/// A single-mip RGBA8 image for winit's custom OS cursor (`CursorIcon::Custom`). Vanilla cursors are +/// tiny (`Point.blp` is 32×32), so unlike [`repeat_texture`] this needs neither a mip chain nor a +/// repeat sampler. macOS doesn't use this path (it builds an `NSCursor` directly — see `crate::cursor`). +#[cfg(not(target_os = "macos"))] +pub(crate) fn cursor_texture(width: u32, height: u32, rgba: Vec) -> Image { + Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::default(), + ) +} + +/// A single-mip, **sRGB**, clamp-sampled image for an emissive celestial billboard (the sun/moon +/// discs — see [`WorldAssets::sprite_texture`]). sRGB so an unlit pass round-trips the authored gamma +/// bytes to screen; clamp-to-edge + linear (no repeat, no mips) since the whole texture maps to one +/// quad and the disc never tiles or minifies far. +pub(crate) fn sprite_image(width: u32, height: u32, rgba: Vec) -> Image { + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::ClampToEdge, + address_mode_v: ImageAddressMode::ClampToEdge, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + ..default() + }); + image +} + +/// A coverage **mask** image (the minimap's `MinimapMask.blp`, decision 0203): identical sampling +/// to [`sprite_image`] (clamp, linear filtering, no mips) but uploaded **`Rgba8Unorm`** (not +/// sRGB) — the shader reads coverage, not color, so no gamma decode belongs anywhere in the path. +/// The coverage itself rides the ALPHA channel (`MinimapMask.blp` is DXT3: white color plane, the +/// circle ramp authored in its 8-bit alpha; alpha is gamma-free in either format, but the linear +/// upload keeps the color plane byte-exact too, and documents the intent). +pub(crate) fn mask_image(width: u32, height: u32, rgba: Vec) -> Image { + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8Unorm, + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::ClampToEdge, + address_mode_v: ImageAddressMode::ClampToEdge, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + ..default() + }); + image +} + +/// A portrait sprite: [`sprite_image`] with the RGBA pre-masked to its **inscribed circle** — the +/// alpha of every texel outside the largest centred circle is zeroed (with a 1px anti-aliased edge), +/// so a square icon or model reads as the round portrait the unit-frame ring expects. The ring in +/// `UI-TargetingFrame` is a thin band with transparent corners; without the mask the square texture's +/// corners poke past it (the placeholder `INV_Misc_QuestionMark` even bakes a grey border, so its +/// corners read as metal brackets). Same sRGB/clamp/linear/no-mip setup as [`sprite_image`]. +pub(crate) fn portrait_image(width: u32, height: u32, mut rgba: Vec) -> Image { + if width > 0 && height > 0 && rgba.len() == (width * height * 4) as usize { + let (cx, cy) = (width as f32 / 2.0, height as f32 / 2.0); + let radius = cx.min(cy); + for y in 0..height { + for x in 0..width { + let dx = x as f32 + 0.5 - cx; + let dy = y as f32 + 0.5 - cy; + let dist = (dx * dx + dy * dy).sqrt(); + // Coverage: 1 well inside, 0 well outside, a 1px linear ramp across the edge. + let coverage = (radius - dist).clamp(0.0, 1.0); + let a = ((y * width + x) * 4 + 3) as usize; + rgba[a] = (f32::from(rgba[a]) * coverage).round() as u8; + } + } + } + sprite_image(width, height, rgba) +} + +/// A single-mip, **sRGB**, **repeat**-sampled image for a frame `Backdrop`'s tiled pieces (the +/// tooltip/panel border edges + a tiling background — `backdrop-mechanism.md` §2/§3). Identical to +/// [`sprite_image`] except the address mode is `Repeat`: a backdrop edge strip runs UVs `[0..N]` +/// (N = the edge count = frame-side / edgeSize − 2) and a tiled bg runs `[0..w/period]`, so the +/// slice must *wrap*, not clamp-stretch. The real client flags exactly this on the backdrop's +/// `SetTexture` (arg2 pushed twice into the load descriptor — `backdrop-mechanism.md` §2, INFERRED +/// U+V wrap). sRGB + no mips, same as the clamp sprite (UI art, one authored gamma round-trip). +pub(crate) fn sprite_image_tiled(width: u32, height: u32, rgba: Vec) -> Image { + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + address_mode_u: ImageAddressMode::Repeat, + address_mode_v: ImageAddressMode::Repeat, + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + ..default() + }); + image +} + +/// Total bytes for a mip pyramid of `mip_count` levels starting at `top²` RGBA8. +pub(crate) fn mip_chain_byte_size(top: u32, mip_count: u32) -> usize { + (0..mip_count) + .map(|i| { + let w = (top >> i).max(1); + (w * w * 4) as usize + }) + .sum() +} + +/// Append `src` (RGBA8 `sw × sh`) resized to `dw × dh` to `out`, using `NEAREST` filtering in +/// gamma byte space. NEAREST = pure texel-replication (or decimation), so no averaging happens — +/// preserves gamma-encoded bytes verbatim (no darkening). Identity at `(sw, sh) == (dw, dh)`. +pub(crate) fn extend_nearest(out: &mut Vec, src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) { + if sw == dw && sh == dh { + out.extend_from_slice(src); + return; + } + let Some(buf) = image::RgbaImage::from_raw(sw, sh, src.to_vec()) else { + // Shape mismatch — fall back to a black layer rather than panicking. + out.extend(std::iter::repeat_n(0u8, (dw * dh * 4) as usize)); + return; + }; + let resized = image::imageops::resize(&buf, dw, dh, image::imageops::FilterType::Nearest); + out.extend_from_slice(resized.as_raw()); +} diff --git a/crates/benilla/src/assets/mod.rs b/crates/benilla/src/assets/mod.rs new file mode 100644 index 00000000..770f372b --- /dev/null +++ b/crates/benilla/src/assets/mod.rs @@ -0,0 +1,424 @@ +//! Shared asset foundation for the client: the **one** open patch chain plus dedup caches, and the +//! helpers that decode raw BLP pixels into Bevy textures + build per-submesh `WowModelMaterial`s. +//! Used by the ground-clutter build (which still reads models off the chain); the terrain streamer and +//! streamed entities load through the `benilla-assets` `mpq://` pipeline instead. Deduping by +//! path/material means each unique BLP is decoded + uploaded once, and submeshes sharing a texture+blend +//! share a material handle — which is what lets Bevy batch draws. +//! +//! (Pure relocation out of `main.rs`. The +//! `AssetPlugin` that opens the chain at startup lands in a later step.) + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, MutexGuard}; + +use bevy::image::Image; +use bevy::prelude::*; +use bevy::render::render_resource::{Buffer, Face}; +use bevy::render::renderer::RenderDevice; + +use crate::model_render::VANILLA_ALPHA_KEY_REF; +use crate::terrain::{WowModelExt, WowModelMaterial}; +use benilla_formats::{open_chain, read_texture_mip_chain, read_texture_rgba, Chain, ModelBlend}; + +/// BLP/RGBA → Bevy `Image` texture helpers (+ solid/cursor/liquid-frame textures). Split out for size; +/// re-exported so callers keep using `crate::assets::{solid_layer_chain, …}`. +mod images; +pub(crate) use images::*; + +/// Shared asset store: the **one** open patch chain plus dedup caches, used by every model/texture +/// load (terrain layers, doodads, WMOs, creatures, GameObjects). Replaces the three independent +/// chains we used to open. Deduping by path/material means each unique BLP is decoded + uploaded +/// once, and submeshes sharing a texture+blend share a `StandardMaterial` handle — which is what +/// lets Bevy batch their draw calls. +#[derive(Resource)] +pub(crate) struct WorldAssets { + /// The **one** open patch chain, behind `Arc>` because the streaming loaders thread a + /// `&mut Chain` (the `read_file` shim); the `Mutex` is `Send + Sync` glue, not a read serializer — + /// `Chain` reads are `&self` and open a fresh OS handle, so they share no seek state. + /// Main-thread reads lock via [`LockRecover`] (poison-tolerant); contention is ~nil since after + /// startup the only main-thread reads are the occasional map-change WDL load + lazy clutter M2s. + pub(crate) chain: Arc>, + /// Decoded GPU textures by normalized path. + pub(crate) textures: HashMap>, + /// Decoded UI sprite textures by resolved path (`None` = a miss, cached so a bad path never + /// re-walks the chain per frame). Separate from `textures`: sprites are sRGB/clamped, world + /// art is Unorm/repeat — the same BLP can legitimately live in both. + sprites: HashMap>>, + /// Decoded UI sprite textures sampled with **repeat** addressing (frame `Backdrop` pieces — + /// tiled edges/bg). Separate cache from `sprites`: the *same* BLP can be wanted clamp-sampled as + /// a plain sprite and repeat-sampled as a backdrop piece, and the two need distinct GPU images + /// (the sampler is baked into the `Image`). See [`Self::sprite_texture_tiled`]. + tiled_sprites: HashMap>>, + /// Decoded **portrait** sprites — [`Self::sprite_texture`]'s clamp/sRGB sprite with a circular + /// alpha mask baked in ([`portrait_image`]). Its own cache, like `tiled_sprites`: the mask bakes + /// into the GPU image, so the same BLP wanted as a plain icon and as a portrait needs two images. + portraits: HashMap>>, + /// Decoded coverage **masks** ([`mask_image`] — linear, not sRGB): the minimap's circular clip + /// (decision 0203). Its own cache for the same reason as the others: the format is baked into + /// the GPU image. + masks: HashMap>>, + /// Materials deduped by their identity (texture path + blend, or the untextured fallback). + pub(crate) model_materials: HashMap>, + /// The shared global-light storage buffer (`lighting::global_light`). Held here so `model_material` + /// can clone it into every deduped model material's `light_buf` without threading a `Buffer` through + /// the many call sites. One buffer for the whole scene, updated in place each frame. + pub(crate) shared_light: Buffer, +} + +/// Identity of a deduped model material: a textured material is uniquely determined by its texture +/// path, blend mode, sidedness, alpha-key cutoff, and clutter fade-distance; everything textureless +/// (missing/failed texture) shares one fallback. Cutoff + fade are in the key so ground clutter (lower +/// alpha ref + a distance fade) doesn't collide with a tree sharing the same texture. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) enum MaterialKey { + /// `(texture, blend, two_sided, alpha_key_u8, fade_far_yd_u16, is_wmo, is_fade_variant)`. + Textured(String, ModelBlend, bool, u8, u16, bool, bool), + Fallback(bool), +} + +/// Normalize an internal asset path so case/slash variants of the same file share a cache entry +/// (MPQ lookup is case-insensitive and accepts either slash). +pub(crate) fn normalize_path(path: &str) -> String { + path.replace('/', "\\").to_ascii_lowercase() +} + +/// Lock a `Mutex`, recovering the guard even if a previous holder **poisoned** it by panicking. +/// +/// The shared [`WorldAssets::chain`] is read from many systems; one panic mid-read would otherwise +/// poison the lock and turn every subsequent `lock().unwrap()` — including the render loop's — into a +/// cascade. The chain read is a best-effort decode (a half-read texture is harmless), so we recover +/// the guard and carry on. (Phase 3 deleted the net subsystem's shared mutexes entirely; this remains +/// only for the asset foundation, which is its one legitimate user.) +pub(crate) trait LockRecover { + fn lock_recover(&self) -> MutexGuard<'_, T>; +} + +impl LockRecover for Mutex { + fn lock_recover(&self) -> MutexGuard<'_, T> { + self.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl WorldAssets { + /// Decode + upload a BLP once per path; later requests for the same texture share the handle. + /// `None` if the texture is missing or fails to decode. + pub(crate) fn texture( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + let key = normalize_path(path); + if let Some(handle) = self.textures.get(&key) { + return Some(handle.clone()); + } + let chain = read_texture_mip_chain(&mut self.chain.lock_recover(), &key).ok()?; + let handle = images.add(repeat_texture_authored(chain)); + self.textures.insert(key, handle.clone()); + Some(handle) + } + + /// Decode a BLP into a clamp-sampled **sRGB** sprite image (mip 0 only) — the celestial discs + /// (sun/moon) and every player-UI quad. Unlike [`Self::texture`] (repeat-sampled, gamma-space + /// `Unorm`, mipmapped — for tiling world art), this is sRGB + clamp-to-edge with no mip chain. + /// Extensionless references get `.blp` appended (the FrameXML convention). Cached by resolved + /// path — hits and misses both — in its own `sprites` map, separate from the `textures` cache + /// (the same BLP can live in both, with different sampling). + pub(crate) fn sprite_texture( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + // FrameXML/DBC texture references are canonically EXTENSIONLESS + // (`Interface\Buttons\UI-Quickslot2`) — the real client appends `.blp` at resolve. Paths + // that already carry an extension (`textures\moon.blp`) pass through unchanged. + let mut key = normalize_path(path); + if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) { + key.push_str(".blp"); + } + // Cached (hits AND misses): the UI extractor asks per quad per frame — uncached, every + // frame re-decoded every BLP into a fresh `Image` (unbounded asset growth) and the fresh + // handles defeated the quad-list dirty check. + if let Some(cached) = self.sprites.get(&key) { + return cached.clone(); + } + let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key) + .ok() + .map(|(w, h, rgba)| images.add(sprite_image(w, h, rgba))); + self.sprites.insert(key, loaded.clone()); + loaded + } + + /// Decode a texture to raw RGBA8 `(w, h, bytes)` **without** uploading a GPU image — for callers + /// that resample the pixels themselves before upload (the V-plate border sharpen, decision 0188, + /// which resizes the 128×32 frame to the plate's exact size). Same extensionless→`.blp` resolve + /// as [`Self::sprite_texture`]; not cached (a one-shot decode at load, not a per-frame ask). + pub(crate) fn decode_rgba(&mut self, path: &str) -> Option<(u32, u32, Vec)> { + let mut key = normalize_path(path); + if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) { + key.push_str(".blp"); + } + read_texture_rgba(&mut self.chain.lock_recover(), &key).ok() + } + + /// A UI sprite decoded with **repeat** (wrap) addressing — the frame `Backdrop` tiled pieces + /// (`backdrop-mechanism.md`): a border edge strip samples UVs `[0..N]` and a tiled bg `[0..w/period]`, + /// so the texture must wrap, not clamp. Same sRGB/no-mip decode + extensionless→`.blp` resolve + /// as [`Self::sprite_texture`], but its own cache (the sampler is baked into the `Image`, so a + /// path wanted both clamp and repeat needs two GPU images). Cached hits **and** misses. + pub(crate) fn sprite_texture_tiled( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + let mut key = normalize_path(path); + if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) { + key.push_str(".blp"); + } + if let Some(cached) = self.tiled_sprites.get(&key) { + return cached.clone(); + } + let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key) + .ok() + .map(|(w, h, rgba)| images.add(sprite_image_tiled(w, h, rgba))); + self.tiled_sprites.insert(key, loaded.clone()); + loaded + } + + /// A **portrait** sprite: [`Self::sprite_texture`]'s clamp/sRGB decode with a circular alpha mask + /// baked in ([`portrait_image`]) — the unit-frame portrait, round like the client's. Same + /// extensionless→`.blp` resolve; its own cache (the mask bakes into the GPU image, so a path + /// wanted both plain and portrait needs two images). Cached hits **and** misses. + pub(crate) fn portrait_texture( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + let mut key = normalize_path(path); + if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) { + key.push_str(".blp"); + } + if let Some(cached) = self.portraits.get(&key) { + return cached.clone(); + } + let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key) + .ok() + .map(|(w, h, rgba)| images.add(portrait_image(w, h, rgba))); + self.portraits.insert(key, loaded.clone()); + loaded + } + + /// A coverage **mask** ([`mask_image`] — linear/clamp, the texel bytes handed to the shader + /// 1:1): the minimap's `MinimapMask.blp` circle (decision 0203, [`crate::ui_pass::UiQuadMask`]). + /// Same extensionless→`.blp` resolve as [`Self::sprite_texture`]; its own cache, hits AND misses. + pub(crate) fn mask_texture( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + let mut key = normalize_path(path); + if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) { + key.push_str(".blp"); + } + if let Some(cached) = self.masks.get(&key) { + return cached.clone(); + } + let loaded = read_texture_rgba(&mut self.chain.lock_recover(), &key) + .ok() + .map(|(w, h, rgba)| images.add(mask_image(w, h, rgba))); + self.masks.insert(key, loaded.clone()); + loaded + } + + /// Decode a cursor BLP (e.g. `Interface\Cursor\Point.blp`) into a single-mip GPU image for + /// winit's custom OS cursor (`CursorIcon::Custom`) — *not* the mipmapped, repeat-sampled + /// [`repeat_texture_authored`] used for world art. `None` if the file is missing or fails to decode. + /// macOS doesn't use this (it drives `NSCursor` from the raw RGBA directly — see `crate::cursor`). + #[cfg(not(target_os = "macos"))] + pub(crate) fn decode_cursor( + &mut self, + path: &str, + images: &mut Assets, + ) -> Option> { + let (w, h, rgba) = + read_texture_rgba(&mut self.chain.lock_recover(), &normalize_path(path)).ok()?; + Some(images.add(cursor_texture(w, h, rgba))) + } + + /// A `StandardMaterial` for a model submesh, deduped by (texture, blend) so submeshes/models + /// sharing a texture share one material handle (enabling draw-call batching). A missing/failed + /// texture falls back to a single shared untextured material. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn model_material( + &mut self, + texture: Option<&str>, + blend: ModelBlend, + two_sided: bool, + alpha_cutoff: Option, + fade_far: Option, + is_wmo: bool, + is_fade_variant: bool, + images: &mut Assets, + materials: &mut Assets, + ) -> Handle { + // Alpha-test cutoff: callers may override (ground clutter uses the detail-doodad ref ≈ 0.5); + // everything else uses the general vanilla model alpha key. + let cutoff = alpha_cutoff.unwrap_or(VANILLA_ALPHA_KEY_REF); + // Ground-clutter distance fade (None for normal models). Near = 0.75× far (the client's ratio). + let clutter_fade = match fade_far { + Some(far) => Vec4::new(far * 0.75, far, 0.0, 1.0), + None => Vec4::ZERO, + }; + let resolved = + texture.and_then(|t| self.texture(t, images).map(|h| (normalize_path(t), h))); + // Only AlphaTest consumes the cutoff, so don't fragment opaque/blend materials by it. + let key_alpha = if blend == ModelBlend::AlphaTest { + (cutoff * 255.0).round() as u8 + } else { + 0 + }; + let key_fade = fade_far.map(|f| f.round() as u16).unwrap_or(0); + let key = match &resolved { + Some((path, _)) => MaterialKey::Textured( + path.clone(), + blend, + two_sided, + key_alpha, + key_fade, + is_wmo, + is_fade_variant, + ), + None => MaterialKey::Fallback(is_wmo), + }; + if let Some(handle) = self.model_materials.get(&key) { + return handle.clone(); + } + // Per-submesh blend (opaque trunk/wall vs alpha-cut leaves/windows). This builder serves + // ground clutter, which never authors the multiply modes — Mod/Mod2x fall to plain Blend + // here rather than growing this path the marker bits `model_render::model_material` packs + // (that builder is the one every Mod-capable consumer uses; decision 0528). + let alpha_mode = match blend { + ModelBlend::Opaque => AlphaMode::Opaque, + ModelBlend::AlphaTest => AlphaMode::Mask(cutoff), + ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x => AlphaMode::Blend, + }; + // Single-sided unless the M2's 0x04 flag is set — matches the real client (many canopy planes + // are visible from one direction only). + let cull_mode = if two_sided { None } else { Some(Face::Back) }; + // The base StandardMaterial carries texture/alpha/cull; our `wow_model.wgsl` extension does + // the WoW lighting (PBR fields like reflectance/roughness are ignored by it). Light uniforms + // are placeholders — `apply_wow_lighting` fills them from Light.dbc + the panel knobs. + let base = match resolved { + Some((_, image)) => StandardMaterial { + base_color_texture: Some(image), + alpha_mode, + double_sided: two_sided, + cull_mode, + ..default() + }, + // No resolved texture → untextured WHITE draw (stage disabled in the reference — + // wow-re m2-runtime-texture-null-bind.md; see model_render.rs for the full note). + None => StandardMaterial { + base_color: Color::WHITE, + ..default() + }, + }; + let handle = materials.add(WowModelMaterial { + base, + extension: WowModelExt { + clutter_fade, + // x = WMO (FFP N·L × MOCV, not SH); y = distance-fade blend variant (depth-write-on + + // stable cutout via `specialize`/the shader — see WowModelKey). + model_flags: Vec4::new( + if is_wmo { 1.0 } else { 0.0 }, + if is_fade_variant { 1.0 } else { 0.0 }, + 0.0, + 0.0, + ), + // x = doodad-lobe sun scale; `1.0` (lit) here — this builder serves clutter, which lights on + // the FFP N·L path with its own baked MCSH tint, so the lobe's `sun_scale` is unused. + sun_scale: Vec4::new(1.0, 0.0, 0.0, 0.0), + tint: Vec4::ONE, // clutter never carries an animated M2Color tint (w: not a WMO batch) + sidn: Vec4::ZERO, // clutter is never SIDN/WINDOW glass (WMO-only) + // The shared global light (light/fog/SH come from here, updated in place each frame). + light_buf: self.shared_light.clone(), + }, + }); + self.model_materials.insert(key, handle.clone()); + handle + } +} + +/// World/render configuration read once at startup from the environment, shared by the subsystems +/// that need it: terrain reads `tile_radius` (streaming) + `tex_tiles` (splat tiling); lighting reads +/// `tile_radius` for the fog range. Inserted by [`AssetPlugin`] alongside [`WorldAssets`]. +#[derive(Resource, Clone, Copy)] +pub(crate) struct RenderConfig { + /// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default 1). + pub(crate) tile_radius: u32, + /// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8). + pub(crate) tex_tiles: f32, +} + +/// Startup ordering seam: [`AssetPlugin`] opens the patch chain in this set so every other +/// subsystem's startup (catalog loads, terrain/lighting setup) can run `.after(AssetSet::Open)` and +/// borrow the shared [`WorldAssets`]. +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum AssetSet { + Open, +} + +/// The asset foundation plugin: opens the **one** patch chain at startup and inserts the shared +/// [`WorldAssets`] + [`RenderConfig`] that the other subsystems build on. +pub(crate) struct AssetPlugin; + +impl Plugin for AssetPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, open_world_assets.in_set(AssetSet::Open)); + } +} + +/// Open the vanilla patch chain from `$WOW_DATA` (default `WoW/Data`) and insert the shared +/// [`WorldAssets`] (chain + dedup caches) + [`RenderConfig`]. If the client data can't be opened, +/// `WorldAssets` is simply absent and downstream startup falls back to an empty free-fly scene. +fn open_world_assets(mut commands: Commands, device: Res) { + let data = PathBuf::from(std::env::var("WOW_DATA").unwrap_or_else(|_| "WoW/Data".into())); + // Default 2 (a 5×5 block, ~1066 yd) so loaded terrain extends a buffer past the ~777 yd far-clip + // wall — tiles are resident before the wall reveals them, no streaming pop-in. Geometry beyond the + // wall is clipped/culled anyway. Lower $WOW_TILE_RADIUS to 1 to shrink the working set (less view + // distance / better perf); raise it for more buffer. + let tile_radius = std::env::var("WOW_TILE_RADIUS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2); + // Ground-texture repeats per chunk; tunable live in the panel afterward. + let tex_tiles = std::env::var("WOW_TEX_TILES") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(8.0); + commands.insert_resource(RenderConfig { + tile_radius, + tex_tiles, + }); + + // The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists + // before any material is built. Cloned into `WorldAssets` (for model materials) and inserted as the + // `SharedLightBuffer` resource (the terrain streamer + the render-world upload read it). Always + // inserted — even with no client data — so the render upload has a target; harmless if unused. + let shared_light = crate::lighting::new_shared_light_buffer(&device); + match open_chain(&data) { + Ok(chain) => commands.insert_resource(WorldAssets { + chain: Arc::new(Mutex::new(chain)), + textures: HashMap::new(), + sprites: HashMap::new(), + tiled_sprites: HashMap::new(), + portraits: HashMap::new(), + masks: HashMap::new(), + model_materials: HashMap::new(), + shared_light: shared_light.0.clone(), + }), + Err(e) => error!("failed to open client data: {e:#}"), + } + commands.insert_resource(shared_light); +} diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs new file mode 100644 index 00000000..46fb7861 --- /dev/null +++ b/crates/benilla/src/billboard.rs @@ -0,0 +1,910 @@ +//! M2 billboard cards — submeshes that ride a billboard bone (glow cards, chains, the questgiver +//! markers). The real 1.12 client re-orients the bone to the camera every frame; benilla otherwise +//! renders M2 geometry in its static bind pose, single-sided. +//! +//! **The re-orientation law is byte-pinned** (wow-re `animation/scratch/billboard-bone-law.md`, +//! §5): the M2 bone palette is computed in **VIEW space**, and a billboard bone's matrix rows are +//! REPLACED with the camera basis — spherical (`0x08`) takes the whole fixed basis (bone X toward +//! the viewer, Y screen-right, Z screen-up: the identity rows `{(0,0,−1),(1,0,0),(0,1,0)}` at +//! `0x714463`); the lock arms (`0x10`/`0x20`/`0x40` = keep X/Y/Z) keep their authored axis and +//! rebuild the other two from the camera (`0x40` lock-Z — the `?` marker's `0x240` — keeps model +//! up, rebuild the in-plane pair). Crucially this is the **view-matrix basis, one shared +//! orientation for every billboard** — NOT a per-pivot aim, and NOT the geometry's facet normal +//! (the old card aimed its first-triangle normal at the camera: arbitrary for 3-D geometry like +//! the 353-vert `?`, which is exactly why its proportions read wrong). The lock-Z in-plane sign +//! is `Y = Fwd × Z` — the 0168 handedness residual, settled by the director's A/B (the recorded +//! `Z × Fwd` order turned the model 180°: a mirrored `?`); it makes lock-Z agree with the +//! spherical arm's toward-the-viewer X at a level camera. +//! +//! The submesh mesh is built **centred at its bone pivot** (`benilla_assets::build_submesh_mesh`) +//! in the model-local Bevy frame — where the WoW bone axes land as X→−Z, Y→−X, Z→+Y (coords.rs) — +//! so we place the entity at the pivot's world position and write the rebuilt basis as its +//! rotation each frame; the geometry itself is never touched. + +use benilla_assets::BillboardInfo; +use benilla_formats::{BillboardKind, BoneScaleAnim}; +use bevy::prelude::*; + +use crate::player::WorldCamera; + +/// A spawned billboard card: where its pivot sits in the world, the uniform placement scale, how +/// it tracks the camera (the bone-flag arm), and its optional global-sequence scale pulse. The +/// per-frame system rewrites the entity transform from these — including `Visibility` (the +/// hidden-owner mirror), so a card requires it rather than trusting every spawn site's `Mesh3d` +/// to bring it along. +#[derive(Component)] +#[require(Transform, Visibility)] +pub struct BillboardCard { + world_pivot: Vec3, + scale: f32, + kind: BillboardKind, + /// The billboard bone's looping scale animation (the lamppost glow "breathe"), sampled each frame + /// and multiplied into [`Self::scale`]. `None` for a static card (no global-sequence scale track). + scale_anim: Option, + /// Per-instance phase offset (ms) into the global sequence, hashed from the world position — so a + /// row of identical lampposts breathes out of lockstep (each prop instance arms its own clock) rather + /// than blinking in unison. No-op when there's no `scale_anim`. + phase_ms: u32, + /// The bone's armed first-sequence **translation** loop (the questgiver `?` marker's bob, keys in + /// Bevy axes) — sampled each frame on the same clock/phase and added at the pivot, rotated by + /// [`Self::placement_rot`]. `None` (every doodad card today) = the static pivot; only the marker + /// spawn site arms it via [`Self::with_seq_translation`] — the doodad half of that ride belongs + /// to the 0130 phase-4 bone-follow work. + seq_translation: Option, + /// The placement's rotation, so the bob offset (model-local) points where the instance points. + placement_rot: Quat, + /// The entity this card FOLLOWS (a unit/GameObject anchor or held-item root): the facing system + /// re-seats the card from its live `GlobalTransform` every frame and despawns the card when it + /// goes — the ONE mechanism for every non-doodad spawn path (braziers, held torches, missiles), + /// so a glow card can never again render at the model origin because a spawn site forgot the + /// pivot (the recurring "glow on the ground" family — decision 0153). `None` = fixed placement + /// (terrain doodads, whose transform never moves). + follow: Option, + /// The pivot in the model's local Bevy frame (re-applied each frame when `follow` is set). + local_pivot: Vec3, +} + +impl BillboardCard { + /// Build a card from a submesh's [`BillboardInfo`] and its instance `placement`. The pivot is placed + /// in the world; the card's orientation ignores the placement rotation (a billboard faces the camera + /// regardless of how the prop is turned). + pub fn new(info: &BillboardInfo, placement: Transform) -> Self { + let world_pivot = placement.transform_point(info.pivot); + // A stable per-instance phase from the world position (same hashing as the particle RNG seed). + let phase_ms = world_pivot.x.to_bits().wrapping_mul(0x9E37_79B9) + ^ world_pivot.y.to_bits().rotate_left(11) + ^ world_pivot.z.to_bits().rotate_left(22); + Self { + world_pivot, + scale: placement.scale.x, + kind: info.kind, + scale_anim: info.scale_anim.clone(), + phase_ms, + seq_translation: None, + placement_rot: placement.rotation, + follow: None, + local_pivot: info.pivot, + } + } + + /// Build a card that FOLLOWS `owner` — the entity-path form (creatures, GameObjects, held + /// items, missiles, spell effects): world pivot/scale/rotation are re-derived from the owner's + /// live `GlobalTransform` every frame, and the card despawns when the owner goes. + pub fn following(info: &BillboardInfo, owner: Entity) -> Self { + let mut card = Self::new(info, Transform::IDENTITY); + card.follow = Some(owner); + card + } + + /// Build a card riding a live JOINT — an animated host's billboard bone (the swinging lamp, + /// the mount's lights). The joint's frame already bakes the bone pivot (the 0130 rig identity + /// `joint = root · M_bone · T(pivot)`), so the card's local pivot is the joint origin. + pub fn following_joint(info: &BillboardInfo, joint: Entity) -> Self { + let mut card = Self::following(info, joint); + card.local_pivot = Vec3::ZERO; + card + } + + /// Arm the card's first-sequence translation loop (the questgiver `?` bob) with the client's + /// arm-time cursor: sampling runs on `elapsed − arm_ms` (the loop starts at its first key the + /// moment the marker attaches, like the real arm at status receive). Overrides the position-hash + /// phase — markers of one NPC swap in place and must not inherit a doodad-flavored phase. + pub(crate) fn with_seq_translation(mut self, anim: Option, arm_ms: u32) -> Self { + self.arm_seq_translation(anim, arm_ms); + self + } + + /// Re-arm the translation loop on a LIVE card — the marker swapping between its low (anim 0) + /// and raised (anim 190) bob when the unit's overhead name toggles: fresh cursor, same law as + /// [`Self::with_seq_translation`]. + pub(crate) fn arm_seq_translation(&mut self, anim: Option, arm_ms: u32) { + if anim.is_some() { + self.phase_ms = arm_ms.wrapping_neg(); + } + self.seq_translation = anim; + } + + /// Re-seat a card that FOLLOWS something (the questgiver `!`/`?` markers over a unit that can + /// move) — recompute the world pivot/scale/rotation from a fresh `placement`, keeping the card's + /// orientation kind, rest normal, and animation phase. Doodad cards never need this (their + /// placement is fixed at spawn). + pub(crate) fn re_place(&mut self, placement: Transform, local_pivot: Vec3) { + self.world_pivot = placement.transform_point(local_pivot); + self.scale = placement.scale.x; + self.placement_rot = placement.rotation; + } +} + +/// The rebuilt orientation for a billboard of `kind` — the byte law (module doc), one function +/// for both consumers: the CARD path (`kept_rot` = the placement/owner rotation) and the JOINT +/// palette pass below (`kept_rot` = the joint's fully-composed pre-billboard world rotation, the +/// law's `normalize(rK)`). `bx/by/bz` are the bone's WoW-frame X/Y/Z axes as world directions +/// after the replacement; the returned quat maps the mesh's model-local Bevy frame onto them +/// (WoW axes sit in that frame as X→−Z, Y→−X, Z→+Y — coords.rs — so local X→−by, Y→bz, Z→−bx). +pub(crate) fn billboard_basis( + kind: BillboardKind, + kept_rot: Quat, + fwd: Vec3, + right: Vec3, + up: Vec3, +) -> Quat { + let (bx, by, bz) = match kind { + // Spherical (`0x08`): the whole fixed basis — X toward the viewer, Y screen-right, + // Z screen-up (the view-space identity rows). + BillboardKind::Spherical => (-fwd, right, up), + // Lock-Z (`0x40` — the `?` marker, the frost-armor sheets): keep the authored bone Z + // (model up, pointed by `kept_rot`), rebuild the in-plane pair from the camera. The + // in-plane sign is `Y = Fwd × Z` — the 0168 residual, settled by the director's A/B + // (the other order showed the model's back: a mirrored `?`); this order also agrees + // with the spherical arm at a level camera (X toward the viewer, Y screen-right), the + // coherence the flipped version lacked. A camera looking straight along the kept axis + // degenerates the cross — hold screen-right then. + BillboardKind::LockZ => { + let bz = (kept_rot * Vec3::Y).normalize_or(Vec3::Y); + let by = fwd.cross(bz).try_normalize().unwrap_or(right); + let bx = by.cross(bz); + (bx, by, bz) + } + // Lock-X/-Y: the same verified structure generalized per kept axis — the cyclically + // PREVIOUS axis takes `Fwd × kept` (that assignment is what reproduces the settled + // lock-Z arm), the third completes the right-handed WoW triple. No shipped content has + // A/B'd these two arms yet; if a chain/rope ever reads mirrored, the sign here is the + // one knob (0168's pattern). + BillboardKind::LockX => { + let bx = (kept_rot * -Vec3::Z).normalize_or(-fwd); + let bz = fwd.cross(bx).try_normalize().unwrap_or(up); + let by = bz.cross(bx); + (bx, by, bz) + } + BillboardKind::LockY => { + let by = (kept_rot * -Vec3::X).normalize_or(right); + let bx = fwd.cross(by).try_normalize().unwrap_or(-fwd); + let bz = bx.cross(by); + (bx, by, bz) + } + }; + Quat::from_mat3(&Mat3::from_cols(-by, bz, -bx)) +} + +/// A rigged host whose skeleton authors billboard bones (component beside the rig's +/// `AnimationPlayer`): the joint entities in bone order, each bone's parent, and which joints +/// billboard. [`billboard_joint_palette`] rewrites those joints' propagated world rotations to +/// the camera basis every frame — the byte law operates on the BONE PALETTE +/// (`finalBoneWorld … children multiply onto this`, wow-re `billboard-bone-law.md`), so geometry +/// skinned to a billboard bone's CHILDREN inherits the facing. The per-batch card split can +/// never catch that case: the frost-armor sheets skin every vertex to the scale-in CHILD of the +/// lock-Z bone, which is exactly why they rendered glued to the character. +#[derive(Component)] +pub struct BillboardJointRig { + /// The host root entity — the frame an `ignore_parent_rotation` joint's rotation snaps back + /// to (bone flag `0x04` keeps the MODEL's orientation, not the parent bone's). + root: Entity, + joints: Vec, + parents: Vec, + kinds: Vec>, + /// Bone flag `0x04` per joint (the HandArrow/Bullet attach helpers): pivot rides the parent's + /// full matrix, rotation resets to the model root's — the nocked arrow lies flat along the + /// facing instead of twisting with the draw hand (wow-re `nocked-ammo-cancel.md` §E4). + ignore_rot: Vec, +} + +impl BillboardJointRig { + /// Build for a spawned rig — `None` when the skeleton authors no billboard bone and no + /// ignore-parent-rotation bone (the common case: ordinary rigs cost nothing). `root` is the + /// host entity the joints hang under (the model-space frame). + pub fn new( + skeleton: &benilla_assets::ModelSkeleton, + joints: &[Entity], + root: Entity, + ) -> Option { + if skeleton + .joints + .iter() + .all(|j| j.billboard.is_none() && !j.ignore_parent_rotation) + { + return None; + } + Some(Self { + root, + joints: joints.to_vec(), + parents: skeleton.joints.iter().map(|j| j.parent).collect(), + kinds: skeleton.joints.iter().map(|j| j.billboard).collect(), + ignore_rot: skeleton + .joints + .iter() + .map(|j| j.ignore_parent_rotation) + .collect(), + }) + } +} + +/// The palette half of the billboard law: for each rigged host, replace every billboard joint's +/// world rotation with the camera basis (scale and pivot translation preserved — the law's +/// `lenK`/`finalTranslation`, which in our rig identity is simply "keep the joint's global +/// scale/translation"), then re-compose every descendant joint from its local TRS so skinned +/// geometry — and emitters/ribbons riding those joints — inherit the facing. Runs after +/// propagation and writes `GlobalTransform` directly (the same exactness argument as +/// [`face_billboards`], which must run after this so following-joint cards read the replaced +/// frames). **Every palette consumer must read AFTER this system, same frame**: avian's physics +/// sync re-propagates the hierarchy from locals inside the fixed loop, so an Update-time read +/// gets the UN-billboarded pose — the Demon Skin flames followed the character's yaw instead of +/// the camera until the particle/ribbon sims moved behind this pass. Bone order is parent-sorted +/// in every real M2 (the format guarantees parent < child); a malformed child whose parent +/// follows it just keeps its propagated pose. +/// +/// A rigged model can hang under ANOTHER rig's joint — a spell-effect instance on a unit's +/// attach-helper bone, a rigged held item in a hand. One ownership law keeps the passes from +/// fighting over those frames: the child-recompose walk **never enters a nested rig's subtree** +/// — not even its root, whose propagated global (the live ANIMATED attach-bone frame) is what +/// its emitters' attach frame must read. Without it, the boar's flag-0x04 attach helper +/// re-composed the Eviscerate impact model's frames from raw locals, erasing its camera-born +/// billboard basis or its animated attach rotation depending on per-launch query order — the +/// burst rendered as a body-locked pillar on some launches and correctly on others. With no rig +/// ever writing into another rig's subtree, the passes are order-independent again. +pub(crate) fn billboard_joint_palette( + cam: Query<&GlobalTransform, With>, + hosts: Query<&BillboardJointRig>, + // A parked unit's pose is frozen off-frustum (decision 0448) — camera-facing its glow joints + // would re-dirty the subtree for a rig no one sees. A parked host still sits in the + // do-not-enter set (its propagated frames are real); it just isn't re-faced. + parked: Query>, + mut joints: Query<(&Transform, &mut GlobalTransform), Without>, + children: Query<&Children>, +) { + let Ok(cam_tf) = cam.single() else { + return; + }; + let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up()); + // Every rig root — the walk's do-not-enter set. + let rig_roots: bevy::platform::collections::HashSet = + hosts.iter().map(|r| r.root).collect(); + for rig in hosts + .iter() + .filter(|r| !parked.get(r.root).unwrap_or(false)) + { + // The model-space frame an ignore-parent-rotation joint (bone flag 0x04) snaps back to: + // the host root's world rotation. Read before the joint loop (the root is never a joint). + let root_rot = joints + .get(rig.root) + .map(|(_, g)| g.rotation()) + .unwrap_or_default(); + let n = rig.joints.len(); + let mut replaced: Vec> = vec![None; n]; + for i in 0..n { + let parent_new = usize::try_from(rig.parents[i]) + .ok() + .filter(|&p| p < i) + .and_then(|p| replaced[p]); + if parent_new.is_none() && rig.kinds[i].is_none() && !rig.ignore_rot[i] { + continue; // untouched subtree — the propagated pose stands + } + let Ok((local, mut global)) = joints.get_mut(rig.joints[i]) else { + continue; + }; + let mut g = match parent_new { + Some(pg) => pg.mul_transform(*local), + None => *global, + }; + if rig.ignore_rot[i] { + // Bone flag 0x04: keep the parent-composed pivot (the hand carries the point), + // reset the rotation to the model root's frame — children (the nocked arrow) + // inherit the flat model-space orientation, not the hand twist. + let (scale, _, translation) = g.to_scale_rotation_translation(); + g = GlobalTransform::from(Transform { + translation, + rotation: root_rot, + scale, + }); + } else if let Some(kind) = rig.kinds[i] { + let (scale, rot, translation) = g.to_scale_rotation_translation(); + g = GlobalTransform::from(Transform { + translation, + rotation: billboard_basis(kind, rot, fwd, right, up), + scale, + }); + } + replaced[i] = Some(g); + *global = g; + } + // Rigid children hanging under a rewritten joint (a held item, the nocked arrow) got + // their globals from ordinary propagation — BEFORE this rewrite. Re-compose those + // subtrees from the replaced frames; sibling JOINTS are excluded (the replaced-chain + // above owns them). Skinned geometry never needs this — it reads the joint frames. + let joint_set: bevy::platform::collections::HashSet = + rig.joints.iter().copied().collect(); + let mut stack: Vec<(Entity, GlobalTransform)> = Vec::new(); + for (&joint, g) in rig + .joints + .iter() + .zip(&replaced) + .filter_map(|(j, r)| r.map(|g| (j, g))) + { + if let Ok(cs) = children.get(joint) { + stack.extend( + cs.iter() + .filter(|c| !joint_set.contains(c) && !rig_roots.contains(c)) + .map(|c| (c, g)), + ); + } + } + while let Some((e, parent_g)) = stack.pop() { + let Ok((local, mut global)) = joints.get_mut(e) else { + continue; + }; + let g = parent_g.mul_transform(*local); + *global = g; + if let Ok(cs) = children.get(e) { + stack.extend(cs.iter().filter(|c| !rig_roots.contains(c)).map(|c| (c, g))); + } + } + } +} + +/// The billboard placement pass (PostUpdate, after `TransformSystems::Propagate`, before +/// visibility) — [`billboard_joint_palette`] then [`face_billboards`] run here, and upstream +/// card re-seaters (the quest markers) order `.before` it. Placement reads the SAME-frame +/// propagated pose: running in Update read last-frame joint/owner globals, so a card over a +/// moving unit trailed a frame behind and snapped forward on stop (the nameplate lag's sibling). +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct BillboardPlace; + +/// Per-frame: face each billboard card to the camera (around its pivot) and apply its scale pulse. +/// A FOLLOWING card (entity-path) is first re-seated from its owner's live global transform — and +/// despawned when the owner is gone (streamed out, unequipped, died). Runs in [`BillboardPlace`] +/// (post-propagation), so it writes `GlobalTransform` directly alongside `Transform` — cards +/// write ABSOLUTE world transforms and live at the root/identity, so the direct write is exact. +#[allow(clippy::type_complexity)] // the owner pose + visibility read, commented inline +fn face_billboards( + mut commands: Commands, + time: Res