From df4faaaa1585d6b603087d1f80f27fff5e997b6d Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 01:13:41 -0400 Subject: [PATCH 1/5] L142: v83 move-path element layouts Read and write path elements by type as v83 does (FUN_0068a33c decode, FUN_0068a563 encode): velocity-only types keep the position before them, type 10 is one byte, every other type ends with stance and duration. HC sends velocities in px/s and a real start point, and records element 4 when Teleport is used. The server relays the client's start point instead of 0 and parses from it, for players and mobs. Co-Authored-By: Claude Opus 5.5 --- HeavenClient/CMakeLists.txt | 3 + HeavenClient/Character/OtherChar.cpp | 3 + HeavenClient/Character/Player.cpp | 15 ++- HeavenClient/Character/Player.h | 6 + HeavenClient/Gameplay/Movement.h | 31 +++-- HeavenClient/Gameplay/Stage.cpp | 4 + .../Net/Handlers/Helpers/MovementParser.cpp | 87 ++++++++------ .../Net/Handlers/Helpers/MovementParser.h | 5 +- .../Net/Handlers/MapObjectHandlers.cpp | 6 +- HeavenClient/Net/Packets/GameplayPackets.h | 8 +- HeavenClient/Net/Packets/MovementPacket.h | 63 ++++++++++- HeavenClient/tests/movement_path.cpp | 84 ++++++++++++++ net/src/handler.rs | 2 + net/src/packet/build/world/field.rs | 107 +++++++++++++----- net/src/packet/handle/world/move_player.rs | 11 +- runtime/src/actor/client.rs | 7 +- runtime/src/actor/field.rs | 5 +- runtime/src/actor/field_mobs.rs | 22 ++-- runtime/src/message.rs | 1 + 19 files changed, 366 insertions(+), 104 deletions(-) create mode 100644 HeavenClient/tests/movement_path.cpp diff --git a/HeavenClient/CMakeLists.txt b/HeavenClient/CMakeLists.txt index 1408712fa..5af8875ac 100644 --- a/HeavenClient/CMakeLists.txt +++ b/HeavenClient/CMakeLists.txt @@ -136,6 +136,9 @@ if(HEAVENCLIENT_BUILD_TESTS) add_executable(shop_list_tests tests/shop_list.cpp Net/InPacket.cpp) target_include_directories(shop_list_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME shop_list COMMAND shop_list_tests) + add_executable(movement_path_tests tests/movement_path.cpp Net/InPacket.cpp Net/Handlers/Helpers/MovementParser.cpp) + target_include_directories(movement_path_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) + add_test(NAME movement_path COMMAND movement_path_tests) add_executable(status_messages_tests tests/status_messages.cpp Net/InPacket.cpp) target_include_directories(status_messages_tests PRIVATE ${PROJECT_SOURCE_DIR}/libs/NoLifeNx) add_test(NAME status_messages COMMAND status_messages_tests) diff --git a/HeavenClient/Character/OtherChar.cpp b/HeavenClient/Character/OtherChar.cpp index b96d5bbdb..b28f6c4a0 100644 --- a/HeavenClient/Character/OtherChar.cpp +++ b/HeavenClient/Character/OtherChar.cpp @@ -76,6 +76,9 @@ namespace ms void OtherChar::send_movement(const std::vector &newmoves) { + if (newmoves.empty()) + return; + movements.push(newmoves.back()); if (timer == 0) diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index fd0c9a11a..eed576abd 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -259,19 +259,28 @@ namespace ms update_recovery(); - uint8_t stancebyte = facing_right ? state : state + 1; - Movement newmove(phobj, stancebyte); + Movement newmove(phobj, get_stancebyte()); bool needupdate = lastmove.hasmoved(newmove); if (needupdate) { - MovePlayerPacket(newmove).dispatch(); + MovePlayerPacket({ phobj.get_last_x(), phobj.get_last_y() }, newmove).dispatch(); lastmove = newmove; } return get_layer(); } + uint8_t Player::get_stancebyte() const + { + return facing_right ? state : state + 1; + } + + void Player::record_move(uint8_t command) + { + MovePlayerPacket(phobj.get_position(), Movement(phobj, get_stancebyte(), command, 0)).dispatch(); + } + void Player::update_death() { bool dead = stats.get_stat(MapleStat::Id::HP) <= 0; diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index be90ec8f9..971e6346a 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -64,6 +64,10 @@ namespace ms // Respawn the player at the given position void respawn(Point position, bool underwater); + // Tell the server, as path element `command` of no duration, where + // the player is now (v83 FUN_0052ef17). + void record_move(uint8_t command); + // Sends a Keyaction to the player's state, to apply forces, change the state and other behaviour. void send_action(KeyAction::Id action, bool pressed); @@ -306,6 +310,8 @@ namespace ms Movement lastmove; + uint8_t get_stancebyte() const; + Randomizer randomizer; Optional ladder; diff --git a/HeavenClient/Gameplay/Movement.h b/HeavenClient/Gameplay/Movement.h index af467b043..7b2a451f9 100644 --- a/HeavenClient/Gameplay/Movement.h +++ b/HeavenClient/Gameplay/Movement.h @@ -17,8 +17,11 @@ ////////////////////////////////////////////////////////////////////////////////// #pragma once +#include #include +#include "../Constants.h" + #include "Physics/PhysicsObject.h" namespace ms @@ -34,17 +37,18 @@ namespace ms JUMPDOWN }; - Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t lx, int16_t ly, uint16_t f, uint8_t s, int16_t d) - : type(t), command(c), xpos(x), ypos(y), lastx(lx), lasty(ly), fh(f), newstate(s), duration(d) + Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t vx, int16_t vy, uint16_t f, uint8_t s, int16_t d) + : type(t), command(c), xpos(x), ypos(y), vx(vx), vy(vy), fh(f), newstate(s), duration(d) {} - Movement(int16_t x, int16_t y, int16_t lx, int16_t ly, uint8_t s, int16_t d) : Movement(Type::ABSOLUTE, 0, x, y, - lx, ly, 0, s, d) + Movement(int16_t x, int16_t y, int16_t vx, int16_t vy, uint8_t s, int16_t d) : Movement(Type::ABSOLUTE, 0, x, y, + vx, vy, 0, s, d) {} - Movement(const PhysicsObject &phobj, uint8_t s) : Movement(Type::ABSOLUTE, 0, phobj.get_x(), phobj.get_y(), - phobj.get_last_x(), phobj.get_last_y(), phobj.fhid, - s, 1) + // Where the object is and how fast it goes, as element `c`. + Movement(const PhysicsObject &phobj, uint8_t s, uint8_t c = 0, int16_t d = 1) + : Movement(Type::ABSOLUTE, c, phobj.get_x(), phobj.get_y(), velocity(phobj.hspeed), + velocity(phobj.vspeed), phobj.fhid, s, d) {} Movement() : Movement(Type::NONE, 0, 0, 0, 0, 0) @@ -53,15 +57,22 @@ namespace ms bool hasmoved(const Movement &newmove) const { return newmove.newstate != newstate || newmove.xpos != xpos || newmove.ypos != ypos || - newmove.lastx != lastx || newmove.lasty != lasty; + newmove.vx != vx || newmove.vy != vy; + } + + // v83 paths carry speeds in pixels per second; HC's are per step. + static int16_t velocity(double speed) + { + return static_cast(std::lround(speed * 1000 / Constants::TIMESTEP)); } Type type; uint8_t command; int16_t xpos; int16_t ypos; - int16_t lastx; - int16_t lasty; + // Velocity, in pixels per second. + int16_t vx; + int16_t vy; uint16_t fh; uint8_t newstate; int16_t duration; diff --git a/HeavenClient/Gameplay/Stage.cpp b/HeavenClient/Gameplay/Stage.cpp index 2161aba65..31a2c6bbe 100644 --- a/HeavenClient/Gameplay/Stage.cpp +++ b/HeavenClient/Gameplay/Stage.cpp @@ -399,6 +399,10 @@ namespace ms { pending_teleport = Teleport{ spot->position, spot->foothold }; player.show_effect_id(CharEffect::Id::TELEPORT); + + // Element 4 marks the jump in the path, from where the player + // stands until the move lands on the next update. + player.record_move(4); } } diff --git a/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp b/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp index 005d70f33..a53b70f87 100644 --- a/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp +++ b/HeavenClient/Net/Handlers/Helpers/MovementParser.cpp @@ -19,10 +19,13 @@ namespace ms { - std::vector MovementParser::parse_movements(InPacket& recv) + std::vector MovementParser::parse_movements(InPacket& recv, Point start) { std::vector movements; uint8_t length = recv.read_byte(); + int16_t x = start.x(); + int16_t y = start.y(); + uint8_t stance = 0; for (uint8_t i = 0; i < length; ++i) { @@ -33,15 +36,20 @@ namespace ms { case 0: case 5: + case 15: case 17: - fragment.type = Movement::ABSOLUTE; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.lastx = recv.read_short(); - fragment.lasty = recv.read_short(); + // A move to a point, at a speed, onto a foothold; a jump down + // also says the foothold it fell from. + fragment.type = fragment.command == 15 ? Movement::JUMPDOWN : Movement::ABSOLUTE; + x = recv.read_short(); + y = recv.read_short(); + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); fragment.fh = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); + + if (fragment.command == 15) + recv.skip(2); + break; case 1: case 2: @@ -49,48 +57,57 @@ namespace ms case 12: case 13: case 16: + case 18: + case 19: + case 20: + case 22: + // A speed from where the last element left off. fragment.type = Movement::RELATIVE; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); - break; - case 11: - fragment.type = Movement::CHAIR; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - recv.skip(2); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); - break; - case 15: - fragment.type = Movement::JUMPDOWN; - fragment.xpos = recv.read_short(); - fragment.ypos = recv.read_short(); - fragment.lastx = recv.read_short(); - fragment.lasty = recv.read_short(); - recv.skip(2); - fragment.fh = recv.read_short(); - fragment.newstate = recv.read_byte(); - fragment.duration = recv.read_short(); + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); break; case 3: case 4: case 7: case 8: case 9: - case 14: - fragment.type = Movement::NONE; + case 11: + // A point and a foothold with no speed: a teleport (4), a seat + // (11) and the like. + fragment.type = fragment.command == 11 ? Movement::CHAIR : Movement::ABSOLUTE; + x = recv.read_short(); + y = recv.read_short(); + fragment.fh = recv.read_short(); break; case 10: + // An equip or stat change: one byte, with no stance or time. + fragment.type = Movement::NONE; + recv.skip(1); + break; + case 14: + fragment.type = Movement::RELATIVE; + fragment.vx = recv.read_short(); + fragment.vy = recv.read_short(); + recv.skip(2); + break; + default: fragment.type = Movement::NONE; - // Change equip break; } + fragment.xpos = x; + fragment.ypos = y; + + if (fragment.command != 10) + { + stance = recv.read_byte(); + fragment.duration = recv.read_short(); + } + + fragment.newstate = stance; movements.push_back(fragment); } return movements; } -} \ No newline at end of file +} diff --git a/HeavenClient/Net/Handlers/Helpers/MovementParser.h b/HeavenClient/Net/Handlers/Helpers/MovementParser.h index cc3f37a45..f1ba55329 100644 --- a/HeavenClient/Net/Handlers/Helpers/MovementParser.h +++ b/HeavenClient/Net/Handlers/Helpers/MovementParser.h @@ -26,6 +26,9 @@ namespace ms { namespace MovementParser { - std::vector parse_movements(InPacket& recv); + // A path's elements after its start position, as v83 reads them + // (FUN_0068a33c). Elements that carry no position keep the one + // before, starting from `start`. + std::vector parse_movements(InPacket& recv, Point start); } } \ No newline at end of file diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 49cb6d8b3..675c2aa97 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -155,8 +155,8 @@ namespace ms void CharMovedHandler::handle(InPacket& recv) const { int32_t cid = recv.read_int(); - recv.skip(4); - std::vector movements = MovementParser::parse_movements(recv); + Point start = recv.read_point(); + std::vector movements = MovementParser::parse_movements(recv, start); Stage::get().get_chars().send_movement(cid, movements); } @@ -341,7 +341,7 @@ namespace ms recv.read_byte(); // skill 4 Point position = recv.read_point(); - std::vector movements = MovementParser::parse_movements(recv); + std::vector movements = MovementParser::parse_movements(recv, position); Stage::get().get_mobs().send_movement(oid, position, std::move(movements)); } diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 80a562686..d22cf76c8 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -215,10 +215,12 @@ namespace ms class MovePlayerPacket : public MovementPacket { public: - // Updates the player's position with the server - MovePlayerPacket(const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) + // Updates the player's position with the server: a path of one + // element from `start` + MovePlayerPacket(Point start, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) { - skip(9); + skip(5); + write_point(start); write_byte(1); writemovement(movement); } diff --git a/HeavenClient/Net/Packets/MovementPacket.h b/HeavenClient/Net/Packets/MovementPacket.h index 3a18372a6..9bb28983c 100644 --- a/HeavenClient/Net/Packets/MovementPacket.h +++ b/HeavenClient/Net/Packets/MovementPacket.h @@ -30,14 +30,65 @@ namespace ms MovementPacket(OutPacket::Opcode opc) : OutPacket(opc) {} protected: + // One path element, laid out by its type as v83 writes it + // (FUN_0068a563). void writemovement(const Movement& movement) { - write_byte(movement.command); - write_short(movement.xpos); - write_short(movement.ypos); - write_short(movement.lastx); - write_short(movement.lasty); - write_short(movement.fh); + uint8_t command = movement.command; + write_byte(command); + + switch (command) + { + case 0: + case 5: + case 15: + case 17: + write_short(movement.xpos); + write_short(movement.ypos); + write_short(movement.vx); + write_short(movement.vy); + write_short(movement.fh); + + // The foothold it fell from, which HC doesn't keep. + if (command == 15) + write_short(0); + + break; + case 1: + case 2: + case 6: + case 12: + case 13: + case 16: + case 18: + case 19: + case 20: + case 22: + write_short(movement.vx); + write_short(movement.vy); + break; + case 3: + case 4: + case 7: + case 8: + case 9: + case 11: + write_short(movement.xpos); + write_short(movement.ypos); + write_short(movement.fh); + break; + case 10: + write_byte(movement.newstate); + return; + case 14: + write_short(movement.vx); + write_short(movement.vy); + write_short(0); + break; + default: + break; + } + write_byte(movement.newstate); write_short(movement.duration); } diff --git a/HeavenClient/tests/movement_path.cpp b/HeavenClient/tests/movement_path.cpp new file mode 100644 index 000000000..d05f5d1c2 --- /dev/null +++ b/HeavenClient/tests/movement_path.cpp @@ -0,0 +1,84 @@ +#include "../Net/Handlers/Helpers/MovementParser.h" +#include +#include +#include +#include + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +struct Writer +{ + std::vector bytes; + + template + Writer& put(T value) + { + int8_t raw[sizeof(T)]; + std::memcpy(raw, &value, sizeof(T)); + bytes.insert(bytes.end(), raw, raw + sizeof(T)); + return *this; + } + + Writer& end(uint8_t stance, int16_t duration) + { + return put(stance).put(duration); + } +}; + +int main() +{ + try + { + using namespace ms; + + Writer w; + w.put(7); + // Walk: position, velocity, foothold. + w.put(0).put(100).put(-20).put(125).put(0).put(7).end(2, 90); + // Jump: velocity only, at the walk's position. + w.put(1).put(125).put(-555).end(6, 300); + // Type 10: one byte and nothing else. + w.put(10).put(3); + // Down jump: foothold 9, fallen from 8. + w.put(15).put(110).put(40).put(0).put(0).put(9).put(8).end(7, 120); + // Teleport: position and foothold. + w.put(4).put(300).put(40).put(11).end(2, 0); + // Velocity and the foothold fallen from. + w.put(14).put(-60).put(0).put(11).end(3, 40); + // A type it doesn't know: stance and duration only. + w.put(21).end(5, 16); + w.put(42); + + InPacket recv(w.bytes.data(), w.bytes.size()); + auto moves = MovementParser::parse_movements(recv, { -5, -6 }); + + require(moves.size() == 7, "every element is read"); + require(recv.available() && recv.read_byte() == 42 && !recv.available(), "the path ends where it should"); + require(moves[0].type == Movement::ABSOLUTE && moves[0].xpos == 100 && moves[0].ypos == -20 && moves[0].vx == 125 && moves[0].fh == 7, "a walk has a position, velocity and foothold"); + require(moves[0].newstate == 2 && moves[0].duration == 90, "elements end with a stance and duration"); + require(moves[1].type == Movement::RELATIVE && moves[1].xpos == 100 && moves[1].ypos == -20 && moves[1].vy == -555, "a jump keeps the position before it"); + require(moves[2].type == Movement::NONE && moves[2].newstate == 6 && moves[2].xpos == 100, "type 10 keeps the stance before it"); + require(moves[3].type == Movement::JUMPDOWN && moves[3].fh == 9 && moves[3].newstate == 7, "a down jump lands on its first foothold"); + require(moves[4].type == Movement::ABSOLUTE && moves[4].xpos == 300 && moves[4].fh == 11, "a teleport has a position and foothold"); + require(moves[5].type == Movement::RELATIVE && moves[5].xpos == 300 && moves[5].vx == -60, "type 14 is a velocity"); + require(moves[6].type == Movement::NONE && moves[6].newstate == 5 && moves[6].duration == 16, "an unknown type still has a stance"); + + Writer empty; + empty.put(0); + InPacket none(empty.bytes.data(), empty.bytes.size()); + require(MovementParser::parse_movements(none, {}).empty(), "a path may have no elements"); + + require(Movement::velocity(1.0) == 125 && Movement::velocity(-4.44) == -555, "speeds are per second"); + + std::cout << "Movement path passed\n"; + return 0; + } + catch (const std::exception& error) + { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/net/src/handler.rs b/net/src/handler.rs index db92d509c..81ee9152e 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -273,6 +273,8 @@ pub enum WorldAction { FieldChat(Packet), FieldMove { packet: Packet, + /// Where the path starts. + start: (i16, i16), movement_bytes: Vec, }, NpcInteraction(NpcInteraction), diff --git a/net/src/packet/build/world/field.rs b/net/src/packet/build/world/field.rs index d36fb499d..dd60eca87 100644 --- a/net/src/packet/build/world/field.rs +++ b/net/src/packet/build/world/field.rs @@ -94,11 +94,17 @@ pub fn build_player_leave_field(character_id: i32) -> Result Result { +/// MOVE_PLAYER: relay a player's movement path, which starts at `start`. +pub fn build_player_move( + character_id: i32, + start: (i16, i16), + movement_bytes: &[u8], +) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::MovePlayer as i16)?; packet.write_int(character_id)?; - packet.write_int(0)?; + packet.write_short(start.0)?; + packet.write_short(start.1)?; packet.write_bytes(movement_bytes)?; Ok(packet) } @@ -154,8 +160,12 @@ pub fn parse_movement_state( } /// Like `parse_movement_state`, plus the foothold the mover ended up on: -/// that of the last absolute or jump-down fragment (0 means airborne), if -/// any fragment had one. +/// that of the last element with a position (0 means airborne), if any. +/// +/// The elements follow a path's start position (`current`'s x and y), as +/// v83 reads them (FUN_0068a33c): each is a type byte and its fields, then a +/// stance byte and a duration short, except type 10, which has neither. +/// Elements that carry only a velocity keep the position before them. pub fn parse_movement( movement_bytes: &[u8], current: (i16, i16, u8), @@ -172,41 +182,43 @@ pub fn parse_movement( for _ in 0..count { let command = cursor.read_byte().ok()?; match command { - 0 | 5 | 17 => { + // Position, velocity, foothold (and for 15, the foothold it + // fell from). + 0 | 5 | 15 | 17 => { x = cursor.read_short().ok()?; y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; + cursor.read_short().ok()?; + cursor.read_short().ok()?; foothold = Some(cursor.read_short().ok()?); - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; + if command == 15 { + cursor.read_short().ok()?; + } } - 1 | 2 | 6 | 12 | 13 | 16 => { - x = cursor.read_short().ok()?; - y = cursor.read_short().ok()?; - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; + // Velocity. + 1 | 2 | 6 | 12 | 13 | 16 | 18 | 19 | 20 | 22 => { + cursor.read_short().ok()?; + cursor.read_short().ok()?; } - 11 => { + // Position and foothold. + 3 | 4 | 7 | 8 | 9 | 11 => { x = cursor.read_short().ok()?; y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; - } - 15 => { - x = cursor.read_short().ok()?; - y = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; - let _ = cursor.read_short().ok()?; foothold = Some(cursor.read_short().ok()?); - stance = cursor.read_byte().ok()?; - let _ = cursor.read_short().ok()?; } - 3 | 4 | 7 | 8 | 9 | 10 | 14 => {} - _ => return None, + // Velocity and the foothold fallen from. + 14 => { + cursor.read_short().ok()?; + cursor.read_short().ok()?; + cursor.read_short().ok()?; + } + 10 => { + cursor.read_byte().ok()?; + continue; + } + _ => {} } + stance = cursor.read_byte().ok()?; + cursor.read_short().ok()?; } Some((x, y, stance, foothold)) @@ -317,15 +329,48 @@ mod tests { #[test] fn player_move_rebroadcasts_the_movement_bytes() { let movement = [1u8, 0, 0x10, 0, 0x20, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0x64, 0]; - let packet = build_player_move(0x0102_0304, &movement).unwrap(); + let packet = build_player_move(0x0102_0304, (-40, 300), &movement).unwrap(); let mut expect = Expect::new(&packet.bytes); expect.short(SendOpcode::MovePlayer as i16); - expect.int(0x0102_0304).int(0); + expect.int(0x0102_0304).short(-40).short(300); expect.bytes(&movement); expect.end(); } + #[test] + fn movement_reads_each_element_type() { + let movement = [ + 6u8, // + // A walk: position (16, 32), velocity, foothold 7. + 0, 0x10, 0, 0x20, 0, 0x05, 0, 0, 0, 7, 0, 4, 0x64, 0, // + // A jump: velocity only, so the position stays. + 1, 0xF0, 0xFF, 0x9C, 0xFF, 6, 0x5A, 0, // + // Type 10: one byte, no stance or duration. + 10, 3, // + // A down jump: foothold 9, fallen from 8. + 15, 0x30, 0, 0x40, 0, 0, 0, 0, 0, 9, 0, 8, 0, 7, 0x10, 0, // + // A teleport: position and foothold 11. + 4, 0x50, 0, 0x60, 0, 11, 0, 2, 0, 0, // + // An unknown type: stance and duration only. + 21, 5, 0x10, 0, + ]; + assert_eq!( + parse_movement(&movement, (1, 2, 3)), + Some((0x50, 0x60, 5, Some(11))) + ); + + // Velocity-only elements keep the start and its stance until theirs. + let jump = [ + 2u8, 1, 0, 0, 0, 0, 6, 0x5A, 0, 14, 0, 0, 0, 0, 3, 0, 7, 0x10, 0, + ]; + assert_eq!( + parse_movement(&jump, (100, -50, 2)), + Some((100, -50, 7, None)) + ); + assert_eq!(parse_movement(&jump[..jump.len() - 1], (100, -50, 2)), None); + } + #[test] fn local_chat_matches_reference() { let packet = build_local_chat(0x0102_0304, "hi all", false, 1).unwrap(); diff --git a/net/src/packet/handle/world/move_player.rs b/net/src/packet/handle/world/move_player.rs index a9f60a169..051ec1295 100644 --- a/net/src/packet/handle/world/move_player.rs +++ b/net/src/packet/handle/world/move_player.rs @@ -2,6 +2,9 @@ use crate::handler::{HandlerResult, WorldAction, WorldContext, WorldHandler, Wor use crate::packet::build::world::field::build_player_move; use packet::Packet; +/// Field key byte and CRC int before the path. +const PATH_OFFSET: usize = 2 + 5; +/// The path's start position before its elements. const MOVEMENT_HEADER_LEN: usize = 9; pub struct PlayerMoveHandler; @@ -17,10 +20,16 @@ impl WorldHandler for PlayerMoveHandler { return Ok(HandlerResult::empty()); } + let path = &packet.bytes[PATH_OFFSET..]; + let start = ( + i16::from_le_bytes([path[0], path[1]]), + i16::from_le_bytes([path[2], path[3]]), + ); let movement_bytes = movement_fragment.to_vec(); - let movement_packet = build_player_move(ctx.client_id, &movement_bytes)?; + let movement_packet = build_player_move(ctx.client_id, start, &movement_bytes)?; Ok(HandlerResult::empty().with(WorldAction::FieldMove { packet: movement_packet, + start, movement_bytes, })) } diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 76ff97bbd..c7b8c35cc 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -271,18 +271,21 @@ impl ClientActor { } WorldAction::FieldMove { packet, + start, movement_bytes, } => { self.last_moved = Some(tokio::time::Instant::now()); if let Some(game) = self.game.as_mut() { - let (x, y) = game.position; - if let Some((x, y, _)) = parse_movement_state(&movement_bytes, (x, y, 0)) { + if let Some((x, y, _)) = + parse_movement_state(&movement_bytes, (start.0, start.1, 0)) + { game.position = (x, y); } } self.send_to_field(FieldMessage::Move { from: self.client_id, packet, + start, movement_bytes, }) .await? diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index c2af4d38a..512861cb0 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -199,12 +199,13 @@ impl FieldActor { FieldMessage::Move { from, packet, + start, movement_bytes, } => { if let Some(occupant) = self.occupants.get_mut(&from) { let avatar = &mut occupant.avatar; if let Some((x, y, stance)) = - parse_movement_state(&movement_bytes, (avatar.x, avatar.y, avatar.stance)) + parse_movement_state(&movement_bytes, (start.0, start.1, avatar.stance)) { avatar.x = x; avatar.y = y; @@ -381,7 +382,7 @@ impl FieldActor { start: (i16, i16), movement: &[u8], ) { - if !self.mobs.move_mob(from, object_id, movement) { + if !self.mobs.move_mob(from, object_id, start, movement) { return; } if let Some(occupant) = self.occupants.get(&from) { diff --git a/runtime/src/actor/field_mobs.rs b/runtime/src/actor/field_mobs.rs index 33c614f9b..2d0ae4af2 100644 --- a/runtime/src/actor/field_mobs.rs +++ b/runtime/src/actor/field_mobs.rs @@ -234,15 +234,23 @@ impl MobSet { Some(previous) } - /// Apply the controller's movement. `false` if `from` doesn't control it. - pub fn move_mob(&mut self, from: ClientId, object_id: i32, movement: &[u8]) -> bool { + /// Apply the controller's movement, a path from `start`. `false` if + /// `from` doesn't control it. + pub fn move_mob( + &mut self, + from: ClientId, + object_id: i32, + start: (i16, i16), + movement: &[u8], + ) -> bool { let Some(mob) = self.live.get_mut(&object_id) else { return false; }; if mob.controller != Some(from) { return false; } - if let Some((x, y, stance, foothold)) = parse_movement(movement, (mob.x, mob.y, mob.stance)) + if let Some((x, y, stance, foothold)) = + parse_movement(movement, (start.0, start.1, mob.stance)) { mob.x = x; mob.y = y; @@ -442,8 +450,8 @@ mod tests { // Only the controller may move it. let movement = [1, 0, 0x96, 0, 0xE7, 0xFF, 0, 0, 0, 0, 0, 0, 2, 0, 0]; - assert!(!mobs.move_mob(2, snail, &movement)); - assert!(mobs.move_mob(1, snail, &movement)); + assert!(!mobs.move_mob(2, snail, (150, -25), &movement)); + assert!(mobs.move_mob(1, snail, (150, -25), &movement)); assert_eq!( (mobs.get(snail).unwrap().x, mobs.get(snail).unwrap().y), (150, -25) @@ -454,12 +462,12 @@ mod tests { 2, 0, 0x96, 0, 0x2D, 0, 0, 0, 0, 0, 52, 0, 2, 100, 0, // 1, 0x0A, 0, 0, 0, 2, 100, 0, ]; - assert!(mobs.move_mob(1, snail, &walk)); + assert!(mobs.move_mob(1, snail, (150, -25), &walk)); let view = mobs.get(snail).unwrap().view(false); assert_eq!((view.foothold, view.home_foothold), (52, 45)); // An absolute fragment without a foothold leaves it airborne. let fall = [1, 0, 0x96, 0, 0x30, 0, 0, 0, 0, 0, 0, 0, 2, 100, 0]; - assert!(mobs.move_mob(1, snail, &fall)); + assert!(mobs.move_mob(1, snail, (150, 45), &fall)); assert_eq!(mobs.get(snail).unwrap().view(false).foothold, 0); assert_eq!(mobs.damage(1, snail, 3, now), Some(Ok(63))); diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 21f56a026..c7fba27c4 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -178,6 +178,7 @@ pub enum FieldMessage { Move { from: ClientId, packet: Packet, + start: (i16, i16), movement_bytes: Vec, }, NpcInteraction { From 0ee8358fc781673e5cf63838d7f3e623c5ac1d73 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 01:20:26 -0400 Subject: [PATCH 2/5] L143: v83 attack action short with the facing in the top bit --- HeavenClient/Net/Handlers/AttackHandlers.cpp | 6 ++- .../Net/Packets/AttackAndSkillPackets.h | 4 +- integration-harness/src/hunting.rs | 9 ++-- net/src/combat.rs | 53 ++++++++++--------- 4 files changed, 41 insertions(+), 31 deletions(-) diff --git a/HeavenClient/Net/Handlers/AttackHandlers.cpp b/HeavenClient/Net/Handlers/AttackHandlers.cpp index 25b47a7fc..e0d379ab2 100644 --- a/HeavenClient/Net/Handlers/AttackHandlers.cpp +++ b/HeavenClient/Net/Handlers/AttackHandlers.cpp @@ -46,8 +46,10 @@ namespace ms attack.skill = (attack.level > 0) ? recv.read_int() : 0; attack.display = recv.read_byte(); - attack.toleft = recv.read_bool(); - attack.stance = recv.read_byte(); + // The action, with the facing in the top bit (0x9803ab). + uint16_t action = static_cast(recv.read_short()); + attack.toleft = (action >> 15) != 0; + attack.stance = static_cast(action & 0x7FFF); attack.speed = recv.read_byte(); recv.skip(1); diff --git a/HeavenClient/Net/Packets/AttackAndSkillPackets.h b/HeavenClient/Net/Packets/AttackAndSkillPackets.h index 1b23e7e57..364112e80 100644 --- a/HeavenClient/Net/Packets/AttackAndSkillPackets.h +++ b/HeavenClient/Net/Packets/AttackAndSkillPackets.h @@ -53,8 +53,8 @@ namespace ms write_int(attack.charge); write_byte(attack.display); - write_byte(attack.toleft); - write_byte(attack.stance); + // The action, with the facing in the top bit (0x952974). + write_short(static_cast((attack.stance & 0x7FFF) | (attack.toleft ? 0x8000 : 0))); skip(1); diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index ca8d98ed2..ff89cbd1d 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -52,9 +52,12 @@ fn kind_attack( p.write_byte(0x11).unwrap(); p.write_int(skill_id).unwrap(); p.write_bytes(&[0; 8]).unwrap(); - for b in [0u8, 0x80, 5, 0, 4] { - p.write_byte(b).unwrap(); - } + // Flags, then action 5 facing left (v83 `action | left << 15`), then + // a byte and the attack speed. + p.write_byte(0).unwrap(); + p.write_short(0x8005u16 as i16).unwrap(); + p.write_byte(0).unwrap(); + p.write_byte(4).unwrap(); if ranged { p.write_int(0).unwrap(); p.write_short(ammo_slot).unwrap(); diff --git a/net/src/combat.rs b/net/src/combat.rs index 133af5b9c..4fa07aa96 100644 --- a/net/src/combat.rs +++ b/net/src/combat.rs @@ -66,8 +66,9 @@ pub struct Attack { pub mob_count: u8, pub hit_count: u8, pub display: u8, - pub direction: u8, - pub stance: u8, + /// The action, with the facing in the top bit (v83 0x952974: `action & + /// 0x7fff | left << 15`). + pub action: u16, pub speed: u8, /// How long a held skill was charged, in ms; 0 for the rest. pub charge: i32, @@ -101,8 +102,7 @@ pub fn parse_attack(packet: &Packet, kind: AttackKind) -> Result Result Date: Sat, 17 Oct 2026 01:38:06 -0400 Subject: [PATCH 3/5] Batch the user's movement into v83 paths and play others' back smoothly The user's steps merge into runs and go out about once a second, holding back air and rope elements until a foothold follows (FUN_0068ab85, FUN_0068a88d), with the key byte and bounding rect. Other characters queue whole paths and play them on a Hermite curve, a tenth faster when a second or more behind, snapping when over six (FUN_0068b371, FUN_0068adcc). --- HeavenClient/Character/OtherChar.cpp | 49 +--- HeavenClient/Character/OtherChar.h | 9 +- HeavenClient/Character/Player.cpp | 27 +- HeavenClient/Character/Player.h | 10 +- HeavenClient/Gameplay/MapleMap/MapInfo.cpp | 8 + HeavenClient/Gameplay/MapleMap/MapInfo.h | 4 + HeavenClient/Gameplay/MovePath.h | 295 +++++++++++++++++++++ HeavenClient/Gameplay/Movement.h | 6 +- HeavenClient/Net/Packets/GameplayPackets.h | 12 +- HeavenClient/Net/Packets/MovementPacket.h | 56 ++++ HeavenClient/tests/movement_path.cpp | 108 ++++++++ 11 files changed, 521 insertions(+), 63 deletions(-) create mode 100644 HeavenClient/Gameplay/MovePath.h diff --git a/HeavenClient/Character/OtherChar.cpp b/HeavenClient/Character/OtherChar.cpp index b28f6c4a0..09cd54607 100644 --- a/HeavenClient/Character/OtherChar.cpp +++ b/HeavenClient/Character/OtherChar.cpp @@ -22,46 +22,29 @@ namespace ms { OtherChar::OtherChar(int32_t id, const CharLook &lk, uint8_t lvl, int16_t jb, const std::string &nm, int8_t st, - Point pos) : Char(id, lk, nm) + Point pos) : Char(id, lk, nm), + path({ static_cast(pos.x()), static_cast(pos.y()), 0.0, 0.0, 0, + static_cast(st) }) { level = lvl; job = jb; set_position(pos); - lastmove.xpos = pos.x(); - lastmove.ypos = pos.y(); - lastmove.newstate = st; - timer = 0; - attackspeed = 6; attacking = false; } int8_t OtherChar::update(const Physics &physics) { - if (timer > 1) - { - timer--; - } else if (timer == 1) - { - if (!movements.empty()) - { - lastmove = movements.front(); - movements.pop(); - } else - { - timer = 0; - } - } + path.step(Constants::TIMESTEP); + + const move_path::Player::State &now = path.get_state(); if (!attacking) - { - uint8_t laststate = lastmove.newstate; - set_state(laststate); - } + set_state(now.stance); - phobj.hspeed = lastmove.xpos - phobj.crnt_x(); - phobj.vspeed = lastmove.ypos - phobj.crnt_y(); + phobj.hspeed = now.x - phobj.crnt_x(); + phobj.vspeed = now.y - phobj.crnt_y(); phobj.move(); physics.get_fht().update_fh(phobj); @@ -76,16 +59,7 @@ namespace ms void OtherChar::send_movement(const std::vector &newmoves) { - if (newmoves.empty()) - return; - - movements.push(newmoves.back()); - - if (timer == 0) - { - constexpr uint16_t DELAY = 50; - timer = DELAY; - } + path.receive(newmoves); } void OtherChar::update_skill(int32_t skillid, uint8_t skilllevel) @@ -102,8 +76,7 @@ namespace ms { look = newlook; - uint8_t laststate = lastmove.newstate; - set_state(laststate); + set_state(path.get_state().stance); } int8_t OtherChar::get_integer_attackspeed() const diff --git a/HeavenClient/Character/OtherChar.h b/HeavenClient/Character/OtherChar.h index 15dc99c77..5992b0137 100644 --- a/HeavenClient/Character/OtherChar.h +++ b/HeavenClient/Character/OtherChar.h @@ -25,9 +25,8 @@ #include "Look/CharLook.h" -#include "../Gameplay/Movement.h" +#include "../Gameplay/MovePath.h" -#include #include namespace ms @@ -42,7 +41,7 @@ namespace ms // Update the character. int8_t update(const Physics &physics) override; - // Add the movements which this character will go through next. + // Queue a path for the character to play. void send_movement(const std::vector &movements); // Update a skill level. @@ -66,9 +65,7 @@ namespace ms private: uint16_t level; int16_t job; - std::queue movements; - Movement lastmove; - uint16_t timer; + move_path::Player path; std::unordered_map skilllevels; uint8_t attackspeed; diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index eed576abd..2e99296ea 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -111,6 +111,7 @@ namespace ms ladder = nullptr; set_chair(0); nullstate.update_state(*this); + path.reset(current_move(0)); } void Player::send_action(KeyAction::Id action, bool down) @@ -259,14 +260,10 @@ namespace ms update_recovery(); - Movement newmove(phobj, get_stancebyte()); - bool needupdate = lastmove.hasmoved(newmove); + path.step(current_move(0), Constants::TIMESTEP); - if (needupdate) - { - MovePlayerPacket({ phobj.get_last_x(), phobj.get_last_y() }, newmove).dispatch(); - lastmove = newmove; - } + if (auto sent = path.flush()) + MovePlayerPacket(*sent).dispatch(); return get_layer(); } @@ -278,7 +275,21 @@ namespace ms void Player::record_move(uint8_t command) { - MovePlayerPacket(phobj.get_position(), Movement(phobj, get_stancebyte(), command, 0)).dispatch(); + path.add(current_move(command)); + } + + Movement Player::current_move(uint8_t command) const + { + Movement move(phobj, get_stancebyte(), command, 0); + + if (is_dead()) + move.fh = 0; + else if (ladder) + move.fh = -ladder->get_id(); + else if (!phobj.onground) + move.fh = 0; + + return move; } void Player::update_death() diff --git a/HeavenClient/Character/Player.h b/HeavenClient/Character/Player.h index 971e6346a..1c0039377 100644 --- a/HeavenClient/Character/Player.h +++ b/HeavenClient/Character/Player.h @@ -31,6 +31,7 @@ #include "Inventory/Inventory.h" +#include "../Gameplay/MovePath.h" #include "../Gameplay/Playable.h" #include "../Gameplay/Combat/Skill.h" @@ -64,8 +65,8 @@ namespace ms // Respawn the player at the given position void respawn(Point position, bool underwater); - // Tell the server, as path element `command` of no duration, where - // the player is now (v83 FUN_0052ef17). + // Mark where the player is now in the path to the server, as + // element `command` of no duration (v83 FUN_0052ef17). void record_move(uint8_t command); // Sends a Keyaction to the player's state, to apply forces, change the state and other behaviour. @@ -308,10 +309,13 @@ namespace ms std::map keysdown; - Movement lastmove; + move_path::Recorder path; uint8_t get_stancebyte() const; + // Where the player is and how it moves, as path element `command`. + Movement current_move(uint8_t command) const; + Randomizer randomizer; Optional ladder; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp index 584716fbb..9c7702e53 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.cpp @@ -156,6 +156,9 @@ namespace ms Ladder::Ladder(nl::node src) { + std::string name = src.name(); + id = 0; + std::from_chars(name.data(), name.data() + name.size(), id); x = src["x"]; y1 = src["y1"]; y2 = src["y2"]; @@ -163,6 +166,11 @@ namespace ms uf = src["uf"].get_bool(); } + int16_t Ladder::get_id() const + { + return id; + } + bool Ladder::is_ladder() const { return ladder; diff --git a/HeavenClient/Gameplay/MapleMap/MapInfo.h b/HeavenClient/Gameplay/MapleMap/MapInfo.h index e37df1c4c..5ab0f3723 100644 --- a/HeavenClient/Gameplay/MapleMap/MapInfo.h +++ b/HeavenClient/Gameplay/MapleMap/MapInfo.h @@ -49,6 +49,9 @@ namespace ms bool is_ladder() const; + // Its number in the map. + int16_t get_id() const; + // Whether climbing past the top steps onto a platform (uf). bool leads_up() const; @@ -57,6 +60,7 @@ namespace ms int16_t get_bottom() const; private: + int16_t id; int16_t x; int16_t y1; int16_t y2; diff --git a/HeavenClient/Gameplay/MovePath.h b/HeavenClient/Gameplay/MovePath.h new file mode 100644 index 000000000..02aaa58e8 --- /dev/null +++ b/HeavenClient/Gameplay/MovePath.h @@ -0,0 +1,295 @@ +#pragma once + +#include "Movement.h" + +#include "../Template/Point.h" + +#include +#include +#include +#include +#include + +namespace ms +{ + // How the MapleRoyals client batches the user's movement into paths + // (FUN_0068ab85, FUN_0068a88d) and plays other characters' paths back + // (FUN_0068b371, FUN_0068adcc). + namespace move_path + { + // A path goes out once it holds this much movement (FUN_0068a828). + constexpr int32_t SEND_MS = 1000; + + // Elements on a foothold. The rest are in the air (0) or on a ladder + // or rope (minus its number). + inline bool grounded(const Movement &element) + { + return element.fh > 0; + } + + struct Path + { + Point start; + std::vector elements; + }; + + // The user's movement since the last path went out. + class Recorder + { + public: + // Start over where the user stands, as on entering a map + // (FUN_0068aab2). + void reset(const Movement &now) + { + elements.clear(); + recorded = 0; + start = { now.xpos, now.ypos }; + last = now; + } + + // One step of `ms` milliseconds that ended in `now`. A step that + // goes on the same way as the last one only makes that one + // longer. + void step(Movement now, int16_t ms) + { + recorded += ms; + + if (!elements.empty() && extends(elements.back(), now)) + { + now.duration = elements.back().duration + ms; + elements.back() = now; + } else + { + now.duration = ms; + append(now); + } + } + + // An element of its own, such as a teleport (4), which takes no + // time. + void add(Movement element) + { + element.duration = 0; + append(element); + } + + // The path to send, once there is a second of movement with + // something on a foothold in it. What comes after the last + // element on a foothold waits for the next path, and a user who + // has stayed put sends nothing. + std::optional flush() + { + bool ready = !elements.empty() && recorded >= SEND_MS && + std::any_of(elements.begin(), elements.end(), grounded); + + if (!ready) + return std::nullopt; + + auto held = std::find_if(elements.rbegin(), elements.rend(), grounded).base(); + + Path path{ start, std::vector(elements.begin(), held) }; + elements.erase(elements.begin(), held); + + recorded = 0; + + for (const Movement &element : elements) + recorded += element.duration; + + const Movement &tail = path.elements.back(); + bool idle = tail.command == 0 && !last.hasmoved(tail) && tail.fh == last.fh && + std::none_of(path.elements.begin(), path.elements.end(), [](const Movement &element) + { + return element.command == 10; + }); + + last = tail; + start = { tail.xpos, tail.ypos }; + + if (idle) + return std::nullopt; + + return path; + } + + private: + static int sign(int16_t value) + { + return (value > 0) - (value < 0); + } + + static bool extends(const Movement &tail, const Movement &now) + { + bool walks = now.command == 0 || now.command == 15 || now.command == 17; + + return walks && tail.command == now.command && tail.fh == now.fh && tail.newstate == now.newstate && + sign(tail.vx) == sign(now.vx) && sign(tail.vy) == sign(now.vy); + } + + // An empty walk at the end is taken over rather than kept. + void append(const Movement &element) + { + if (!elements.empty() && elements.back().command == 0 && elements.back().duration == 0) + elements.back() = element; + else + elements.push_back(element); + } + + std::vector elements; + int32_t recorded = 0; + Point start; + Movement last; + }; + + // Plays another character's paths back in order, as received. + class Player + { + public: + struct State + { + double x; + double y; + // Pixels per second. + double vx; + double vy; + int16_t fh; + uint8_t stance; + }; + + explicit Player(State state) : state(state), from(state) + {} + + // Queue a path. One that falls more than five seconds behind is + // dropped for a jump to where it ends. + void receive(const std::vector &elements) + { + if (elements.empty()) + return; + + for (const Movement &element : elements) + { + queue.push_back(element); + queued += element.duration * TICK; + } + + rate = queued < BEHIND * TICK ? TICK : FAST; + + if (queued > (BEHIND + 5000) * TICK) + { + Movement snap = elements.back(); + snap.command = 3; + snap.duration = 0; + + queue.assign(1, snap); + queued = 0; + } + } + + // Advance `ms` milliseconds, a tenth faster while a second or + // more is queued. + void step(int32_t ms) + { + if (queue.empty()) + return; + + int32_t advance = ms * rate; + elapsed += advance; + + while (!queue.empty()) + { + const Movement &head = queue.front(); + + if (head.command != 10) + { + if (elapsed < head.duration * TICK) + break; + + elapsed -= head.duration * TICK; + arrive(head); + } + + queue.pop_front(); + } + + queued -= advance; + + if (queue.empty() || queued <= 0) + { + queue.clear(); + elapsed = 0; + queued = 0; + + return; + } + + const Movement &head = queue.front(); + double x = state.x; + double y = state.y; + + if (head.command == 3) + { + state.x = head.xpos; + state.y = head.ypos; + state.vx = head.vx; + state.vy = head.vy; + } else + { + state.x = curve(from.x, from.vx, head.xpos, head.vx, head.duration); + state.y = curve(from.y, from.vy, head.ypos, head.vy, head.duration); + state.vx = (state.x - x) * 1000 / ms; + state.vy = (state.y - y) * 1000 / ms; + } + + state.fh = head.fh; + state.stance = head.newstate; + } + + const State &get_state() const + { + return state; + } + + bool playing() const + { + return !queue.empty(); + } + + private: + // Times are kept in thirtieths of a millisecond, so the faster + // rate adds up exactly. + static constexpr int32_t TICK = 30; + static constexpr int32_t FAST = 33; + static constexpr int32_t BEHIND = 1100; + + void arrive(const Movement &element) + { + state.x = element.xpos; + state.y = element.ypos; + state.vx = element.vx; + state.vy = element.vy; + state.fh = element.fh; + state.stance = element.newstate; + from = state; + } + + // The cubic Hermite curve from where the last element ended to + // where this one ends, with both speeds (FUN_0068b108). + double curve(double p0, double v0, double p1, double v1, int16_t duration) const + { + double t = static_cast(elapsed) / (duration * TICK); + double seconds = elapsed / (1000.0 * TICK); + double t2 = t * t; + double t3 = t2 * t; + + return (2 * t3 - 3 * t2 + 1) * p0 + (3 * t2 - 2 * t3) * p1 + (t2 - 2 * t + 1) * seconds * v0 + + (t2 - t) * seconds * v1; + } + + State state; + State from; + std::deque queue; + int32_t elapsed = 0; + int32_t queued = 0; + int32_t rate = TICK; + }; + } +} diff --git a/HeavenClient/Gameplay/Movement.h b/HeavenClient/Gameplay/Movement.h index 7b2a451f9..e386f02c7 100644 --- a/HeavenClient/Gameplay/Movement.h +++ b/HeavenClient/Gameplay/Movement.h @@ -37,7 +37,7 @@ namespace ms JUMPDOWN }; - Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t vx, int16_t vy, uint16_t f, uint8_t s, int16_t d) + Movement(Type t, uint8_t c, int16_t x, int16_t y, int16_t vx, int16_t vy, int16_t f, uint8_t s, int16_t d) : type(t), command(c), xpos(x), ypos(y), vx(vx), vy(vy), fh(f), newstate(s), duration(d) {} @@ -73,7 +73,9 @@ namespace ms // Velocity, in pixels per second. int16_t vx; int16_t vy; - uint16_t fh; + // The foothold, minus the ladder or rope's number on one, or 0 in + // the air. + int16_t fh; uint8_t newstate; int16_t duration; }; diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index d22cf76c8..87988e337 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -24,6 +24,8 @@ #include "MovementPacket.h" +#include "../../Gameplay/MovePath.h" + #include "../../Character/Inventory/InventoryType.h" namespace ms @@ -215,14 +217,12 @@ namespace ms class MovePlayerPacket : public MovementPacket { public: - // Updates the player's position with the server: a path of one - // element from `start` - MovePlayerPacket(Point start, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) + // Updates the player's position with the server: the field key and + // crc (left 0), then the path. + MovePlayerPacket(const move_path::Path& path) : MovementPacket(OutPacket::Opcode::MOVE_PLAYER) { skip(5); - write_point(start); - write_byte(1); - writemovement(movement); + writepath(path.start, path.elements); } }; diff --git a/HeavenClient/Net/Packets/MovementPacket.h b/HeavenClient/Net/Packets/MovementPacket.h index 9bb28983c..17d16c583 100644 --- a/HeavenClient/Net/Packets/MovementPacket.h +++ b/HeavenClient/Net/Packets/MovementPacket.h @@ -21,6 +21,9 @@ #include "../Gameplay/Movement.h" +#include +#include + namespace ms { // Base class for packets which update object movements with the server. @@ -30,6 +33,59 @@ namespace ms MovementPacket(OutPacket::Opcode opc) : OutPacket(opc) {} protected: + // A path as v83 writes it (FUN_0068a563): where it starts, its + // elements, the keys held (none here), and the rectangle around the + // start and every element with a position. + void writepath(Point start, const std::vector& elements) + { + write_point(start); + write_byte(static_cast(elements.size())); + + int16_t left = start.x(); + int16_t top = start.y(); + int16_t right = start.x(); + int16_t bottom = start.y(); + + for (const Movement& element : elements) + { + writemovement(element); + + if (positioned(element.command)) + { + left = std::min(left, element.xpos); + top = std::min(top, element.ypos); + right = std::max(right, element.xpos); + bottom = std::max(bottom, element.ypos); + } + } + + write_byte(0); + write_short(left); + write_short(top); + write_short(right); + write_short(bottom); + } + + static bool positioned(uint8_t command) + { + switch (command) + { + case 0: + case 3: + case 4: + case 5: + case 7: + case 8: + case 9: + case 11: + case 15: + case 17: + return true; + default: + return false; + } + } + // One path element, laid out by its type as v83 writes it // (FUN_0068a563). void writemovement(const Movement& movement) diff --git a/HeavenClient/tests/movement_path.cpp b/HeavenClient/tests/movement_path.cpp index d05f5d1c2..947475312 100644 --- a/HeavenClient/tests/movement_path.cpp +++ b/HeavenClient/tests/movement_path.cpp @@ -1,4 +1,6 @@ +#include "../Gameplay/MovePath.h" #include "../Net/Handlers/Helpers/MovementParser.h" +#include #include #include #include @@ -9,6 +11,109 @@ void require(bool condition, const char* message) if (!condition) throw std::runtime_error(message); } +ms::Movement at(int16_t x, int16_t y, int16_t vx, int16_t vy, int16_t fh, uint8_t stance = 2) +{ + return ms::Movement(ms::Movement::ABSOLUTE, 0, x, y, vx, vy, fh, stance, 0); +} + +// Steps of 8 ms, as HC records them, moving `vx` px/s from `x` each step. +int16_t walk(ms::move_path::Recorder& path, int16_t x, int16_t vx, int16_t fh, int steps, int16_t vy = 0) +{ + for (int i = 0; i < steps; i++) + { + x += vx / 125; + path.step(at(x, 0, vx, vy, fh), 8); + } + + return x; +} + +void recorder() +{ + using namespace ms::move_path; + + Recorder path; + path.reset(at(0, 0, 0, 0, 7)); + + int16_t x = walk(path, 0, 125, 7, 124); + require(!path.flush(), "nothing goes out before a second"); + + x = walk(path, x, 125, 7, 1); + auto sent = path.flush(); + require(sent && sent->start == ms::Point(0, 0), "a second of walking goes out from where it began"); + require(sent->elements.size() == 1 && sent->elements[0].duration == 1000 && sent->elements[0].xpos == x, + "a walk in one direction is one element"); + + walk(path, x, 0, 7, 125); + sent = path.flush(); + require(sent && sent->start == ms::Point(x, 0) && sent->elements.size() == 1, + "the next path starts where the last one ended"); + walk(path, x, 0, 7, 125); + require(!path.flush(), "standing still sends nothing"); + + x = walk(path, x, 125, 7, 60); + x = walk(path, x, -125, 7, 10); + x = walk(path, x, 125, 0, 40, -500); + x = walk(path, x, 125, 0, 40, 500); + sent = path.flush(); + require(sent && sent->elements.size() == 2 && sent->elements[1].vx < 0, + "turning around starts an element, and a jump waits"); + require(!path.flush(), "the jump stays for the next path"); + + int16_t before = sent->elements[1].xpos; + x = walk(path, x, 125, 7, 45); + sent = path.flush(); + require(sent && sent->start == ms::Point(before, 0) && sent->elements.size() == 3, + "the jump goes out once the player lands"); + require(sent->elements[0].fh == 0 && sent->elements[0].vy < 0 && sent->elements[1].vy > 0, + "rising and falling are separate elements"); + + path.add(ms::Movement(ms::Movement::ABSOLUTE, 4, x, 0, 0, 0, 7, 2, 5)); + walk(path, 200, 0, 7, 125); + sent = path.flush(); + require(sent && sent->elements.size() == 2 && sent->elements[0].command == 4 && + sent->elements[0].duration == 0 && sent->elements[0].xpos == x && sent->elements[1].xpos == 200, + "a teleport marks where it left from"); + + walk(path, 200, 0, -3, 200); + require(!path.flush(), "a climb waits for a foothold"); +} + +void player() +{ + using namespace ms::move_path; + + Player path({ 0, 0, 0, 0, 7, 2 }); + ms::Movement first = at(100, 0, 100, 0, 7, 3); + first.duration = 1000; + path.receive({ first }); + path.receive({}); + require(path.playing(), "a path plays at once"); + + path.step(500); + require(std::abs(path.get_state().x - 37.5) < 1e-9 && path.get_state().stance == 3, + "it follows a curve between the two speeds"); + + path.step(500); + require(!path.playing() && path.get_state().x == 100 && path.get_state().vx == 100, + "it ends where the path does"); + + ms::Movement skip = at(0, 0, 0, 0, 0); + skip.command = 10; + ms::Movement walk = at(300, 0, 0, 0, 7, 4); + walk.duration = 1200; + path.receive({ skip, walk }); + path.step(100); + // 110 ms into 1200, leaving 100 at 100 px/s for 300. + require(std::abs(path.get_state().x - 113.8093287) < 1e-6, "a path of 1.1 seconds or more plays a tenth faster"); + + walk.duration = 7000; + walk.xpos = -50; + path.receive({ walk }); + path.step(8); + require(!path.playing() && path.get_state().x == -50, "one too far behind jumps to its end"); +} + struct Writer { std::vector bytes; @@ -73,6 +178,9 @@ int main() require(Movement::velocity(1.0) == 125 && Movement::velocity(-4.44) == -555, "speeds are per second"); + recorder(); + player(); + std::cout << "Movement path passed\n"; return 0; } From 83e7ddae01a9b6e1584ac8949fb29729e96b0e46 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 01:58:35 -0400 Subject: [PATCH 4/5] HeavenClient: mobs move by paths, as v83 does (L170) The controller runs the mob's AI on its own body ahead of what is shown, steps it until it has a second of movement (FUN_0066b6fc), sends that path in MOVE_MONSTER and plays it like every other client. A hit shows at once; the shown path stops at the next foothold and the AI flinches from there (FUN_0068b16f). Other clients play the paths they get in turn. --- HeavenClient/Character/Player.cpp | 9 +- HeavenClient/Gameplay/MapleMap/Mob.cpp | 650 +++++++++++---------- HeavenClient/Gameplay/MapleMap/Mob.h | 70 ++- HeavenClient/Gameplay/MovePath.h | 75 ++- HeavenClient/Graphics/Animation.cpp | 7 +- HeavenClient/Graphics/Animation.h | 3 + HeavenClient/Net/Packets/GameplayPackets.h | 9 +- HeavenClient/tests/movement_path.cpp | 80 ++- 8 files changed, 562 insertions(+), 341 deletions(-) diff --git a/HeavenClient/Character/Player.cpp b/HeavenClient/Character/Player.cpp index 2e99296ea..2826ddc05 100644 --- a/HeavenClient/Character/Player.cpp +++ b/HeavenClient/Character/Player.cpp @@ -262,8 +262,13 @@ namespace ms path.step(current_move(0), Constants::TIMESTEP); - if (auto sent = path.flush()) - MovePlayerPacket(*sent).dispatch(); + if (path.ready()) + { + move_path::Path sent = path.flush(); + + if (!sent.idle) + MovePlayerPacket(sent).dispatch(); + } return get_layer(); } diff --git a/HeavenClient/Gameplay/MapleMap/Mob.cpp b/HeavenClient/Gameplay/MapleMap/Mob.cpp index 4913e558a..c463fe518 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.cpp +++ b/HeavenClient/Gameplay/MapleMap/Mob.cpp @@ -39,7 +39,7 @@ namespace ms { Mob::Mob(int32_t oi, int32_t mid, int8_t mode, int8_t st, uint16_t fh, uint16_t homefh, bool newspawn, int8_t tm, - Point position, const Physics &physics) : MapObject(oi) + Point position, const Physics &physics) : MapObject(oi), shown(move_path::Player::State{}) { std::string strid = string_format::extend_id(mid, 7); nl::node src = nl::nx::mob[strid + ".img"]; @@ -103,7 +103,6 @@ namespace ms phobj.shoe = true; phobj.walkforce = info["fs"].get_real(1.0); phobj.walkdrag = phobj.walkforce; - update_shoe(false); id = mid; team = tm; @@ -116,7 +115,6 @@ namespace ms jumpdir = 0; checkjump = false; jumpy = 0; - set_control(mode); phobj.fhid = fh; // v83 stands a new mob on the foothold it enters on (FUN_007f115f), @@ -154,16 +152,26 @@ namespace ms dying = false; dead = false; fading = false; - set_stance(st); + stance = Stance::STAND; + show_stance(st); haswaypoint = false; flygroup = -1; flycount = 0; flyx = 0; flyy = 0; - flysteps = 0; pending_hits = 0; death_timeout = 0; + // It shows where it enters until a path comes, and the AI starts + // from there. + shown = move_path::Player({ phobj.crnt_x(), phobj.crnt_y(), 0.0, 0.0, + static_cast(phobj.onground ? phobj.fhid : 0), value_of(stance, flip) }); + body = phobj; + bodystance = stance; + bodyflip = flip; + update_shoe(false); + set_control(mode); + namelabel = Text(Text::Font::A13M, Text::Alignment::CENTER, Color::Name::WHITE, Text::Background::NAMETAG, name); set_level_tag(0); @@ -179,11 +187,11 @@ namespace ms } } - void Mob::set_stance(uint8_t stancebyte) + Mob::Stance Mob::stance_of(uint8_t stancebyte, bool &right) const { - flip = (stancebyte % 2) == 0; + right = (stancebyte % 2) == 0; - if (!flip) + if (!right) stancebyte -= 1; if (stancebyte < Stance::MOVE) @@ -194,30 +202,50 @@ namespace ms if (animations.count(newstance) == 0) newstance = Stance::MOVE; - set_stance(newstance); + return newstance; + } + + void Mob::show_stance(uint8_t stancebyte) + { + bool right; + Stance newstance = stance_of(stancebyte, right); + + show_stance(newstance, right); } - void Mob::set_stance(Stance newstance) + void Mob::show_stance(Stance newstance, bool right) + { + flip = right; + + if (stance != newstance) + { + stance = newstance; + + animations.at(stance).reset(); + } + } + + void Mob::set_body_stance(Stance newstance) { if (newstance != Stance::HIT) { - phobj.clear_flag(PhysicsObject::Flag::NOFRICTION); + body.clear_flag(PhysicsObject::Flag::NOFRICTION); // Monster Magnet's pull ends with the flinch. if (pull) { pull.reset(); - phobj.hspeed = 0.0; - phobj.vspeed = 0.0; + body.hspeed = 0.0; + body.vspeed = 0.0; } } - if (stance != newstance) - { - stance = newstance; + bodystance = newstance; + } - animations.at(stance).reset(); - } + Point Mob::body_position() const + { + return { body.get_x(), body.get_y() }; } int8_t Mob::update(const Physics &physics) @@ -284,190 +312,310 @@ namespace ms if (!dying) { - if (!canfly) + if (control) { - if (phobj.is_flag_not_set(PhysicsObject::Flag::TURNATEDGES)) + // A firstAttack mob that can attack (FUN_005ce8c6) starts + // chasing the local player once they come within + // |dx| / 10 + |dy| / 3 <= 40 of it. + if (firstattack && !notattack && !hastarget) { - // A knocked back mob stops at the edge, still facing its - // attacker. - if (stance != Stance::HIT) - flip = !flip; + Point position = get_position(); + int32_t dx = std::abs(targetpos.x() - position.x()); + int32_t dy = std::abs(targetpos.y() - position.y()); - phobj.set_flag(PhysicsObject::Flag::TURNATEDGES); + if (dx / 10 + dy / 3 <= 40) + chase(); } - } - if (canfly) - { - // Another client's flier glides the way it faces between the - // controller's updates. - if (!control) + if (flinch) + { + flinch_body(physics); + } else if (!shown.playing() && ctrlstate >= 3) { - flyx = stance == Stance::MOVE ? (flip ? 1 : -1) : 0; - flyy = stance == Stance::HIT ? 0 : -1; + // The next path goes once the last has played and been + // acked (FUN_006675a8). + plan(physics); } - - phobj.walkdir = flyx; - phobj.flydir = flyy; } - switch (stance) - { - case Stance::MOVE: - case Stance::CHASE: - if (!canfly) - phobj.walkdir = flip ? 1 : -1; - - break; - case Stance::HIT: - if (pull) - { - // Monster Magnet's pull (0x9bbdfd) slows each 30 ms - // step once the mob nears the user, and a flier rises - // or sinks toward them too. - Point position = get_position(); + shown.step(Constants::TIMESTEP); - pull->elapsed += Constants::TIMESTEP; + const move_path::Player::State &now = shown.get_state(); - while (pull->elapsed >= magnet::STEP) - { - pull->elapsed -= magnet::STEP; - pull->speed = magnet::step_speed(pull->speed, position.x(), pull->user.x(), pull->reach); - } + // A hit shows at once, over the path, until it has played. + if (acting && aniend) + acting = false; - phobj.hspeed = pull->speed * Constants::TIMESTEP / 1000.0; + if (!acting) + show_stance(now.stance); - if (canfly) - phobj.vspeed = magnet::fly_speed(position.y(), pull->user.y()) * Constants::TIMESTEP / 1000.0; - } else if (phobj.is_flag_set(PhysicsObject::Flag::NOFRICTION) && phobj.onground) - { - // v83 slows the push by 40000 / mass (100) px/s^2 - // along the ground; walls and edges zero it. - constexpr double KBDECEL = 400.0 * Constants::TIMESTEP * Constants::TIMESTEP / 1000000.0; - - double kbspeed = std::abs(phobj.hspeed) - KBDECEL; - - if (kbspeed > 0.0) - { - phobj.hspeed = phobj.hspeed < 0.0 ? -kbspeed : kbspeed; - } else - { - phobj.hspeed = 0.0; - phobj.clear_flag(PhysicsObject::Flag::NOFRICTION); - } - } + phobj.hspeed = now.x - phobj.crnt_x(); + phobj.vspeed = now.y - phobj.crnt_y(); + phobj.move(); - break; - case Stance::JUMP: - // CVecCtrl::Jump (FUN_007f1bb0) speeds the mob up in the - // direction it holds, and it keeps steering in the air. - if (phobj.onground) - Physics::jump(phobj, Physics::jump_speed(1.0), jumpdir); + if (!canfly) + physics.get_fht().update_fh(phobj); + } else + { + phobj.normalize(); + physics.get_fht().update_fh(phobj); - phobj.walkdir = jumpdir; + if (death_timeout > 0 && --death_timeout == 0 && stance != Stance::DIE) + apply_death(); + } - break; - } + return phobj.fhlayer; + } + + void Mob::seat_body(const Physics &physics) + { + move_path::Player::State end = shown.end(); + + body.set_x(end.x); + body.set_y(end.y); + body.hspeed = end.vx * Constants::TIMESTEP / 1000.0; + body.vspeed = end.vy * Constants::TIMESTEP / 1000.0; + + if (end.fh > 0 && !canfly) + physics.get_fht().place_on_fh(body, end.fh); + else + body.onground = false; + + bool right; + Stance endstance = stance_of(end.stance, right); + + // It may stop mid-flinch, but only a flinch it takes holds it. + set_body_stance(endstance == Stance::HIT ? Stance::STAND : endstance); + bodyflip = right; + hittime = 0; + + recorder.reset(Movement(Movement::ABSOLUTE, 0, body.get_x(), body.get_y(), Movement::velocity(body.hspeed), + Movement::velocity(body.vspeed), end.fh, value_of(bodystance, bodyflip), 0)); + seated = true; + } + + void Mob::plan(const Physics &physics) + { + if (!seated) + seat_body(physics); + + // Up to 55 of v83's 90 ms steps (66bbb8). + for (int32_t ms = 0; ms < 55 * 90 && !recorder.ready(canfly); ms += Constants::TIMESTEP) + step_body(physics); + + move_path::Path path = recorder.flush(canfly); + + // It asks to attack when chasing, unless controlling, then 70% of + // the time. + bool wantattack = hastarget && (ctrlstate < 0 || randomizer.next_int(100) < 70); + int8_t flags = (ctrlstate < 0 ? 0x10 : 0) | (wantattack ? 1 : 0); + + MoveMobPacket(oid, ++moveid, flags, -1, false, 0, canfly, path).dispatch(); + + // It plays what it sends, as others will (FUN_0068a88d). + shown.receive(path.elements); + + ctrlstate = ctrlstate == -2 || ctrlstate == 1 ? 1 : 2; + } + + void Mob::flinch_body(const Physics &physics) + { + // v83 stops what it shows at the next foothold and starts the AI + // again from there (FUN_0068b16f, FUN_009bbd3d). Only walkers and + // jumpers go on to one. + shown.land(canfly || !canmove); + seat_body(physics); - if (phobj.is_flag_set(PhysicsObject::Flag::JUMPER)) + Point position = body_position(); + + bodyflip = flinch->user ? flinch->user->x() > position.x() : flinch->left; + set_body_stance(Stance::HIT); + hittime = animations.at(Stance::HIT).get_duration(); + + // It stops steering while hit, so a flier dives (FUN_007f7f02). + flyx = 0; + flyy = 0; + + if (flinch->user) + { + // Monster Magnet pulls it in, then it chases the user (0x66b562). + int16_t reach = standwidth + magnet::REACH; + + pull = Pull{ *flinch->user, reach, magnet::start_speed(position.x(), flinch->user->x(), reach), 0 }; + body.set_flag(PhysicsObject::Flag::NOFRICTION); + chase(); + } else if (canmove) + { + // v83 pushes a flinching mob away from its attacker at 130 px/s. + constexpr double KBSPEED = 130.0 * Constants::TIMESTEP / 1000.0; + + body.hspeed = bodyflip ? -KBSPEED : KBSPEED; + body.set_flag(PhysicsObject::Flag::NOFRICTION); + } + + flinch.reset(); + plan(physics); + } + + void Mob::step_body(const Physics &physics) + { + if (!canfly) + { + if (body.is_flag_not_set(PhysicsObject::Flag::TURNATEDGES)) { - // At a loose end or a wall (FUN_007f8d64), a jumper jumps unless - // it is chasing a target below it, and then only half the time. - // Toward a target on its own group it walks down a step instead. - const FootholdTree &fht = physics.get_fht(); - bool chasing = control && hastarget && stance != Stance::HIT; - - phobj.edgejump = !chasing || targetpos.y() <= phobj.get_y() || randomizer.next_bool(); - phobj.stepdown = !chasing || fht.get_group_below(targetpos) == fht.get_group(phobj.fhid); + // A knocked back mob stops at the edge, still facing its + // attacker. + if (bodystance != Stance::HIT) + bodyflip = !bodyflip; + + body.set_flag(PhysicsObject::Flag::TURNATEDGES); } + } - physics.move_object(phobj); + if (canfly) + { + body.walkdir = flyx; + body.flydir = flyy; + } - bool jumprequest = phobj.jumprequest; - phobj.jumprequest = false; + switch (bodystance) + { + case Stance::MOVE: + case Stance::CHASE: + if (!canfly) + body.walkdir = bodyflip ? 1 : -1; - if (control) - { - // v83 gives up a chase 3 to 4 minutes after aggro (FUN_007f7f02). - if (hastarget) + break; + case Stance::HIT: + if (pull) { - targettime -= Constants::TIMESTEP; + // Monster Magnet's pull (0x9bbdfd) slows each 30 ms + // step once the mob nears the user, and a flier rises + // or sinks toward them too. + Point position = body_position(); - if (targettime < 0) - hastarget = false; - } + pull->elapsed += Constants::TIMESTEP; - // A firstAttack mob that can attack (FUN_005ce8c6) starts - // chasing the local player once they come within - // |dx| / 10 + |dy| / 3 <= 40 of it. - if (firstattack && !notattack && !hastarget) + while (pull->elapsed >= magnet::STEP) + { + pull->elapsed -= magnet::STEP; + pull->speed = magnet::step_speed(pull->speed, position.x(), pull->user.x(), pull->reach); + } + + body.hspeed = pull->speed * Constants::TIMESTEP / 1000.0; + + if (canfly) + body.vspeed = magnet::fly_speed(position.y(), pull->user.y()) * Constants::TIMESTEP / 1000.0; + } else if (body.is_flag_set(PhysicsObject::Flag::NOFRICTION) && body.onground) { - Point position = get_position(); - int32_t dx = std::abs(targetpos.x() - position.x()); - int32_t dy = std::abs(targetpos.y() - position.y()); + // v83 slows the push by 40000 / mass (100) px/s^2 + // along the ground; walls and edges zero it. + constexpr double KBDECEL = 400.0 * Constants::TIMESTEP * Constants::TIMESTEP / 1000000.0; - if (dx / 10 + dy / 3 <= 40) - chase(); + double kbspeed = std::abs(body.hspeed) - KBDECEL; + + if (kbspeed > 0.0) + { + body.hspeed = body.hspeed < 0.0 ? -kbspeed : kbspeed; + } else + { + body.hspeed = 0.0; + body.clear_flag(PhysicsObject::Flag::NOFRICTION); + } } - // v83 (FUN_005cc410) picks a walking mob's action as it goes: - // its chase animation for as long as it has a target. - if (!canfly && (stance == Stance::MOVE || stance == Stance::CHASE)) - walk(input()); + break; + case Stance::JUMP: + // CVecCtrl::Jump (FUN_007f1bb0) speeds the mob up in the + // direction it holds, and it keeps steering in the air. + if (body.onground) + Physics::jump(body, Physics::jump_speed(1.0), jumpdir); + + body.walkdir = jumpdir; + + break; + } + + if (body.is_flag_set(PhysicsObject::Flag::JUMPER)) + { + // At a loose end or a wall (FUN_007f8d64), a jumper jumps unless + // it is chasing a target below it, and then only half the time. + // Toward a target on its own group it walks down a step instead. + const FootholdTree &fht = physics.get_fht(); + bool chasing = hastarget && bodystance != Stance::HIT; + + body.edgejump = !chasing || targetpos.y() <= body.get_y() || randomizer.next_bool(); + body.stepdown = !chasing || fht.get_group_below(targetpos) == fht.get_group(body.fhid); + } + + physics.move_object(body); + + bool jumprequest = body.jumprequest; + body.jumprequest = false; + + // v83 gives up a chase 3 to 4 minutes after aggro (FUN_007f7f02). + if (hastarget) + { + targettime -= Constants::TIMESTEP; + + if (targettime < 0) + hastarget = false; + } + + // v83 (FUN_005cc410) picks a walking mob's action as it goes: + // its chase animation for as long as it has a target. + if (!canfly && (bodystance == Stance::MOVE || bodystance == Stance::CHASE)) + walk(input()); + + switch (bodystance) + { + case Stance::HIT: + // v83 holds a hit mob still for its hit, then leaves it + // standing until its AI next decides. + hittime -= Constants::TIMESTEP; + + if (hittime <= 0) + set_body_stance(Stance::STAND); - switch (stance) + break; + case Stance::JUMP: + // It lands still holding the direction it jumped in, + // and its AI only counts down in the air. + if (body.onground) { - case Stance::HIT: - // v83 holds a hit mob still, then leaves it standing - // until its AI next decides. - if (aniend) - { - set_stance(Stance::STAND); - update_movement(); - } - - break; - case Stance::JUMP: - // It lands still holding the direction it jumped in, - // and its AI only counts down in the air. - if (phobj.onground) - { - walk(jumpdir); - } else - { - for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) - thinktimer--; - } - - break; - default: - // The mob vecctrl steps its AI in 90 ms steps (FUN_007f72d9). - for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) - { - if (canfly) - think_fly(physics); - else - think(physics); - } - - break; + walk(jumpdir); + } else + { + for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) + thinktimer--; } - // A jumper that stopped at the end of its foothold jumps on the - // next tick (FUN_007f1fec). - if (jumprequest && (stance == Stance::MOVE || stance == Stance::CHASE || stance == Stance::STAND)) - jump(input(), false); - } - } else - { - phobj.normalize(); - physics.get_fht().update_fh(phobj); + break; + default: + // The mob vecctrl steps its AI in 90 ms steps (FUN_007f72d9). + for (aitime += Constants::TIMESTEP; aitime >= 90; aitime -= 90) + { + if (canfly) + think_fly(physics); + else + think(physics); + } - if (death_timeout > 0 && --death_timeout == 0 && stance != Stance::DIE) - apply_death(); + break; } - return phobj.fhlayer; + // A jumper that stopped at the end of its foothold jumps on the + // next tick (FUN_007f1fec). + if (jumprequest && + (bodystance == Stance::MOVE || bodystance == Stance::CHASE || bodystance == Stance::STAND)) + jump(input(), false); + + // Footholds are recorded only while on them (FUN_009b108a). + int16_t fh = body.onground ? static_cast(body.fhid) : 0; + + recorder.step(Movement(Movement::ABSOLUTE, 0, body.get_x(), body.get_y(), Movement::velocity(body.hspeed), + Movement::velocity(body.vspeed), fh, value_of(bodystance, bodyflip), 0), + Constants::TIMESTEP); } void Mob::think_fly(const Physics &physics) @@ -477,8 +625,8 @@ namespace ms // it is higher. if (haswaypoint) { - double x = phobj.crnt_x(); - double y = phobj.crnt_y(); + double x = body.crnt_x(); + double y = body.crnt_y(); int8_t xdir = 0; int8_t ydir = 0; @@ -488,7 +636,7 @@ namespace ms if (std::abs(static_cast(y - waypoint.y())) < 10) ydir = -1; else if (waypoint.y() < y) - Physics::fly_jump(phobj); + Physics::fly_jump(body); fly(xdir, ydir); @@ -506,15 +654,8 @@ namespace ms haswaypoint = false; } - // It tells the server where it is at each new waypoint, and at - // least once a second. if (!haswaypoint) pick_waypoint(physics); - else if (++flysteps < 11) - return; - - update_movement(); - flysteps = 0; } void Mob::pick_waypoint(const Physics &physics) @@ -576,17 +717,17 @@ namespace ms if (x == 0) { - set_stance(Stance::STAND); + set_body_stance(Stance::STAND); } else { - set_stance(Stance::MOVE); - flip = x > 0; + set_body_stance(Stance::MOVE); + bodyflip = x > 0; } } void Mob::think(const Physics &physics) { - if (phobj.is_flag_set(PhysicsObject::Flag::JUMPER) && think_jump(physics)) + if (body.is_flag_set(PhysicsObject::Flag::JUMPER) && think_jump(physics)) return; // FUN_007f8025 (standing mobs), FUN_007f81c8 (walking) and @@ -598,7 +739,7 @@ namespace ms return; } - Point position = get_position(); + Point position = body_position(); int16_t dx = targetpos.x() - position.x(); if (!canmove) @@ -607,11 +748,7 @@ namespace ms { thinktimer = (randomizer.next_int(400) + 800) / 90; - if (flip != (dx >= 0)) - { - flip = dx >= 0; - update_movement(); - } + bodyflip = dx >= 0; // It forgets a target that leaves the 2000 x 1200 box around it. int16_t dy = targetpos.y() - position.y(); @@ -629,7 +766,7 @@ namespace ms movedir = dx >= 0 ? 1 : -1; walk(movedir); - } else if (canjump && phobj.onground && randomizer.next_int(5) == 0) + } else if (canjump && body.onground && randomizer.next_int(5) == 0) { // A fifth of a jumping mob's idle decisions are jumps, after // which it decides again. @@ -648,7 +785,7 @@ namespace ms bool Mob::think_jump(const Physics &physics) { // FUN_007f835e only looks around on the ground. - if (!phobj.onground) + if (!body.onground) { thinktimer--; return true; @@ -662,7 +799,7 @@ namespace ms { checkjump = false; - if (dir != 0 && phobj.get_y() == jumpy) + if (dir != 0 && body.get_y() == jumpy) { dir = -dir; walk(dir); @@ -673,7 +810,7 @@ namespace ms // lasts, or turns back once pressed against it. const FootholdTree &fht = physics.get_fht(); - switch (fht.get_wall_ahead(phobj, dir)) + switch (fht.get_wall_ahead(body, dir)) { case FootholdTree::WallAhead::AGAINST: dir = -dir; @@ -698,9 +835,9 @@ namespace ms { uint16_t group = fht.get_group_below(targetpos); Range reach = fht.get_group_reach(group); - int16_t x = phobj.get_x(); + int16_t x = body.get_x(); - if (group != fht.get_group(phobj.fhid) && targetpos.y() < phobj.get_y() && + if (group != fht.get_group(body.fhid) && targetpos.y() < body.get_y() && x > reach.first() && x < reach.second() && randomizer.next_int(3) == 0) { movedir = 0; @@ -717,18 +854,17 @@ namespace ms { jumpdir = dir; checkjump = check; - jumpy = phobj.get_y(); - set_stance(Stance::JUMP); - update_movement(); + jumpy = body.get_y(); + set_body_stance(Stance::JUMP); } int8_t Mob::input() const { - switch (stance) + switch (bodystance) { case Stance::MOVE: case Stance::CHASE: - return flip ? 1 : -1; + return bodyflip ? 1 : -1; case Stance::JUMP: return jumpdir; default: @@ -738,45 +874,14 @@ namespace ms void Mob::walk(int8_t direction) { - Stance before = stance; - bool faced = flip; - if (direction == 0) { - set_stance(Stance::STAND); + set_body_stance(Stance::STAND); } else { - set_stance(hastarget && canchase ? Stance::CHASE : Stance::MOVE); - flip = direction > 0; + set_body_stance(hastarget && canchase ? Stance::CHASE : Stance::MOVE); + bodyflip = direction > 0; } - - if (stance != before || flip != faced) - update_movement(); - } - - void Mob::update_movement() - { - // The controller sends nothing new until its last move is acked, - // then its latest (0x6685ac). - if (control && ctrlstate == 2) - { - movepending = true; - return; - } - - movepending = false; - - // It asks to attack when chasing, unless controlling, then 70% of - // the time. - bool wantattack = hastarget && (ctrlstate < 0 || randomizer.next_int(100) < 70); - int8_t flags = (ctrlstate < 0 ? 0x10 : 0) | (wantattack ? 1 : 0); - - MoveMobPacket( - oid, ++moveid, flags, -1, false, 0, canfly, get_position(), - Movement(phobj, value_of(stance, flip)) - ).dispatch(); - - ctrlstate = ctrlstate == -2 || ctrlstate == 1 ? 1 : 2; } void Mob::acknowledge(int16_t ackid, bool nextattack) @@ -788,9 +893,6 @@ namespace ms ctrlstate = 2; else ctrlstate = nextattack ? 4 : 3; - - if (ctrlstate != 2 && movepending) - update_movement(); } void Mob::draw(double viewx, double viewy, float alpha) const @@ -863,9 +965,12 @@ namespace ms { // Taking control lets it move at once; it may attack only once a // move is acked. Letting go remembers whether it could have. + // The AI starts where the mob is shown going (FUN_006637ec). if (mode > 0 && !control) + { ctrlstate = 3; - else if (mode == 0 && control) + seated = false; + } else if (mode == 0 && control) ctrlstate = ctrlstate == 3 ? -2 : ctrlstate == 4 ? -3 : -1; control = mode > 0; @@ -878,6 +983,7 @@ namespace ms } else if (mode == 0) { hastarget = false; + flinch.reset(); update_shoe(false); } } @@ -907,10 +1013,10 @@ namespace ms float base = canfly ? flyspeed : speed; - phobj.walkspeed = canmove ? shoespeed(chasing && canchase ? chasespeed : base) : 0.0; + body.walkspeed = canmove ? shoespeed(chasing && canchase ? chasespeed : base) : 0.0; if (canfly) - phobj.flyspeed = shoespeed(base); + body.flyspeed = shoespeed(base); } void Mob::set_target(Point position, bool dead) @@ -919,26 +1025,14 @@ namespace ms targetdead = dead; } - void Mob::send_movement(Point start, std::vector &&in_movements) + void Mob::send_movement(Point, std::vector &&in_movements) { + // v83 plays the path on from where the mob is shown; its start is + // not used (FUN_0068b371). if (control) return; - set_position(start); - - movements = std::forward(in_movements); - - if (movements.empty()) - return; - - const Movement &lastmove = movements.front(); - - uint8_t laststance = lastmove.newstate; - set_stance(laststance); - update_shoe(stance == Stance::CHASE); - jumpdir = 0; - - phobj.fhid = lastmove.fh; + shown.receive(in_movements); } Point Mob::get_head_position(Point position) const @@ -1108,8 +1202,6 @@ namespace ms for (auto &hit : result) hit = {next_magic_damage(attack), false}; - update_movement(); - return result; } @@ -1138,8 +1230,6 @@ namespace ms } ); - update_movement(); - return result; } @@ -1205,50 +1295,24 @@ namespace ms { if (pending_hits == 0) apply_death(); - } else if (control && is_alive() && damage >= knockback && stance != Stance::HIT) + } else if (control && is_alive() && damage >= knockback && !acting) { - flip = toleft; - set_stance(Stance::HIT); - - // It stops steering while hit, so a flier dives (FUN_007f7f02). - flyx = 0; - flyy = 0; - - // v83 pushes a flinching mob away from its attacker at 130 px/s. - if (canmove) - { - constexpr double KBSPEED = 130.0 * Constants::TIMESTEP / 1000.0; - - phobj.hspeed = flip ? -KBSPEED : KBSPEED; - phobj.set_flag(PhysicsObject::Flag::NOFRICTION); - } - - update_movement(); + // It shows the hit at once; the AI flinches next update. + show_stance(Stance::HIT, toleft); + acting = true; + flinch = Flinch{ toleft, std::nullopt }; } } void Mob::catch_by(Point user) { - if (!control || !is_alive() || stance == Stance::HIT || !canmove) + if (!control || !is_alive() || acting || !canmove) return; - Point position = get_position(); - // It turns to the user and flinches (0x66b6fc). - flip = user.x() > position.x(); - set_stance(Stance::HIT); - - flyx = 0; - flyy = 0; - - int16_t reach = standwidth + magnet::REACH; - - pull = Pull{ user, reach, magnet::start_speed(position.x(), user.x(), reach), 0 }; - phobj.set_flag(PhysicsObject::Flag::NOFRICTION); - - // Then it chases them (0x66b562). - chase(); - update_movement(); + show_stance(Stance::HIT, user.x() > get_position().x()); + acting = true; + flinch = Flinch{ false, user }; } MobAttack Mob::create_touch_attack() const @@ -1265,7 +1329,7 @@ namespace ms void Mob::apply_death() { - set_stance(Stance::DIE); + show_stance(Stance::DIE, flip); diesound.play(); dying = true; } diff --git a/HeavenClient/Gameplay/MapleMap/Mob.h b/HeavenClient/Gameplay/MapleMap/Mob.h index 0d53d8054..c5ada86e3 100644 --- a/HeavenClient/Gameplay/MapleMap/Mob.h +++ b/HeavenClient/Gameplay/MapleMap/Mob.h @@ -27,6 +27,7 @@ #include "MapObject.h" #include "../Movement.h" +#include "../MovePath.h" #include "../Combat/Attack.h" #include "../Combat/Bullet.h" @@ -96,7 +97,7 @@ namespace ms // the mob may attack or cast next. void acknowledge(int16_t moveid, bool nextattack); - // Send movement to the mob. + // Queue the controller's path to play (0xEF). void send_movement(Point start, std::vector &&movements); // Kill the mob with the appropriate type: @@ -162,11 +163,34 @@ namespace ms Rectangle get_body() const; private: - // Set the stance by byte value - void set_stance(uint8_t stancebyte); + // The stance and facing a stance byte stands for. + Stance stance_of(uint8_t stancebyte, bool &left) const; - // Set the stance by enum value. - void set_stance(Stance newstance); + // Show the stance a path gives. + void show_stance(uint8_t stancebyte); + + // Show a stance, facing right if `right`. + void show_stance(Stance newstance, bool right); + + // Set the stance the AI moves in. + void set_body_stance(Stance newstance); + + // Where the AI has the mob. + Point body_position() const; + + // Start the AI where the shown paths end (FUN_009bbd3d). + void seat_body(const Physics &physics); + + // Step the AI ahead until it has a path to send, then send it and + // play it (FUN_0066b6fc). + void plan(const Physics &physics); + + // One step of the AI and its physics, recorded into the path. + void step_body(const Physics &physics); + + // Stop what is shown at the next foothold, flinch from there, and + // send the path at once. + void flinch_body(const Physics &physics); // Start the death animation. void apply_death(); @@ -200,13 +224,9 @@ namespace ms // Which way the mob is pushing: -1 left, 0 not at all, 1 right. int8_t input() const; - // Stand (0) or walk left (-1) or right (1), telling the server if - // that changes anything. + // Stand (0) or walk left (-1) or right (1). void walk(int8_t direction); - // Send the current position and state to the server. - void update_movement(); - // Calculate the hit chance float calculate_hitchance(int16_t leveldelta, int32_t accuracy) const; @@ -278,7 +298,6 @@ namespace ms TimedBool showhp; - std::vector movements; // Hits queued against this mob that haven't landed yet. uint16_t pending_hits; // Ticks before a killed mob dies even if its pending hits never land. @@ -297,8 +316,31 @@ namespace ms int8_t ctrlstate = -1; // The id of the last move sent, one more each move. int16_t moveid = 0; - // Whether a move came up while awaiting an ack, to be sent with it. - bool movepending = false; + // What the mob shows (phobj, stance, flip) plays its paths, as v83's + // displayed vecctrl (CMob +0x118) does. The controller runs the AI + // on its own body ahead of that (+0x11c) and records the path it + // sends next. + move_path::Player shown; + PhysicsObject body; + Stance bodystance; + bool bodyflip; + move_path::Recorder recorder; + // Whether the body starts over where the shown paths end. + bool seated = false; + // Whether a hit shows over the path until its animation ends. + bool acting = false; + // Ms more a hit mob holds still. + int16_t hittime = 0; + + // A flinch for the AI to take next update: away from its attacker, + // or toward the user Monster Magnet caught it for. + struct Flinch + { + bool left; + std::optional> user; + }; + + std::optional flinch; // Whether the mob chases the local player, and for how many more ms. bool hastarget; int32_t targettime; @@ -327,8 +369,6 @@ namespace ms int32_t flycount; int8_t flyx; int8_t flyy; - // AI steps since the flier last told the server where it is. - uint16_t flysteps; float walkforce; int8_t hppercent; bool fading; diff --git a/HeavenClient/Gameplay/MovePath.h b/HeavenClient/Gameplay/MovePath.h index 02aaa58e8..351382e92 100644 --- a/HeavenClient/Gameplay/MovePath.h +++ b/HeavenClient/Gameplay/MovePath.h @@ -31,6 +31,9 @@ namespace ms { Point start; std::vector elements; + // Whether it ends as the last path did, with no type 10 element: + // the user sends no such path. + bool idle; }; // The user's movement since the last path went out. @@ -73,21 +76,31 @@ namespace ms append(element); } - // The path to send, once there is a second of movement with - // something on a foothold in it. What comes after the last - // element on a foothold waits for the next path, and a user who - // has stayed put sends nothing. - std::optional flush() + // Whether a path should go out: a second of movement with + // something on a foothold in it, or with anything for a flier + // (FUN_0068a828). + bool ready(bool flying = false) const { - bool ready = !elements.empty() && recorded >= SEND_MS && - std::any_of(elements.begin(), elements.end(), grounded); + return !elements.empty() && recorded >= SEND_MS && + (flying || std::any_of(elements.begin(), elements.end(), grounded)); + } - if (!ready) - return std::nullopt; + // The path to send. What comes after the last element on a + // foothold waits for the next path, except for a flier, or when + // nothing is on one. + Path flush(bool flying = false) + { + auto held = elements.end(); - auto held = std::find_if(elements.rbegin(), elements.rend(), grounded).base(); + if (!flying) + { + held = std::find_if(elements.rbegin(), elements.rend(), grounded).base(); - Path path{ start, std::vector(elements.begin(), held) }; + if (held == elements.begin()) + held = elements.end(); + } + + Path path{ start, std::vector(elements.begin(), held), false }; elements.erase(elements.begin(), held); recorded = 0; @@ -96,7 +109,7 @@ namespace ms recorded += element.duration; const Movement &tail = path.elements.back(); - bool idle = tail.command == 0 && !last.hasmoved(tail) && tail.fh == last.fh && + path.idle = tail.command == 0 && !last.hasmoved(tail) && tail.fh == last.fh && std::none_of(path.elements.begin(), path.elements.end(), [](const Movement &element) { return element.command == 10; @@ -105,9 +118,6 @@ namespace ms last = tail; start = { tail.xpos, tail.ypos }; - if (idle) - return std::nullopt; - return path; } @@ -243,6 +253,41 @@ namespace ms state.stance = head.newstate; } + // Stop at the first element on a foothold from the one playing + // now, dropping the rest. A flier, or one with none ahead, + // stops where it is (FUN_0068b16f). + void land(bool flying) + { + auto stop = flying ? queue.end() : std::find_if(queue.begin(), queue.end(), grounded); + + if (stop == queue.end()) + { + queue.clear(); + from = state; + elapsed = 0; + queued = 0; + + return; + } + + for (auto element = stop + 1; element != queue.end(); ++element) + queued -= element->duration * TICK; + + queue.erase(stop + 1, queue.end()); + } + + // Where the queued paths end, or the state with none. + State end() const + { + if (queue.empty()) + return state; + + const Movement &tail = queue.back(); + + return { static_cast(tail.xpos), static_cast(tail.ypos), static_cast(tail.vx), + static_cast(tail.vy), tail.fh, tail.newstate }; + } + const State &get_state() const { return state; diff --git a/HeavenClient/Graphics/Animation.cpp b/HeavenClient/Graphics/Animation.cpp index 9e1e8938a..aadbf6ffd 100644 --- a/HeavenClient/Graphics/Animation.cpp +++ b/HeavenClient/Graphics/Animation.cpp @@ -303,12 +303,17 @@ namespace ms if (i >= frames.size()) break; - total += frames[frame_id].get_delay(); + total += frames[i].get_delay(); } return total; } + uint16_t Animation::get_duration() const + { + return getdelayuntil(static_cast(frames.size())); + } + Point Animation::get_origin() const { return get_frame().get_origin(); diff --git a/HeavenClient/Graphics/Animation.h b/HeavenClient/Graphics/Animation.h index ac5831890..e35cc4e21 100644 --- a/HeavenClient/Graphics/Animation.h +++ b/HeavenClient/Graphics/Animation.h @@ -91,6 +91,9 @@ namespace ms uint16_t getdelayuntil(int16_t frame) const; + // How long one play through every frame takes. + uint16_t get_duration() const; + Point get_origin() const; Point get_dimensions() const; diff --git a/HeavenClient/Net/Packets/GameplayPackets.h b/HeavenClient/Net/Packets/GameplayPackets.h index 87988e337..a958ce4be 100644 --- a/HeavenClient/Net/Packets/GameplayPackets.h +++ b/HeavenClient/Net/Packets/GameplayPackets.h @@ -418,8 +418,8 @@ namespace ms // Updates a mob's position with the server, as v83 does // (FUN_0066b6fc): its move id, flags, its action (-1 for none) and // facing, skill data, an anti-cheat byte and reason, and two ints a - // flyer fills in but others leave at 0xFFDDCC. - MoveMobPacket(int32_t oid, int16_t moveid, int8_t flags, int8_t action, bool left, int32_t skilldata, bool flying, Point startpos, const Movement& movement) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) + // flyer fills in but others leave at 0xFFDDCC, then the path. + MoveMobPacket(int32_t oid, int16_t moveid, int8_t flags, int8_t action, bool left, int32_t skilldata, bool flying, const move_path::Path& path) : MovementPacket(OutPacket::Opcode::MOVE_MONSTER) { write_int(oid); write_short(moveid); @@ -433,10 +433,7 @@ namespace ms write_int(unknown); write_int(unknown); - write_point(startpos); - - write_byte(1); - writemovement(movement); + writepath(path.start, path.elements); } }; diff --git a/HeavenClient/tests/movement_path.cpp b/HeavenClient/tests/movement_path.cpp index 947475312..d9881b47c 100644 --- a/HeavenClient/tests/movement_path.cpp +++ b/HeavenClient/tests/movement_path.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,20 @@ int16_t walk(ms::move_path::Recorder& path, int16_t x, int16_t vx, int16_t fh, i return x; } +// What the user sends after a step. +std::optional send(ms::move_path::Recorder& path) +{ + if (!path.ready()) + return std::nullopt; + + ms::move_path::Path sent = path.flush(); + + if (sent.idle) + return std::nullopt; + + return sent; +} + void recorder() { using namespace ms::move_path; @@ -36,33 +51,33 @@ void recorder() path.reset(at(0, 0, 0, 0, 7)); int16_t x = walk(path, 0, 125, 7, 124); - require(!path.flush(), "nothing goes out before a second"); + require(!send(path), "nothing goes out before a second"); x = walk(path, x, 125, 7, 1); - auto sent = path.flush(); + auto sent = send(path); require(sent && sent->start == ms::Point(0, 0), "a second of walking goes out from where it began"); require(sent->elements.size() == 1 && sent->elements[0].duration == 1000 && sent->elements[0].xpos == x, "a walk in one direction is one element"); walk(path, x, 0, 7, 125); - sent = path.flush(); + sent = send(path); require(sent && sent->start == ms::Point(x, 0) && sent->elements.size() == 1, "the next path starts where the last one ended"); walk(path, x, 0, 7, 125); - require(!path.flush(), "standing still sends nothing"); + require(!send(path), "standing still sends nothing"); x = walk(path, x, 125, 7, 60); x = walk(path, x, -125, 7, 10); x = walk(path, x, 125, 0, 40, -500); x = walk(path, x, 125, 0, 40, 500); - sent = path.flush(); + sent = send(path); require(sent && sent->elements.size() == 2 && sent->elements[1].vx < 0, "turning around starts an element, and a jump waits"); - require(!path.flush(), "the jump stays for the next path"); + require(!send(path), "the jump stays for the next path"); int16_t before = sent->elements[1].xpos; x = walk(path, x, 125, 7, 45); - sent = path.flush(); + sent = send(path); require(sent && sent->start == ms::Point(before, 0) && sent->elements.size() == 3, "the jump goes out once the player lands"); require(sent->elements[0].fh == 0 && sent->elements[0].vy < 0 && sent->elements[1].vy > 0, @@ -70,13 +85,59 @@ void recorder() path.add(ms::Movement(ms::Movement::ABSOLUTE, 4, x, 0, 0, 0, 7, 2, 5)); walk(path, 200, 0, 7, 125); - sent = path.flush(); + sent = send(path); require(sent && sent->elements.size() == 2 && sent->elements[0].command == 4 && sent->elements[0].duration == 0 && sent->elements[0].xpos == x && sent->elements[1].xpos == 200, "a teleport marks where it left from"); walk(path, 200, 0, -3, 200); - require(!path.flush(), "a climb waits for a foothold"); + require(!send(path), "a climb waits for a foothold"); +} + +void mob() +{ + using namespace ms::move_path; + + Recorder path; + path.reset(at(0, 0, 0, 0, 7)); + + // A walker in the air the whole time sends it all. + walk(path, 0, 125, 0, 125, 500); + require(path.ready(true) && !path.ready(), "a walker waits for a foothold, a flier doesn't"); + Path sent = path.flush(); + require(sent.elements.size() == 1 && !path.ready(true), "with nothing on a foothold everything goes"); + + // A flier sends what is in the air after its last foothold. + int16_t x = walk(path, 0, 125, 7, 60); + walk(path, x, 125, 0, 65); + sent = path.flush(true); + require(sent.elements.size() == 2 && sent.elements[1].fh == 0, "a flier holds nothing back"); + + // Standing still still goes out, marked idle. + walk(path, 0, 0, 7, 125); + path.flush(); + walk(path, 0, 0, 7, 125); + require(path.ready() && path.flush().idle, "a path the same as the last is idle"); + + Player shown({ 0, 0, 0, 0, 7, 2 }); + ms::Movement rise = at(100, -50, 100, -500, 0, 6); + rise.duration = 500; + ms::Movement land = at(150, 0, 100, 0, 7, 2); + land.duration = 500; + ms::Movement on = at(250, 0, 100, 0, 7, 2); + on.duration = 1000; + shown.receive({ rise, land, on }); + shown.step(100); + shown.land(false); + require(shown.end().x == 150 && shown.end().fh == 7, "landing stops at the first foothold ahead"); + shown.step(900); + require(!shown.playing() && shown.get_state().x == 150, "and plays only up to it"); + + shown.receive({ rise }); + shown.step(100); + ms::move_path::Player::State now = shown.get_state(); + shown.land(false); + require(!shown.playing() && shown.end().x == now.x, "with no foothold ahead it stops where it is"); } void player() @@ -180,6 +241,7 @@ int main() recorder(); player(); + mob(); std::cout << "Movement path passed\n"; return 0; From 7b7fc7be44166c86dd2f7fbe20d913dfbb904090 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Sat, 17 Oct 2026 02:09:27 -0400 Subject: [PATCH 5/5] NPC idle actions go through the server: control, NPC_ACTION, remove (L162) The server gives one player per field control of its NPCs (0x103, handed on when they leave) and relays their 0xC5 choices to the whole map as 0x104. HC chooses only for NPCs it controls, waits for the echo, and every client plays what comes back, as v83 does (FUN_006d2918, 006d2e07). HC also handles REMOVE_NPC and control release (mode 0 no longer removes). --- HeavenClient/Gameplay/MapleMap/MapNpcs.cpp | 19 ++++ HeavenClient/Gameplay/MapleMap/MapNpcs.h | 7 ++ HeavenClient/Gameplay/MapleMap/Npc.cpp | 61 ++++++++--- HeavenClient/Gameplay/MapleMap/Npc.h | 10 ++ HeavenClient/Gameplay/MapleMap/NpcIdle.h | 4 +- .../Net/Handlers/MapObjectHandlers.cpp | 19 +++- HeavenClient/Net/Handlers/MapObjectHandlers.h | 17 ++- HeavenClient/Net/OutPacket.h | 1 + HeavenClient/Net/PacketSwitch.cpp | 4 + .../Net/Packets/NpcInteractionPackets.h | 15 +++ net/src/handler.rs | 6 +- net/src/packet/build/mod.rs | 4 +- net/src/packet/build/world/npc.rs | 49 ++++++++- net/src/packet/handle/world/combat.rs | 23 ++++ net/src/packet/handle/world/mod.rs | 4 +- net/src/packet/op/recv.rs | 1 + net/src/packet/op/send.rs | 2 + runtime/src/actor/field.rs | 103 +++++++++++++++++- 18 files changed, 326 insertions(+), 23 deletions(-) diff --git a/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp b/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp index 1c1ab6cec..2255c4de9 100644 --- a/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp +++ b/HeavenClient/Gameplay/MapleMap/MapNpcs.cpp @@ -45,6 +45,14 @@ namespace ms npcs.add(spawn.instantiate(physics)); } + for (; !controls.empty(); controls.pop()) + { + auto [oid, control] = controls.front(); + + if (auto npc = npcs.get(oid)) + static_cast(npc.get())->set_control(control); + } + npcs.update(physics); } @@ -59,6 +67,17 @@ namespace ms npc->deactivate(); } + void MapNpcs::control(int32_t oid, bool control) + { + controls.emplace(oid, control); + } + + void MapNpcs::act(int32_t oid, int8_t action, int8_t line) + { + if (auto npc = npcs.get(oid)) + static_cast(npc.get())->act(action, line); + } + void MapNpcs::clear() { npcs.clear(); diff --git a/HeavenClient/Gameplay/MapleMap/MapNpcs.h b/HeavenClient/Gameplay/MapleMap/MapNpcs.h index d5f2fb901..d89b8629e 100644 --- a/HeavenClient/Gameplay/MapleMap/MapNpcs.h +++ b/HeavenClient/Gameplay/MapleMap/MapNpcs.h @@ -46,6 +46,12 @@ namespace ms // Remove the npc with the specified oid; void remove(int32_t oid); + // Take control of an NPC, or give it up, once it has spawned. + void control(int32_t oid, bool control); + + // Play the action the NPC's controller chose. + void act(int32_t oid, int8_t action, int8_t line); + // Remove all NPCs. void clear(); @@ -62,5 +68,6 @@ namespace ms MapObjects npcs; std::queue spawns; + std::queue> controls; }; } \ No newline at end of file diff --git a/HeavenClient/Gameplay/MapleMap/Npc.cpp b/HeavenClient/Gameplay/MapleMap/Npc.cpp index 2367c395c..f2dc0ab9c 100644 --- a/HeavenClient/Gameplay/MapleMap/Npc.cpp +++ b/HeavenClient/Gameplay/MapleMap/Npc.cpp @@ -19,6 +19,7 @@ #include "NpcIdle.h" #include "../Stage.h" #include "../../Constants.h" +#include "../../Net/Packets/NpcInteractionPackets.h" #include "../../Util/NxFiles.h" #include @@ -81,8 +82,6 @@ namespace ms idle_lines = lines["info"]; speech = ChatBalloon("npc", true); - // v83 starts the wait when the client takes control of the NPC. - wait_idle(); name = std::string(strsrc["name"]); func = std::string(strsrc["func"]); @@ -94,8 +93,8 @@ namespace ms npcid = id; flip = !fl; - control = cnt; stance = "stand"; + set_control(cnt); phobj.fhid = f; set_position(position); @@ -233,13 +232,53 @@ namespace ms return Point(20, -head_height() - 15); } + void Npc::set_control(bool cnt) + { + control = cnt; + + // The first choice also waits. + if (control) + { + wait_idle(); + pending = false; + } + } + + void Npc::act(int8_t action, int8_t line) + { + pending = false; + + if (action == -1) + { + if (line >= 0 && line < static_cast(idle_lines.size())) + speech.change_text(idle_lines[line]); + + return; + } + + int32_t index = action - 2; + + if (index < 0 || index >= static_cast(actions.size())) + return; + + const std::string &name = actions[index]; + set_stance(name); + + if (line >= 0 && line < static_cast(lines[name].size())) + speech.change_text(lines[name][line]); + } + void Npc::update_idle() { + // Only the controller chooses, and everyone plays what comes back. + if (!control) + return; + if (idle_cooldown > 0) idle_cooldown -= Constants::TIMESTEP; // An action plays out before the next choice. - if (idle_cooldown > 0 || stance != "stand") + if (pending || idle_cooldown > 0 || stance != "stand") return; wait_idle(); @@ -252,17 +291,9 @@ namespace ms choice)) return; - if (choice.action < 0) - { - speech.change_text(idle_lines[choice.line]); - return; - } - - const std::string& action = actions[choice.action]; - set_stance(action); - - if (choice.line >= 0) - speech.change_text(lines[action][choice.line]); + int8_t action = choice.action < 0 ? -1 : static_cast(choice.action + 2); + NpcActionPacket(oid, action, static_cast(choice.line)).dispatch(); + pending = true; } void Npc::wait_idle() diff --git a/HeavenClient/Gameplay/MapleMap/Npc.h b/HeavenClient/Gameplay/MapleMap/Npc.h index c65dd351b..aa2a4c4f9 100644 --- a/HeavenClient/Gameplay/MapleMap/Npc.h +++ b/HeavenClient/Gameplay/MapleMap/Npc.h @@ -49,6 +49,14 @@ namespace ms // Changes stance and resets animation. void set_stance(const std::string &stance); + // Pick idle actions for everyone, or stop (FUN_006d2024). + void set_control(bool control); + + // Play an idle action, which the controller chose (FUN_006d2e07): + // `action` is an index into the actions from 2, or -1 for `line` + // of the info/speak lines alone; `line` is -1 for none. + void act(int8_t action, int8_t line); + // Check whether this is a server-sided NPC bool isscripted() const; @@ -98,6 +106,8 @@ namespace ms bool flip; std::string stance; bool control; + // The controller waits for its last choice to come back. + bool pending = false; Randomizer random; Text namelabel; diff --git a/HeavenClient/Gameplay/MapleMap/NpcIdle.h b/HeavenClient/Gameplay/MapleMap/NpcIdle.h index 487e2d681..8aee60527 100644 --- a/HeavenClient/Gameplay/MapleMap/NpcIdle.h +++ b/HeavenClient/Gameplay/MapleMap/NpcIdle.h @@ -9,7 +9,9 @@ namespace ms // FUN_005eaf3e and its chooser FUN_005eb2b4). NPCs never walk. Every 3 to // 9 s, an NPC that is standing either plays one of its actions once, // perhaps saying one of that action's lines, or says one of its - // info/speak lines. + // info/speak lines. Only the NPC's controller chooses, and everyone plays + // the choice when the server sends it back (MapleRoyals: FUN_006d2918, + // 006d2c8c, 006d3273, 006d2e07). namespace npc_idle { // The pause before the next choice is PAUSE_MIN + rand() % PAUSE_SPREAD ms. diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp index 675c2aa97..c9f950bc6 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.cpp +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.cpp @@ -393,6 +393,11 @@ namespace ms ); } + void RemoveNpcHandler::handle(InPacket& recv) const + { + Stage::get().get_npcs().remove(recv.read_int()); + } + void SpawnNpcControllerHandler::handle(InPacket& recv) const { int8_t mode = recv.read_byte(); @@ -400,7 +405,7 @@ namespace ms if (mode == 0) { - Stage::get().get_npcs().remove(oid); + Stage::get().get_npcs().control(oid, false); } else { @@ -413,12 +418,24 @@ namespace ms recv.read_short(); // 'rx1' recv.read_bool(); // 'minimap' + // Spawned already, the NPC only changes hands. Stage::get().get_npcs().spawn( { oid, id, position, flip, fh } ); + Stage::get().get_npcs().control(oid, true); } } + void NpcActionHandler::handle(InPacket& recv) const + { + int32_t oid = recv.read_int(); + int8_t action = recv.read_byte(); + int8_t line = recv.read_byte(); + + // A path follows for an NPC that walks; none does in v83. + Stage::get().get_npcs().act(oid, action, line); + } + void DropLootHandler::handle(InPacket& recv) const { int8_t mode = recv.read_byte(); diff --git a/HeavenClient/Net/Handlers/MapObjectHandlers.h b/HeavenClient/Net/Handlers/MapObjectHandlers.h index 1a9f0c65d..baae31e06 100644 --- a/HeavenClient/Net/Handlers/MapObjectHandlers.h +++ b/HeavenClient/Net/Handlers/MapObjectHandlers.h @@ -142,13 +142,28 @@ namespace ms void handle(InPacket& recv) const override; }; - // Spawn an NPC on the current stage and take control of it + // Remove an NPC from the stage (FUN_006d9a25) + // Opcode: REMOVE_NPC(258) + class RemoveNpcHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + + // Take control of an NPC, spawning it if need be, or give it up + // (FUN_006d9a83). The controller picks the NPC's idle actions. // Opcode: SPAWN_NPC_C(259) class SpawnNpcControllerHandler : public PacketHandler { void handle(InPacket& recv) const override; }; + // Play the idle action the NPC's controller chose (FUN_006d2e07) + // Opcode: NPC_ACTION(260) + class NpcActionHandler : public PacketHandler + { + void handle(InPacket& recv) const override; + }; + // Drop an item on the stage // Opcode: DROP_LOOT(268) class DropLootHandler : public PacketHandler diff --git a/HeavenClient/Net/OutPacket.h b/HeavenClient/Net/OutPacket.h index d68075f33..9ef70b2b5 100644 --- a/HeavenClient/Net/OutPacket.h +++ b/HeavenClient/Net/OutPacket.h @@ -119,6 +119,7 @@ namespace ms BUDDYLIST_MODIFY = 130, MOVE_MONSTER = 188, MOB_DISTANCE = 189, + NPC_ACTION = 197, PICKUP_ITEM = 202, DAMAGE_REACTOR = 205, PLAYER_MAP_TRANSFER = 207, diff --git a/HeavenClient/Net/PacketSwitch.cpp b/HeavenClient/Net/PacketSwitch.cpp index d5f7a43a3..90e40ea80 100644 --- a/HeavenClient/Net/PacketSwitch.cpp +++ b/HeavenClient/Net/PacketSwitch.cpp @@ -132,7 +132,9 @@ namespace ms SHOW_MOB_HP = 250, SHOW_MAGNET = 251, SPAWN_NPC = 257, + REMOVE_NPC = 258, SPAWN_NPC_C = 259, + NPC_ACTION = 260, DROP_LOOT = 268, REMOVE_LOOT = 269, HIT_REACTOR = 277, @@ -191,7 +193,9 @@ namespace ms emplace(); emplace(); emplace(); + emplace(); emplace(); + emplace(); emplace(); emplace(); emplace(); diff --git a/HeavenClient/Net/Packets/NpcInteractionPackets.h b/HeavenClient/Net/Packets/NpcInteractionPackets.h index 816c78f60..ae849aff8 100644 --- a/HeavenClient/Net/Packets/NpcInteractionPackets.h +++ b/HeavenClient/Net/Packets/NpcInteractionPackets.h @@ -36,6 +36,21 @@ namespace ms } }; + // The idle action the NPCs' controller chose for one (FUN_006d3273): the + // action as an animation index from 2, or -1 for a line alone, then the + // line, -1 for none. The server shows it to everyone. + // Opcode: NPC_ACTION(197) + class NpcActionPacket : public OutPacket + { + public: + NpcActionPacket(int32_t oid, int8_t action, int8_t line) : OutPacket(OutPacket::Opcode::NPC_ACTION) + { + write_int(oid); + write_byte(action); + write_byte(line); + } + }; + // Packet which sends a response to an NPC dialog to the server // Opcode: NPC_TALK_MORE(60) class NpcTalkMorePacket : public OutPacket diff --git a/net/src/handler.rs b/net/src/handler.rs index 81ee9152e..a1b0f7baf 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -330,6 +330,9 @@ pub enum FieldRequest { }, /// How far the player is from a firstAttack mob. MobDistance { object_id: i32, distance: i32 }, + /// The NPCs' controller chose an idle action for one: what followed the + /// object id (action, line, and a path for an NPC that walks). + NpcAction { object_id: i32, action: Vec }, /// A validated attack: `broadcast` for the others, total damage per mob. Attack { broadcast: Packet, @@ -443,6 +446,7 @@ fn world_routes() -> &'static HashMap { (Op::UseItem, InGame(&world::UseItemHandler)), (Op::MoveLife, InGame(&world::MoveLifeHandler)), (Op::MobDistance, InGame(&world::MobDistanceHandler)), + (Op::NpcAction, InGame(&world::NpcActionHandler)), ( Op::CloseRangeAttack, InGame(&world::AttackHandler(AttackKind::Close)), @@ -497,7 +501,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 46); + assert_eq!(world_routes().len(), 47); } #[test] diff --git a/net/src/packet/build/mod.rs b/net/src/packet/build/mod.rs index 140ddae7b..6de1a7508 100644 --- a/net/src/packet/build/mod.rs +++ b/net/src/packet/build/mod.rs @@ -12,7 +12,7 @@ mod tests { /// uses. Builder tests compare against the enum, so this pins the enum. #[test] fn send_opcodes_match_v83_reference() { - let table: [(SendOpcode, i16); 26] = [ + let table: [(SendOpcode, i16); 28] = [ (SendOpcode::LoginStatus, 0x00), (SendOpcode::GuestIdLogin, 0x01), (SendOpcode::ServerStatus, 0x03), @@ -35,6 +35,8 @@ mod tests { (SendOpcode::ChatText, 0xA2), (SendOpcode::MovePlayer, 0xB9), (SendOpcode::SpawnNpc, 0x101), + (SendOpcode::SpawnNpcRequestController, 0x103), + (SendOpcode::NpcAction, 0x104), (SendOpcode::NpcDialogue, 0x130), (SendOpcode::QueryCashResult, 0x144), (SendOpcode::CashShopOperation, 0x145), diff --git a/net/src/packet/build/world/npc.rs b/net/src/packet/build/world/npc.rs index f0a0476c2..019126050 100644 --- a/net/src/packet/build/world/npc.rs +++ b/net/src/packet/build/world/npc.rs @@ -48,6 +48,31 @@ pub struct ForeignNpc { pub fn build_spawn_npc(npc: &ForeignNpc) -> Result { let mut packet = Packet::new_empty(); packet.write_short(SendOpcode::SpawnNpc as i16)?; + write_npc(&mut packet, npc)?; + Ok(packet) +} + +/// The client now picks the NPC's idle actions: mode 1, then the NPC as +/// SPAWN_NPC writes it, for a client that hasn't seen it yet. +pub fn build_control_npc(npc: &ForeignNpc) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::SpawnNpcRequestController as i16)?; + packet.write_byte(1)?; + write_npc(&mut packet, npc)?; + Ok(packet) +} + +/// NPC_ACTION: everyone, the controller too, plays the action and line it +/// chose. `action` is what followed the object id in its request. +pub fn build_npc_action(object_id: i32, action: &[u8]) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::NpcAction as i16)?; + packet.write_int(object_id)?; + packet.write_bytes(action)?; + Ok(packet) +} + +fn write_npc(packet: &mut Packet, npc: &ForeignNpc) -> Result<(), NetworkError> { packet.write_int(npc.object_id)?; packet.write_int(npc.npc_id)?; packet.write_short(npc.x)?; @@ -57,7 +82,7 @@ pub fn build_spawn_npc(npc: &ForeignNpc) -> Result { packet.write_short(npc.rx0)?; packet.write_short(npc.rx1)?; packet.write_byte(1)?; // show on the minimap - Ok(packet) + Ok(()) } #[cfg(test)] @@ -107,6 +132,28 @@ mod tests { expect.end(); } + #[test] + fn control_npc_is_mode_one_and_the_spawn() { + let packet = build_control_npc(&foreign_npc(true)).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::SpawnNpcRequestController as i16); + expect.byte(1).int(1_000_000_001).int(2100); + expect.short(1200).short(-175).byte(1); + expect.short(32).short(1150).short(1250).byte(1); + expect.end(); + } + + #[test] + fn npc_action_relays_the_choice() { + let packet = build_npc_action(1_000_000_001, &[3, 0xff]).unwrap(); + + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::NpcAction as i16); + expect.int(1_000_000_001).byte(3).byte(0xff); + expect.end(); + } + fn page(has_previous: bool, has_next: bool) -> DialoguePage { DialoguePage { npc_id: 9010000, diff --git a/net/src/packet/handle/world/combat.rs b/net/src/packet/handle/world/combat.rs index 169fba5ab..bf9f8c1f3 100644 --- a/net/src/packet/handle/world/combat.rs +++ b/net/src/packet/handle/world/combat.rs @@ -57,6 +57,29 @@ impl WorldHandler for MobDistanceHandler { } } +/// `NPC_ACTION`: the NPCs' controller chose an idle action. Object id, the +/// action (an animation index from 2, or -1 for a line alone), the line +/// (-1 for none), then a path for an NPC that walks. +pub struct NpcActionHandler; + +impl WorldHandler for NpcActionHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut r = Cursor::new(&packet.bytes); + r.read_short()?; + let object_id = r.read_int()?; + let action = packet.bytes[r.position() as usize..].to_vec(); + if action.len() < 2 { + return Ok(HandlerResult::empty()); + } + Ok( + HandlerResult::empty().with(WorldAction::Field(FieldRequest::NpcAction { + object_id, + action, + })), + ) + } +} + /// `CLOSE_RANGE_ATTACK`, `RANGED_ATTACK`, and `MAGIC_ATTACK`: a swing, a /// shot, or a spell. pub struct AttackHandler(pub AttackKind); diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 628d61c3a..5bdff5162 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -36,7 +36,9 @@ pub use self::change_channel::ChangeChannelHandler; pub use self::change_map::{warp, ChangeMapHandler}; pub use self::char_info::CharInfoRequestHandler; pub use self::chat::AllChatHandler; -pub use self::combat::{AttackHandler, MobDistanceHandler, MoveLifeHandler, TakeDamageHandler}; +pub use self::combat::{ + AttackHandler, MobDistanceHandler, MoveLifeHandler, NpcActionHandler, TakeDamageHandler, +}; pub use self::emote::FaceExpressionHandler; pub use self::item_move::ItemMoveHandler; pub use self::keybinds::ChangeKeybindsHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index e9efef25a..53ea184cf 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -68,6 +68,7 @@ pub enum RecvOpcode { ChangeKeybinds = 0x87, MoveLife = 0xBC, MobDistance = 0xBD, + NpcAction = 0xC5, ItemPickup = 0xCA, DamageReactor = 0xCD, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index c8da2f86b..988660874 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -65,6 +65,8 @@ pub enum SendOpcode { ThrowGrenade = 0xCC, CancelChair = 0xCD, SpawnNpc = 0x101, + SpawnNpcRequestController = 0x103, + NpcAction = 0x104, DropItemFromMapObject = 0x10C, RemoveItemFromMap = 0x10D, HitReactor = 0x115, diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 512861cb0..ed525b078 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -11,7 +11,9 @@ use net::packet::build::world::mob::{ build_control_mob, build_kill_mob, build_mob_hp, build_move_mob, build_move_mob_response, build_show_magnet, build_spawn_mob, build_stop_controlling_mob, }; -use net::packet::build::world::npc::{build_spawn_npc, ForeignNpc}; +use net::packet::build::world::npc::{ + build_control_npc, build_npc_action, build_spawn_npc, ForeignNpc, +}; use net::packet::build::world::reactor::{ build_hit_reactor, build_remove_reactor, build_spawn_reactor, }; @@ -54,6 +56,8 @@ pub struct FieldActor { rx: mpsc::Receiver, occupants: HashMap, map_npcs: Vec, + /// Who picks the NPCs' idle actions; without one they only stand. + npc_controller: Option, mobs: MobSet, drops: DropSet, reactors: ReactorSet, @@ -77,6 +81,7 @@ impl FieldActor { rx, occupants: HashMap::new(), map_npcs, + npc_controller: None, mobs, drops, reactors, @@ -148,6 +153,31 @@ impl FieldActor { } } + /// Tell `client` it now picks every NPC's idle actions. + fn send_npc_control(&mut self, client: ClientId) { + let Some(occupant) = self.occupants.get(&client) else { + return; + }; + self.npc_controller = Some(client); + for map_npc in &self.map_npcs { + if let Ok(packet) = build_control_npc(&to_foreign_npc(map_npc)) { + occupant.handle.send_packet(packet); + } + } + } + + /// Show everyone the action the NPCs' controller chose. + fn handle_npc_action(&self, from: ClientId, object_id: i32, action: &[u8]) { + if self.npc_controller != Some(from) + || !self.map_npcs.iter().any(|npc| npc.object_id == object_id) + { + return; + } + if let Ok(packet) = build_npc_action(object_id, action) { + self.broadcast(None, packet); + } + } + /// Seat `from` on a portable chair (0 to stand) and show the others. fn handle_chair(&mut self, from: ClientId, item_id: i32) { let Some(occupant) = self.occupants.get_mut(&from) else { @@ -235,6 +265,9 @@ impl FieldActor { object_id, distance, } => self.handle_mob_distance(from, object_id, distance), + FieldRequest::NpcAction { object_id, action } => { + self.handle_npc_action(from, object_id, &action) + } FieldRequest::Attack { broadcast, hits } => { self.handle_attack(from, broadcast, hits) } @@ -608,6 +641,10 @@ impl FieldActor { ); let controlled = self.mobs.assign_uncontrolled(client_id); self.send_control(client_id, &controlled); + // A rejoining controller has forgotten it controls them. + if self.npc_controller.is_none() || self.npc_controller == Some(client_id) { + self.send_npc_control(client_id); + } } fn handle_leave(&mut self, client_id: ClientId) { @@ -619,6 +656,12 @@ impl FieldActor { if let Some(next) = next { self.send_control(next, &handed); } + if self.npc_controller == Some(client_id) { + self.npc_controller = None; + if let Some(next) = next { + self.send_npc_control(next); + } + } match build_player_leave_field(client_id) { Ok(packet) => self.broadcast(None, packet), Err(error) => warn!(client_id, %error, "Failed to build leave packet"), @@ -1047,6 +1090,64 @@ mod tests { assert_eq!(checked(&mut far_rx).await, (9, false)); } + #[tokio::test] + async fn one_player_picks_the_npcs_actions_for_everyone() { + let (tx, rx) = mpsc::channel(16); + let maria = FieldMapEntityNpc { + object_id: 1_000_000_000, + npc_id: 2100, + x: 130, + y: 185, + flip: false, + foothold: 0, + rx0: 80, + rx1: 180, + }; + let actor = FieldActor::new( + FieldKey::new(0, 50000), + rx, + vec![maria], + MobSet::default(), + DropSet::default(), + ReactorSet::default(), + Duration::from_millis(50), + ); + tokio::spawn(actor.run()); + let (first, mut first_rx) = client(1); + let (second, mut second_rx) = client(2); + for (handle, id) in [(first, 1), (second, 2)] { + tx.send(FieldMessage::Join { + handle, + avatar: avatar(id, 100, 185), + party: None, + }) + .await + .unwrap(); + } + let control = SendOpcode::SpawnNpcRequestController as i16; + assert!(opcodes(&mut first_rx).await.contains(&control)); + assert!(!opcodes(&mut second_rx).await.contains(&control)); + + let action = |from| FieldMessage::FromClient { + from, + request: FieldRequest::NpcAction { + object_id: 1_000_000_000, + action: vec![2, 0xff], + }, + }; + tx.send(action(2)).await.unwrap(); + assert!(opcodes(&mut first_rx).await.is_empty()); + tx.send(action(1)).await.unwrap(); + for rx in [&mut first_rx, &mut second_rx] { + let packet = next_packet(rx).await; + assert_eq!(packet.opcode(), SendOpcode::NpcAction as i16); + assert_eq!(&packet.bytes[2..], &[0x00, 0xca, 0x9a, 0x3b, 2, 0xff]); + } + + tx.send(FieldMessage::Leave { client_id: 1 }).await.unwrap(); + assert!(opcodes(&mut second_rx).await.contains(&control)); + } + #[tokio::test] async fn the_target_answers_a_char_info_request() { let field = spawn_field();