├── tests ├── __init__.py ├── data │ └── __init__.py ├── game │ └── __init__.py ├── login │ └── __init__.py └── common │ └── __init__.py ├── utils ├── __init__.py ├── xml_to_json.py ├── sql_to_json.py └── common.py ├── login ├── login │ ├── __init__.py │ ├── keys │ │ ├── __init__.py │ │ ├── xor.py │ │ ├── blowfish.py │ │ ├── session.py │ │ └── rsa.py │ ├── crypt │ │ ├── __init__.py │ │ └── xor.py │ ├── api │ │ ├── __init__.py │ │ ├── handlers.py │ │ └── login.py │ ├── session_storage.py │ ├── middleware │ │ ├── __init__.py │ │ ├── padding.py │ │ ├── encryption.py │ │ ├── xor.py │ │ └── checksum.py │ ├── packets │ │ ├── login_fail.py │ │ ├── play_fail.py │ │ ├── play_ok.py │ │ ├── __init__.py │ │ ├── gg_auth.py │ │ ├── login_ok.py │ │ ├── account_kicked.py │ │ ├── base.py │ │ ├── init.py │ │ └── server_list.py │ ├── __main__.py │ ├── state.py │ ├── config.py │ ├── constants.py │ ├── runner.py │ ├── application.py │ ├── protocol.py │ └── session.py ├── bin │ ├── sitecustomize.py │ └── register_game_server ├── Dockerfile └── pyproject.toml ├── common ├── common │ ├── __init__.py │ ├── client │ │ ├── __init__.py │ │ ├── game_client.py │ │ ├── exceptions.py │ │ ├── login_client.py │ │ └── client.py │ ├── transport │ │ ├── __init__.py │ │ ├── protocol.py │ │ └── packet_transport.py │ ├── application_modules │ │ ├── __init__.py │ │ ├── module.py │ │ ├── tcp.py │ │ ├── http.py │ │ └── scheduler.py │ ├── middleware │ │ ├── __init__.py │ │ ├── middleware.py │ │ └── length.py │ ├── constants.py │ ├── models │ │ ├── __init__.py │ │ ├── id_factory.py │ │ └── game_server.py │ ├── response.py │ ├── request.py │ ├── packet.py │ ├── exceptions.py │ ├── application.py │ ├── session.py │ ├── config.py │ ├── api_handlers.py │ ├── json.py │ ├── misc.py │ ├── template.py │ └── document.py ├── tests │ ├── __init__.py │ └── test_ctype.py ├── bin │ ├── sitecustomize.py │ └── create_indexes.py ├── sitecustomize.py ├── Dockerfile └── pyproject.toml ├── game ├── game │ ├── api │ │ ├── handlers.py │ │ ├── __init__.py │ │ ├── minimap.py │ │ ├── party.py │ │ ├── say.py │ │ ├── attack.py │ │ ├── shortcuts.py │ │ ├── world.py │ │ ├── move.py │ │ ├── game.py │ │ ├── macros.py │ │ └── action.py │ ├── keys │ │ ├── __init__.py │ │ └── xor_key.py │ ├── middleware │ │ ├── __init__.py │ │ └── xor.py │ ├── packets │ │ ├── change_wait_time.py │ │ ├── leave_world.py │ │ ├── action_failed.py │ │ ├── char_delete_ok.py │ │ ├── char_delete_fail.py │ │ ├── auth_login_fail.py │ │ ├── char_create_ok.py │ │ ├── net_ping_request.py │ │ ├── quest_list.py │ │ ├── char_create_fail.py │ │ ├── ex_send_manor_list.py │ │ ├── server_socket_close.py │ │ ├── chair_sit.py │ │ ├── status_update.py │ │ ├── my_target_selected.py │ │ ├── social_action.py │ │ ├── logout_ok.py │ │ ├── open_minimap.py │ │ ├── snoop.py │ │ ├── chage_move_type.py │ │ ├── base.py │ │ ├── move_to_location.py │ │ ├── friend_invite.py │ │ ├── crypt_init.py │ │ ├── move_to_pawn.py │ │ ├── ally_crest.py │ │ ├── restart_response.py │ │ ├── delete_object.py │ │ ├── char_templates.py │ │ ├── teleport_to_location.py │ │ ├── friend_message.py │ │ ├── creature_say.py │ │ ├── system_message.py │ │ ├── shortcut_register.py │ │ ├── change_wait_type.py │ │ ├── target_unselected.py │ │ ├── target_selected.py │ │ ├── etc_status_update.py │ │ ├── ex_storage_max_count.py │ │ ├── char_move_to_location.py │ │ ├── friend_list.py │ │ ├── acquire_skill_info.py │ │ ├── item_list.py │ │ ├── macros_list.py │ │ ├── __init__.py │ │ ├── attack.py │ │ └── char_selected.py │ ├── models │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── structures │ │ │ ├── npc │ │ │ │ ├── __init__.py │ │ │ │ └── template.py │ │ │ ├── race │ │ │ │ ├── race.py │ │ │ │ └── __init__.py │ │ │ ├── object │ │ │ │ ├── __init__.py │ │ │ │ ├── poly.py │ │ │ │ ├── point3d.py │ │ │ │ ├── position.py │ │ │ │ ├── object.py │ │ │ │ └── playable.py │ │ │ ├── skill │ │ │ │ ├── __init__.py │ │ │ │ └── skill.py │ │ │ ├── zone │ │ │ │ ├── __init__.py │ │ │ │ ├── farm.py │ │ │ │ ├── manager.py │ │ │ │ ├── cube.py │ │ │ │ ├── cylinder.py │ │ │ │ └── type.py │ │ │ ├── character │ │ │ │ ├── __init__.py │ │ │ │ ├── consume_rates.py │ │ │ │ ├── updates.py │ │ │ │ ├── reflections.py │ │ │ │ ├── limits.py │ │ │ │ ├── attack_buffs.py │ │ │ │ ├── weapon_vulnerabilities.py │ │ │ │ ├── resists.py │ │ │ │ ├── appearance.py │ │ │ │ ├── equipped_items.py │ │ │ │ ├── effects.py │ │ │ │ ├── status.py │ │ │ │ ├── template.py │ │ │ │ ├── stats.py │ │ │ │ └── character.py │ │ │ ├── __init__.py │ │ │ ├── item │ │ │ │ ├── __init__.py │ │ │ │ ├── armor.py │ │ │ │ ├── etc.py │ │ │ │ └── weapon.py │ │ │ ├── shortcut.py │ │ │ ├── macro.py │ │ │ ├── world_region.py │ │ │ ├── experience.py │ │ │ └── system_message.py │ │ ├── crest.py │ │ ├── __init__.py │ │ ├── character_template.py │ │ ├── party.py │ │ ├── item.py │ │ ├── login_server.py │ │ └── clan.py │ ├── __init__.py │ ├── static │ │ ├── __init__.py │ │ ├── etcitem.py │ │ ├── static.py │ │ ├── armor.py │ │ ├── cache.py │ │ ├── weapon.py │ │ ├── npc.py │ │ ├── character_template.py │ │ └── item.py │ ├── event.py │ ├── request.py │ ├── __main__.py │ ├── periodic_tasks.py │ ├── states.py │ ├── config.py │ ├── application.py │ ├── session.py │ ├── protocol.py │ ├── broadcaster.py │ ├── runner.py │ └── constants.py ├── Dockerfile └── pyproject.toml ├── .pythonrc ├── sitecustomize.py ├── .gitignore ├── .env.example ├── docker-compose.yml ├── .github ├── workflows │ └── main.yml └── actions │ └── poetry │ └── action.yml ├── pyproject.toml ├── LICENSE ├── Makefile └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /login/login/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/game/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/login/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/api/handlers.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/keys/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /login/login/keys/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/common/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/common/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /login/login/crypt/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/common/transport/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/packets/change_wait_time.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/npc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/race/race.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /common/common/application_modules/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/object/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/race/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/skill/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /game/game/__init__.py: -------------------------------------------------------------------------------- 1 | from . import broadcaster 2 | -------------------------------------------------------------------------------- /game/game/models/structures/character/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /login/login/api/__init__.py: -------------------------------------------------------------------------------- 1 | from . import login 2 | -------------------------------------------------------------------------------- /game/game/models/crest.py: -------------------------------------------------------------------------------- 1 | class Crest: 2 | pass 3 | -------------------------------------------------------------------------------- /login/login/session_storage.py: -------------------------------------------------------------------------------- 1 | session_storage = {} 2 | -------------------------------------------------------------------------------- /game/game/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .character import Character 2 | -------------------------------------------------------------------------------- /game/game/static/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache import StaticDataCache 2 | -------------------------------------------------------------------------------- /common/common/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from . import length, middleware 2 | -------------------------------------------------------------------------------- /common/common/constants.py: -------------------------------------------------------------------------------- 1 | MINUTE = 60 2 | HOUR = MINUTE * 60 3 | DAY = HOUR * 24 4 | -------------------------------------------------------------------------------- /common/bin/sitecustomize.py: -------------------------------------------------------------------------------- 1 | # This file is used for loading env and data types on app startup. 2 | -------------------------------------------------------------------------------- /game/game/models/structures/__init__.py: -------------------------------------------------------------------------------- 1 | from game.static.character_template import StaticCharacterTemplate 2 | -------------------------------------------------------------------------------- /game/game/event.py: -------------------------------------------------------------------------------- 1 | from common.model import BaseModel 2 | 3 | 4 | class ServerEvent(BaseModel): 5 | pass 6 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/farm.py: -------------------------------------------------------------------------------- 1 | from common.model import BaseModel 2 | 3 | 4 | class ZoneFarm(BaseModel): 5 | pass 6 | -------------------------------------------------------------------------------- /common/common/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .account import Account 2 | from .game_server import GameServer 3 | from .id_factory import IDFactory 4 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/manager.py: -------------------------------------------------------------------------------- 1 | from common.model import BaseModel 2 | 3 | 4 | class ZoneManager(BaseModel): 5 | zones: list 6 | -------------------------------------------------------------------------------- /game/game/models/structures/item/__init__.py: -------------------------------------------------------------------------------- 1 | from .armor import Armor 2 | from .etc import EtcItem 3 | from .item import Item 4 | from .weapon import Weapon 5 | -------------------------------------------------------------------------------- /game/game/request.py: -------------------------------------------------------------------------------- 1 | from common.request import Request 2 | from game.session import GameSession 3 | 4 | 5 | class GameRequest(Request): 6 | session: GameSession 7 | -------------------------------------------------------------------------------- /common/common/client/game_client.py: -------------------------------------------------------------------------------- 1 | from aiojsonapi.client import ApiClient 2 | 3 | 4 | class GameClient(ApiClient): 5 | async def ping(self): 6 | return await self.post("api/ping") 7 | -------------------------------------------------------------------------------- /login/login/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .checksum import ChecksumMiddleware 2 | from .encryption import EncryptionMiddleware 3 | from .padding import PaddingMiddleware 4 | from .xor import XORMiddleware 5 | -------------------------------------------------------------------------------- /game/game/models/character_template.py: -------------------------------------------------------------------------------- 1 | from common.document import Document 2 | 3 | 4 | class CharacterTemplate(Document): 5 | @classmethod 6 | def from_static_template(cls, template, sex): 7 | pass 8 | -------------------------------------------------------------------------------- /game/game/models/structures/character/consume_rates.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class ConsumeRates(BaseModel): 6 | mp: ctype.int32 7 | hp: ctype.int32 8 | -------------------------------------------------------------------------------- /game/game/models/structures/object/poly.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class ObjectPolymorph(BaseModel): 6 | id: ctype.int32 = 0 7 | type: ctype.int32 = 0 8 | -------------------------------------------------------------------------------- /login/login/packets/login_fail.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | from .base import LoginServerPacket 4 | 5 | 6 | class LoginFail(LoginServerPacket): 7 | type: ctype.char = 1 8 | reason_id: ctype.long 9 | -------------------------------------------------------------------------------- /login/login/packets/play_fail.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | from .base import LoginServerPacket 4 | 5 | 6 | class PlayFail(LoginServerPacket): 7 | type: ctype.char = 6 8 | reason_id: ctype.char 9 | -------------------------------------------------------------------------------- /.pythonrc: -------------------------------------------------------------------------------- 1 | # This file is used in `make python` command for interactive mode. 2 | import pathlib 3 | import sys 4 | 5 | for module in ["common", "game", "login"]: 6 | sys.path.append(str(pathlib.Path(module).resolve())) 7 | -------------------------------------------------------------------------------- /game/game/models/party.py: -------------------------------------------------------------------------------- 1 | from common.model import BaseModel 2 | from game.models.structures.character.character import Character 3 | 4 | 5 | class Party(BaseModel): 6 | participants: list 7 | leader: Character 8 | -------------------------------------------------------------------------------- /game/game/models/structures/object/point3d.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Point3D(BaseModel): 6 | x: ctype.int32 7 | y: ctype.int32 8 | z: ctype.int32 9 | -------------------------------------------------------------------------------- /game/game/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | from game.runner import main 5 | 6 | workdir = pathlib.Path(__file__).parent.parent 7 | 8 | if __name__ == "__main__": 9 | os.chdir(workdir) 10 | main() 11 | -------------------------------------------------------------------------------- /game/game/packets/leave_world.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | 7 | class LeaveWorld(GameServerPacket): 8 | type: ctype.int8 = 126 9 | -------------------------------------------------------------------------------- /game/game/packets/action_failed.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class ActionFailed(GameServerPacket): 9 | type: ctype.int8 = 37 10 | -------------------------------------------------------------------------------- /game/game/packets/char_delete_ok.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class CharDeleteOk(GameServerPacket): 9 | type: ctype.int8 = 35 10 | -------------------------------------------------------------------------------- /login/login/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | from login.runner import main 5 | 6 | workdir = pathlib.Path(__file__).parent.parent 7 | 8 | if __name__ == "__main__": 9 | os.chdir(workdir) 10 | main() 11 | -------------------------------------------------------------------------------- /sitecustomize.py: -------------------------------------------------------------------------------- 1 | # This file is used for loading env and data types on app startup. 2 | import pathlib 3 | import sys 4 | 5 | for module in ["common", "data", "game", "login"]: 6 | sys.path.append(str(pathlib.Path(module).resolve())) 7 | -------------------------------------------------------------------------------- /game/game/packets/char_delete_fail.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class CharDeleteFail(GameServerPacket): 9 | type: ctype.int8 = 36 10 | -------------------------------------------------------------------------------- /login/bin/sitecustomize.py: -------------------------------------------------------------------------------- 1 | # This file is used for loading env and data types on app startup. 2 | import pathlib 3 | import sys 4 | 5 | for module in ["common", "game", "login"]: 6 | sys.path.append(str(pathlib.Path(module).resolve())) 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.pyi 4 | *pycache* 5 | .pytest_cache 6 | 7 | .idea 8 | game/game/data/static 9 | .env 10 | .venv 11 | .vscode 12 | .dockerignore 13 | .DS_Store 14 | poetry.lock 15 | *venv* 16 | 17 | .c 18 | .cpp 19 | .so 20 | -------------------------------------------------------------------------------- /common/sitecustomize.py: -------------------------------------------------------------------------------- 1 | # This file is used in `make python` command for interactive mode. 2 | import pathlib 3 | import sys 4 | 5 | for module in ["common", "data", "game", "login"]: 6 | sys.path.append(str(pathlib.Path(module).resolve())) 7 | -------------------------------------------------------------------------------- /game/game/api/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | action, 3 | attack, 4 | characters, 5 | friends, 6 | game, 7 | macros, 8 | minimap, 9 | move, 10 | party, 11 | say, 12 | shortcuts, 13 | world, 14 | ) 15 | -------------------------------------------------------------------------------- /game/game/models/structures/character/updates.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class UpdateChecks(BaseModel): 6 | increase: ctype.int64 = 0 7 | decrease: ctype.int64 = 0 8 | interval: ctype.int64 = 0 9 | -------------------------------------------------------------------------------- /game/game/packets/auth_login_fail.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class AuthLoginFail(GameServerPacket): 9 | type: ctype.int8 = 12 10 | reason_id: ctype.int32 11 | -------------------------------------------------------------------------------- /game/game/packets/char_create_ok.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class CharCreateOk(GameServerPacket): 9 | type: ctype.int8 = 25 10 | result_ok: ctype.int8 = 1 11 | -------------------------------------------------------------------------------- /game/game/packets/net_ping_request.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class NetPingRequest(GameServerPacket): 9 | type: ctype.int8 = 211 10 | ping_id: ctype.int32 11 | -------------------------------------------------------------------------------- /game/game/packets/quest_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class QuestList(GameServerPacket): 9 | type: ctype.int8 = 128 10 | quests_count: ctype.int16 = 0 11 | -------------------------------------------------------------------------------- /game/game/packets/char_create_fail.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class CharCreateFail(GameServerPacket): 9 | type: ctype.int8 = 26 10 | reason_id: ctype.int32 11 | -------------------------------------------------------------------------------- /game/game/packets/ex_send_manor_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class ExSendManorList(GameServerPacket): 9 | type: ctype.int8 = 254 10 | constant: ctype.int8 = 27 11 | -------------------------------------------------------------------------------- /game/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM l2py_common 2 | 3 | WORKDIR /code/ 4 | ADD game /code/game 5 | ADD pyproject.toml /code/ 6 | 7 | RUN poetry update --no-interaction --no-ansi 8 | RUN poetry install --no-interaction --no-ansi 9 | 10 | CMD ["poetry", "run", "python", "./game/runner.py"] 11 | -------------------------------------------------------------------------------- /game/game/models/structures/skill/skill.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Skill(BaseModel): 6 | id: ctype.int32 = 0 7 | activation_type: str = "" 8 | target_type: ctype.int32 = 0 9 | type: ctype.int32 = 0 10 | -------------------------------------------------------------------------------- /game/game/packets/server_socket_close.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class ServerSocketClose(GameServerPacket): 9 | type: ctype.int8 = 175 10 | constant: ctype.int32 = 0 11 | -------------------------------------------------------------------------------- /login/login/packets/play_ok.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | from .base import LoginServerPacket 4 | 5 | 6 | class PlayOk(LoginServerPacket): 7 | type: ctype.char = 7 8 | play_ok1: ctype.int32 9 | play_ok2: ctype.int32 10 | unknown: ctype.char = 1 11 | -------------------------------------------------------------------------------- /game/game/packets/chair_sit.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class ChairSit(GameServerPacket): 9 | type: ctype.int8 = 225 10 | object_id: ctype.int32 11 | static_object_id: ctype.int32 12 | -------------------------------------------------------------------------------- /game/game/packets/status_update.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class StatusUpdate(GameServerPacket): 9 | type: ctype.int8 = 14 10 | object_id: ctype.int32 11 | stats_count: ctype.int32 12 | -------------------------------------------------------------------------------- /login/login/packets/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import LoginServerPacket 2 | from .gg_auth import GGAuth 3 | from .init import Init 4 | from .login_fail import LoginFail 5 | from .login_ok import LoginOk 6 | from .play_fail import PlayFail 7 | from .play_ok import PlayOk 8 | from .server_list import ServerList 9 | -------------------------------------------------------------------------------- /common/common/client/exceptions.py: -------------------------------------------------------------------------------- 1 | class ApiException(Exception): 2 | def __init__(self, message): 3 | super().__init__() 4 | self.message = message 5 | 6 | 7 | class WrongCredentials(ApiException): 8 | pass 9 | 10 | 11 | class DocumentDoesntExist(ApiException): 12 | pass 13 | -------------------------------------------------------------------------------- /game/game/packets/my_target_selected.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | 7 | class MyTargetSelected(GameServerPacket): 8 | type: ctype.int8 = 166 9 | object_id: ctype.int32 10 | color: ctype.int 11 | -------------------------------------------------------------------------------- /login/login/keys/xor.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | 4 | class LoginXorKey: 5 | def __init__(self, key=None): 6 | self.key: ctype.int32 = ctype.int32.random() if not key else key 7 | self.initiated = False 8 | 9 | def __repr__(self): 10 | return str(self.key) 11 | -------------------------------------------------------------------------------- /game/game/packets/social_action.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | 7 | class SocialAction(GameServerPacket): 8 | type: ctype.int8 = 45 9 | 10 | character_id: ctype.int32 11 | action_id: ctype.int32 12 | -------------------------------------------------------------------------------- /common/common/middleware/middleware.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class Middleware(abc.ABC): 5 | @classmethod 6 | @abc.abstractmethod 7 | def before(cls, session, request): 8 | pass 9 | 10 | @classmethod 11 | @abc.abstractmethod 12 | def after(cls, session, response): 13 | pass 14 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/cube.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from game.models.structures.zone.farm import ZoneFarm 3 | 4 | 5 | class ZoneCube(ZoneFarm): 6 | x1: ctype.int32 7 | x2: ctype.int32 8 | y1: ctype.int32 9 | y2: ctype.int32 10 | z1: ctype.int32 11 | z2: ctype.int32 12 | -------------------------------------------------------------------------------- /game/game/static/etcitem.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from game.static.item import Item 4 | from game.static.static import StaticData 5 | 6 | 7 | class EtcItem(Item, StaticData): 8 | filepath: ClassVar[str] = "game/data/etcitem.json" 9 | 10 | @property 11 | def is_arrow(self): 12 | return 13 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/cylinder.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from game.models.structures.zone.farm import ZoneFarm 3 | 4 | 5 | class ZoneCylinder(ZoneFarm): 6 | x: ctype.int32 7 | y: ctype.int32 8 | z1: ctype.int32 9 | z2: ctype.int32 10 | radiant: ctype.int32 11 | radiantS: ctype.int32 12 | -------------------------------------------------------------------------------- /game/game/packets/logout_ok.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class LogoutOk(GameServerPacket): 9 | type: ctype.int8 = 126 10 | 11 | def encode(self, session): 12 | encoded = bytearray(self.type) 13 | return encoded 14 | -------------------------------------------------------------------------------- /game/game/models/structures/character/reflections.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Reflections(BaseModel): 6 | damage_percent: ctype.int32 7 | magic_skill: ctype.int32 8 | physical_skill: ctype.int32 9 | absorb_percent: ctype.int32 10 | transfer_percent: ctype.int32 11 | -------------------------------------------------------------------------------- /login/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM l2py_common 2 | 3 | WORKDIR /code/ 4 | 5 | ADD login /code/login 6 | ADD pyproject.toml /code/ 7 | 8 | COPY bin/ /usr/local/bin/ 9 | RUN chmod +x -R /usr/local/bin 10 | 11 | RUN poetry update --no-interaction --no-ansi 12 | RUN poetry install --no-interaction --no-ansi 13 | 14 | CMD ["poetry", "run", "python", "./login/runner.py"] 15 | -------------------------------------------------------------------------------- /game/game/models/structures/shortcut.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class Shortcut(BaseModel): 8 | slot: ctype.int32 9 | page: ctype.int32 10 | type: ctype.int32 11 | id: ctype.int32 12 | level: Optional[ctype.int32] = None # works only for skills 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PYTHONPATH=$(pwd):$(pwd)/common:$(pwd)/login:$(pwd)/game 2 | MONGO_URI=localhost 3 | 4 | GAME_SERVER_ID=1 5 | GAME_SERVER_HOST=0.0.0.0 6 | GAME_SERVER_PORT=7777 7 | GAME_SERVER_API_HOST=0.0.0.0 8 | GAME_SERVER_API_PORT=7778 9 | 10 | LOGIN_SERVER_HOST=0.0.0.0 11 | LOGIN_SERVER_PORT=2106 12 | LOGIN_SERVER_API_HOST=0.0.0.0 13 | LOGIN_SERVER_API_PORT=2107 14 | -------------------------------------------------------------------------------- /common/bin/create_indexes.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import common.modelsx # noqa 4 | from common.document import Document 5 | 6 | 7 | async def main(): 8 | for model in Document.__subclasses__(): 9 | if hasattr(model, "create_index"): 10 | await model.create_index() 11 | 12 | 13 | if __name__ == "__main__": 14 | asyncio.run(main()) 15 | -------------------------------------------------------------------------------- /game/game/models/structures/character/limits.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Limits(BaseModel): 6 | inventory: ctype.int32 7 | warehouse: ctype.int32 8 | freight: ctype.int32 9 | sell: ctype.int32 10 | buy: ctype.int32 11 | dwarf_recipe: ctype.int32 12 | common_recipe: ctype.int32 13 | -------------------------------------------------------------------------------- /game/game/models/item.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.document import Document 4 | from game.models.structures.item.item import Item, ItemLocation 5 | 6 | 7 | class DroppedItem(Document, Item): 8 | __collection__: ClassVar[str] = "dropped_items" 9 | __database__: ClassVar[str] = "l2py" 10 | 11 | def validate_position(self): 12 | pass 13 | -------------------------------------------------------------------------------- /game/game/packets/open_minimap.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | if TYPE_CHECKING: 7 | from game.session import GameSession 8 | 9 | 10 | class OpenMinimap(GameServerPacket): 11 | type: ctype.int8 = 157 12 | map_id: ctype.int32 13 | seven_signs_period: ctype.int32 14 | -------------------------------------------------------------------------------- /game/game/models/structures/character/attack_buffs.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class AttacksBuffs(BaseModel): 6 | physical_plants: ctype.int32 7 | physical_insects: ctype.int32 8 | physical_animals: ctype.int32 9 | physical_monsters: ctype.int32 10 | physical_dragons: ctype.int32 11 | physical_undead: ctype.int32 12 | -------------------------------------------------------------------------------- /login/login/api/handlers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | 4 | def verify_secrets(f): 5 | @functools.wraps(f) 6 | async def wrap(request, *args, **kwargs): 7 | request.session.session_key.verify_login( 8 | request.validated_data["login_ok1"], request.validated_data["login_ok2"] 9 | ) 10 | return await f(request, *args, **kwargs) 11 | 12 | return wrap 13 | -------------------------------------------------------------------------------- /common/common/application_modules/module.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class ApplicationModule(abc.ABC): 5 | """Application extension module base.""" 6 | 7 | name: str 8 | 9 | def __init__(self, name): 10 | self.name = name 11 | 12 | @abc.abstractmethod 13 | async def start(self, config, loop): 14 | """Initializes application module. MUST be set in each module.""" 15 | -------------------------------------------------------------------------------- /game/game/packets/snoop.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | 7 | class Snoop(GameServerPacket): 8 | type: ctype.int8 = 213 9 | conversation_id: ctype.int32 10 | receiver: str 11 | unknown_constant: ctype.int32 = 0 12 | text_type: ctype.int32 13 | speaker: str 14 | message: str 15 | -------------------------------------------------------------------------------- /game/game/packets/chage_move_type.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | 7 | from .base import GameServerPacket 8 | 9 | 10 | class ChangeMoveType(GameServerPacket): 11 | type: ctype.int8 = 46 12 | 13 | character_id: ctype.int32 14 | move_type: ctype.int32 15 | constant: ctype.int32 = Field(default=0, const=True) 16 | -------------------------------------------------------------------------------- /game/game/packets/base.py: -------------------------------------------------------------------------------- 1 | from common.packet import Packet 2 | 3 | 4 | class GameServerPacket(Packet): 5 | def encode(self, session, strings_format="utf-8"): 6 | return super().encode(session, strings_format=strings_format) 7 | 8 | @classmethod 9 | def parse(cls, data, client): 10 | pass 11 | 12 | @classmethod 13 | def decode(cls, data, client, **kwargs): 14 | pass 15 | -------------------------------------------------------------------------------- /game/game/models/structures/zone/type.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | from game.models.structures.zone.farm import ZoneFarm 6 | 7 | 8 | class ZoneType(BaseModel): 9 | zone: ZoneFarm 10 | characters: list[None] # TODO 11 | min_lvl: ctype.int32 12 | max_lvl: ctype.int32 13 | races: list[ctype.int32] 14 | classes: list[ctype.int32] 15 | -------------------------------------------------------------------------------- /game/game/models/login_server.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from common.document import Document 7 | 8 | 9 | class LoginServer(Document): 10 | id: str = Field(alias="_id") 11 | host: str 12 | port: ctype.int 13 | status: ctype.int8 14 | 15 | __collection__: ClassVar[str] = "login_servers" 16 | __database__: ClassVar[str] = "l2py" 17 | -------------------------------------------------------------------------------- /login/login/packets/gg_auth.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | from .base import LoginServerPacket 4 | 5 | 6 | class GGAuth(LoginServerPacket): 7 | type: ctype.char = 11 8 | reply: ctype.int32 = 1 9 | zero1: ctype.int32 = 0 10 | zero2: ctype.int32 = 0 11 | zero3: ctype.int32 = 0 12 | zero4: ctype.int32 = 0 13 | 14 | @classmethod 15 | def parse(cls, data, client): 16 | return cls(data[1:5]) 17 | -------------------------------------------------------------------------------- /game/game/models/structures/object/position.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | from game.models.structures.object.point3d import Point3D 6 | from game.models.structures.world_region import WorldRegion 7 | 8 | 9 | class Position(BaseModel): 10 | heading_angle: ctype.int32 11 | point3d: Point3D 12 | region: WorldRegion = Field(default_factory=WorldRegion) 13 | -------------------------------------------------------------------------------- /game/game/periodic_tasks.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from common.application_modules.scheduler import ScheduleModule 4 | from common.models import GameServer 5 | from game.config import GameConfig 6 | 7 | 8 | @ScheduleModule.job("interval", seconds=10) 9 | async def i_am_alive(): 10 | server = await GameServer.one(GameConfig().GAME_SERVER_ID) 11 | server.last_alive = int(time.time()) 12 | await server.commit_changes(fields=["last_alive"]) 13 | -------------------------------------------------------------------------------- /game/game/models/structures/item/armor.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from game.models.structures.item.item import Item 3 | from game.models.structures.skill.skill import Skill 4 | 5 | 6 | class Armor(Item): 7 | avoid_modifier: ctype.int32 = 0 8 | physical_defense: ctype.int32 = 0 9 | magic_defense: ctype.int32 = 0 10 | mp_bonus: ctype.int32 = 0 11 | hp_bonus: ctype.int32 = 0 12 | 13 | passive_skill: None | Skill = None 14 | -------------------------------------------------------------------------------- /game/game/packets/move_to_location.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import GameServerPacket 6 | 7 | 8 | class MoveToLocation(GameServerPacket): 9 | type: ctype.int8 = 1 10 | 11 | object_id: ctype.int32 12 | destination_x: ctype.int32 13 | destination_y: ctype.int32 14 | destination_z: ctype.int32 15 | current_x: ctype.int32 16 | current_y: ctype.int32 17 | current_z: ctype.int32 18 | -------------------------------------------------------------------------------- /game/game/states.py: -------------------------------------------------------------------------------- 1 | class State: 2 | pass 3 | 4 | 5 | class Any(State): 6 | pass 7 | 8 | 9 | class Connected(State): 10 | pass 11 | 12 | 13 | class WaitingAuthentication(State): 14 | pass 15 | 16 | 17 | class WaitingCharacterSelect(State): 18 | pass 19 | 20 | 21 | class CreatingCharacter(State): 22 | pass 23 | 24 | 25 | class CreatingCharacterWait(State): 26 | pass 27 | 28 | 29 | class CharacterSelected(State): 30 | pass 31 | -------------------------------------------------------------------------------- /login/login/state.py: -------------------------------------------------------------------------------- 1 | class State: 2 | pass 3 | 4 | 5 | class Connected(State): 6 | pass 7 | 8 | 9 | class GGAuthenticated(State): 10 | pass 11 | 12 | 13 | class WaitingGGAccept(State): 14 | pass 15 | 16 | 17 | class Authenticated(State): 18 | pass 19 | 20 | 21 | class WaitingAuthenticationAccept(State): 22 | pass 23 | 24 | 25 | class WaitingGameServerSelect(State): 26 | pass 27 | 28 | 29 | class GameServerSelected(State): 30 | pass 31 | -------------------------------------------------------------------------------- /game/game/api/minimap.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.packets 3 | import game.states 4 | from common.api_handlers import l2_request_handler 5 | from common.template import Template 6 | 7 | 8 | @l2_request_handler(game.constants.GAME_REQUEST_OPEN_MINIMAP, Template([])) 9 | async def open_minimap(request): 10 | return game.packets.OpenMinimap( 11 | map_id=1665, 12 | seven_signs_period=game.constants.SEVEN_SIGNS_PERIOD_COMPETITION_RECRUITING, 13 | ) 14 | -------------------------------------------------------------------------------- /login/login/packets/login_ok.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | from .base import LoginServerPacket 4 | 5 | 6 | class LoginOk(LoginServerPacket): 7 | type: ctype.char = 3 8 | login_ok1: ctype.int32 9 | login_ok2: ctype.int32 10 | unknown_bytes: bytes = ( 11 | b"\x00\x00\x00\x00" 12 | b"\x00\x00\x00\x00" 13 | b"\xEA\x03\x00\x00" 14 | b"\x00\x00\x00\x00" 15 | b"\x00\x00\x00\x00" 16 | b"\x02\x00\x00\x00" 17 | ) 18 | -------------------------------------------------------------------------------- /game/game/static/static.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import ClassVar 3 | 4 | from common.model import BaseModel 5 | 6 | from .cache import StaticDataCache 7 | 8 | 9 | class StaticData(BaseModel): 10 | filepath: ClassVar[str] 11 | 12 | @classmethod 13 | def read_file(cls) -> list["StaticData"]: 14 | import game 15 | 16 | game_root = Path(game.__path__[0]).parent 17 | return StaticDataCache().read(Path(game_root, cls.filepath), cls) 18 | -------------------------------------------------------------------------------- /game/game/packets/friend_invite.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | 8 | class FriendInvite(GameServerPacket): 9 | type: ctype.int8 = 125 10 | requestor_name: str 11 | 12 | def encode(self, session): 13 | encoded = bytearray(self.type) 14 | 15 | extend_bytearray(encoded, [self.requestor_name]) 16 | 17 | return encoded 18 | -------------------------------------------------------------------------------- /game/game/models/structures/character/weapon_vulnerabilities.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class WeaponVulnerabilities(BaseModel): 6 | shield: ctype.int32 = 0 7 | sword: ctype.int32 = 0 8 | blunt: ctype.int32 = 0 9 | dagger: ctype.int32 = 0 10 | bow: ctype.int32 = 0 11 | pole: ctype.int32 = 0 12 | etc: ctype.int32 = 0 13 | fist: ctype.int32 = 0 14 | dual: ctype.int32 = 0 15 | dual_fist: ctype.int32 = 0 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mongo: 5 | image: mongo 6 | 7 | login: 8 | build: 9 | context: ./login 10 | restart: always 11 | env_file: 12 | - .env 13 | ports: 14 | - 2106:2106 15 | environment: 16 | - MONGO_URI=mongo 17 | 18 | game: 19 | build: 20 | context: ./game 21 | restart: always 22 | env_file: 23 | - .env 24 | environment: 25 | - MONGO_URI=mongo 26 | ports: 27 | - 7777:7777 28 | -------------------------------------------------------------------------------- /game/game/models/structures/character/resists.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Resists(BaseModel): 6 | breath: ctype.int32 = 0 7 | aggression: ctype.int32 = 0 8 | confusion: ctype.int32 = 0 9 | movement: ctype.int32 = 0 10 | sleep: ctype.int32 = 0 11 | fire: ctype.int32 = 0 12 | wind: ctype.int32 = 0 13 | water: ctype.int32 = 0 14 | earth: ctype.int32 = 0 15 | holy: ctype.int32 = 0 16 | dark: ctype.int32 = 0 17 | -------------------------------------------------------------------------------- /game/game/models/structures/item/etc.py: -------------------------------------------------------------------------------- 1 | from .item import Item 2 | 3 | 4 | class EtcItemType: 5 | ARROW = 0 6 | MATERIAL = 1 7 | PET_COLLAR = 2 8 | POTION = 3 9 | RECIPE = 4 10 | SCROLL = 5 11 | QUEST = 6 12 | MONEY = 7 13 | OTHER = 8 14 | SPELLBOOK = 9 15 | SEED = 10 16 | SHOT = 11 17 | HERB = 12 18 | 19 | 20 | class EtcItem(Item): 21 | @property 22 | def is_consumable(self): 23 | return self.type in [EtcItemType.SHOT, EtcItemType.POTION] 24 | -------------------------------------------------------------------------------- /login/login/packets/account_kicked.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from .base import LoginServerPacket 6 | 7 | 8 | class Reason: 9 | DATA_STEALER: ctype.int8 = 1 10 | GENERIC_VIOLATION: ctype.int8 = 8 11 | SEVEN_DAYS_PASSED: ctype.int8 = 16 12 | ACCOUNT_BANNED: ctype.int8 = 32 13 | 14 | 15 | class AccountKicked(LoginServerPacket): 16 | type: ctype.int8 = 2 17 | kick_reason: ctype.int32 18 | 19 | REASON: ClassVar[ctype.int8] = Reason 20 | -------------------------------------------------------------------------------- /game/game/packets/crypt_init.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.packets.base import GameServerPacket 5 | 6 | 7 | class CryptInit(GameServerPacket): 8 | type: ctype.int8 = 0 9 | # unknown1: ctype.int32 = field(default=1, repr=False, init=False) 10 | is_valid: ctype.int8 11 | xor_key: bytearray 12 | unknown2: ctype.int32 = 16777216 13 | unknown3: ctype.int32 = 16777216 14 | # unknown4: ctype.int8 = field(default=1, repr=False, init=False) 15 | -------------------------------------------------------------------------------- /login/login/config.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | loop = asyncio.get_event_loop() 5 | 6 | # Registration 7 | auto_registration = True # TODO 8 | 9 | # Server config 10 | LOGIN_SERVER_HOST = os.environ.get("LOGIN_SERVER_HOST", "0.0.0.0") 11 | LOGIN_SERVER_PORT = os.environ.get("LOGIN_SERVER_PORT", 2106) 12 | 13 | LOGIN_SERVER_API_HOST = os.environ.get("LOGIN_SERVER_API_HOST", "0.0.0.0") 14 | LOGIN_SERVER_API_PORT = os.environ.get("LOGIN_SERVER_API_PORT", 2107) 15 | 16 | DEBUG = os.environ.get("DEBUG", False) 17 | -------------------------------------------------------------------------------- /common/common/response.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from common.model import BaseModel 4 | from common.packet import Packet 5 | from common.session import Session 6 | 7 | 8 | class Response(BaseModel): 9 | packet: Packet # Packet response. 10 | session: Session # Client connection session 11 | data: typing.Optional[bytearray] = None # packet in bytearray format. 12 | 13 | def __init__(self, **kwargs): 14 | super().__init__(**kwargs) 15 | self.data = self.packet.encode(self.session) if self.data is None else self.data 16 | -------------------------------------------------------------------------------- /game/game/models/structures/macro.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class MacroEntry(BaseModel): 8 | entry_id: ctype.int8 9 | type: ctype.int8 10 | skill_id: ctype.int32 11 | shortcut_id: ctype.int8 12 | command: str 13 | 14 | 15 | class Macro(BaseModel): 16 | id: ctype.int32 17 | name: str 18 | icon: ctype.int8 19 | acronym: str = "" 20 | description: str = "" 21 | entries: list[MacroEntry] = Field(default_factory=list) 22 | -------------------------------------------------------------------------------- /game/game/models/structures/world_region.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class WorldRegion(BaseModel): 8 | tile_x: ctype.int32 = 0 9 | tile_y: ctype.int32 = 0 10 | active: ctype.int32 = 0 11 | 12 | playable_objects: list[ctype.int32] = Field(default_factory=list, exclude=True) 13 | visible_objects: list[ctype.int32] = Field(default_factory=list, exclude=True) 14 | neighbours: list["WorldRegion"] = Field(default_factory=list, exclude=True) 15 | -------------------------------------------------------------------------------- /game/game/models/clan.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | import pymongo 4 | 5 | from common.ctype import ctype 6 | from common.document import Document 7 | 8 | 9 | class Clan(Document): 10 | name: str 11 | leader: ctype.int32 12 | 13 | __collection__: ClassVar[str] = "clans" 14 | __database__: ClassVar[str] = "l2py" 15 | 16 | def create_indexes(self): 17 | self.sync_collection().create_index([("name", pymongo.ASCENDING)], unique=True) 18 | self.sync_collection().create_index([("leader", pymongo.ASCENDING)], unique=True) 19 | -------------------------------------------------------------------------------- /game/game/static/armor.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from game.models.structures.item.item import Item, ItemProperties 3 | 4 | 5 | class ArmorProperties(ItemProperties): 6 | stackable: ctype.bool = False 7 | crystallizable: ctype.bool 8 | sellable: ctype.bool 9 | droppable: ctype.bool 10 | destroyable: ctype.bool 11 | tradable: ctype.bool 12 | 13 | 14 | class Armor(Item): 15 | states: ArmorProperties 16 | physical_defense: ctype.int32 17 | magic_defense: ctype.int32 18 | mp_bonus: ctype.int32 19 | armor_type: str 20 | -------------------------------------------------------------------------------- /login/login/packets/base.py: -------------------------------------------------------------------------------- 1 | from common.packet import Packet 2 | 3 | 4 | class LoginServerPacket(Packet): 5 | def encode(self, session, strings_format="utf-16-le"): 6 | return super().encode(session, strings_format="utf-16-le") 7 | 8 | @classmethod 9 | def parse(cls, data, client): 10 | pass 11 | 12 | @classmethod 13 | def decode(cls, data, client, **kwargs): 14 | packet_type = data[0] 15 | packet_cls = cls.mapper.get(packet_type) 16 | if packet_cls: 17 | return packet_cls.parse(data, client) 18 | -------------------------------------------------------------------------------- /common/common/client/login_client.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from aiojsonapi.client import ApiClient 4 | 5 | 6 | class LoginClient(ApiClient): 7 | async def auth_login(self, login, login_ok1, login_ok2, play_ok1, play_ok2): 8 | return await self.post( 9 | "api/login/auth_login", 10 | json_data={ 11 | "login": login, 12 | "login_ok1": login_ok1, 13 | "login_ok2": login_ok2, 14 | "play_ok1": play_ok1, 15 | "play_ok2": play_ok2, 16 | }, 17 | ) 18 | -------------------------------------------------------------------------------- /game/game/models/structures/character/appearance.py: -------------------------------------------------------------------------------- 1 | from pydantic import validator 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class CharacterAppearance(BaseModel): 8 | face_id: ctype.int32 9 | hair_style: ctype.int32 10 | hair_color: ctype.int32 11 | sex: ctype.bool # 0 = male, 1 = female 12 | 13 | @classmethod 14 | @validator("sex") 15 | def validate_male_or_female(cls, v): 16 | if int(v) not in [0, 1]: 17 | raise ValueError("Sex value only can be 0 or 1") 18 | return v 19 | -------------------------------------------------------------------------------- /game/game/models/structures/object/object.py: -------------------------------------------------------------------------------- 1 | from pydantic import Extra, Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | from game.models.structures.object.poly import ObjectPolymorph 6 | from game.models.structures.object.position import Position 7 | 8 | 9 | class L2Object(BaseModel): 10 | id: ctype.int32 11 | name: str 12 | position: Position 13 | is_visible: ctype.bool 14 | poly: ObjectPolymorph = Field(default_factory=ObjectPolymorph) 15 | 16 | class Config(BaseModel.Config): 17 | extra = Extra.ignore 18 | -------------------------------------------------------------------------------- /common/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ENV PATH $PATH:/root/.local/bin 4 | ENV PYTHONPATH /code 5 | ENV PYTHONBUFFERED 1 6 | 7 | ADD common /code/common/ 8 | ADD pyproject.toml /code/ 9 | ADD bin/sitecustomize.py /code 10 | 11 | WORKDIR /code 12 | 13 | RUN pip install --upgrade pip 14 | RUN apt-get update && apt-get install -y build-essential swig 15 | 16 | RUN curl -sSL https://install.python-poetry.org | python3 - 17 | 18 | RUN poetry config virtualenvs.in-project true 19 | RUN poetry config experimental.new-installer false 20 | RUN poetry install --no-interaction --no-ansi 21 | -------------------------------------------------------------------------------- /game/game/config.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | from common.config import Config 5 | 6 | 7 | class GameConfig(Config): 8 | def __init__(self): 9 | super().__init__() 10 | self.GAME_SERVER_HOST = os.environ.get("GAME_SERVER_HOST", "0.0.0.0") 11 | self.GAME_SERVER_PORT = os.environ.get("GAME_SERVER_PORT", 7777) 12 | self.GAME_SERVER_API_HOST = os.environ.get("GAME_SERVER_API_HOST", "0.0.0.0") 13 | self.GAME_SERVER_API_PORT = os.environ.get("GAME_SERVER_API_PORT", 7778) 14 | self.GAME_SERVER_ID = os.environ["GAME_SERVER_ID"] 15 | -------------------------------------------------------------------------------- /login/login/constants.py: -------------------------------------------------------------------------------- 1 | REQUEST_GG_AUTH = 7 2 | REQUEST_SERVER_LIST = 5 3 | REQUEST_AUTH_LOGIN = 0 4 | REQUEST_SERVER_LOGIN = 2 5 | 6 | LOGIN_FAIL_SYSTEM_ERROR = 1 7 | LOGIN_FAIL_WRONG_PASSWORD = 2 8 | LOGIN_FAIL_WRONG_LOGIN_OR_PASSWORD = 2 9 | LOGIN_FAIL_ACCESS_DENIED = 4 10 | LOGIN_FAIL_DATABASE_ERROR = 5 11 | LOGIN_FAIL_ACCOUNT_ALREADY_IN_USE = 7 12 | LOGIN_FAIL_ACCOUNT_BANNED = 9 13 | LOGIN_FAIL_MAINTENANCE = 16 14 | LOGIN_FAIL_EXPIRED = 18 15 | LOGIN_FAIL_TIME_IS_UP = 19 16 | 17 | 18 | PLAY_FAIL_PASSWORD_MISMATCH = 2 19 | PLAY_FAIL_ACCESS_DENIED = 4 20 | PLAY_FAIL_TOO_MANY_USERS = 15 21 | -------------------------------------------------------------------------------- /game/game/packets/move_to_pawn.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.models.character import Character 5 | from game.models.structures.object.object import L2Object 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class MoveToPawn(GameServerPacket): 10 | type: ctype.int8 = 96 11 | character: Character 12 | object: L2Object 13 | 14 | def encode(self, session): 15 | encoded = self.type.encode() 16 | 17 | ordered_data = [ 18 | self.character.id, 19 | self.object.id, 20 | ] 21 | -------------------------------------------------------------------------------- /login/login/middleware/padding.py: -------------------------------------------------------------------------------- 1 | from common.middleware.middleware import Middleware 2 | from common.response import Response 3 | from login.session import LoginSession 4 | 5 | 6 | class PaddingMiddleware(Middleware): 7 | @classmethod 8 | def after(cls, session: LoginSession, response: Response): 9 | pad_length = 4 10 | 11 | if not session.xor_key.initiated: 12 | pad_length += 4 13 | session.xor_key.initiated = True 14 | 15 | pad_length += 8 - (len(response.data) + pad_length) % 8 16 | response.data.extend(b"\x00" * pad_length) 17 | -------------------------------------------------------------------------------- /login/login/packets/init.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from login.packets.base import LoginServerPacket 6 | 7 | 8 | class Init(LoginServerPacket): 9 | type: ctype.char = 0 10 | session_id: ctype.int32 11 | protocol_version: ctype.int32 12 | rsa_key: bytes 13 | unknown1: ctype.uint = 0x29DD954E 14 | unknown2: ctype.uint = 0x77C39CFC 15 | unknown3: ctype.uint = 0x97ADB620 16 | unknown4: ctype.uint = 0x07BDE0F7 17 | blowfish_key: bytes 18 | null_termination: ctype.char = 0 19 | -------------------------------------------------------------------------------- /common/common/request.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from pydantic import Field 4 | 5 | from common.model import BaseModel 6 | from common.session import Session 7 | 8 | 9 | class Request(BaseModel): 10 | raw_data: bytearray # Data received from socket 11 | session: Session # Client connection session 12 | data: bytearray = Field(default_factory=bytearray) # Data modified during processing 13 | validated_data: typing.Dict[str, typing.Any] = Field(default_factory=dict) 14 | 15 | def __init__(self, **kwargs): 16 | super().__init__(**kwargs) 17 | self.data = self.raw_data 18 | -------------------------------------------------------------------------------- /game/game/packets/ally_crest.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from game.models.crest import Crest 5 | 6 | from .base import GameServerPacket 7 | 8 | 9 | class AllyCrest(GameServerPacket): 10 | type: ctype.int8 = 174 11 | crest: Crest 12 | 13 | def encode(self, session): 14 | encoded = self.type.encode() 15 | 16 | ordered_data = [ 17 | self.crest.id, 18 | self.creast.size, 19 | self.crest.data, 20 | ] 21 | for item in ordered_data: 22 | encoded.append(item) 23 | return encoded 24 | -------------------------------------------------------------------------------- /common/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "common" 3 | version = "0.1.0" 4 | description = "Lineage2 Interlude game server emulator" 5 | authors = ["Yury Sokov "] 6 | license = "MIT" 7 | packages = [ 8 | {include = "common", from = "."}, 9 | ] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | aiojsonapi = "^0.3.1633554756" 14 | motor = "^3.0.0" 15 | typeguard = "^2.13.3" 16 | python-dotenv = "^0.19.2" 17 | pydantic = "^1.10.7" 18 | 19 | [tool.poetry.dev-dependencies] 20 | 21 | 22 | [build-system] 23 | requires = ["poetry-core>=1.0.0", "poetry>=1.1.12"] 24 | build-backend = "poetry.core.masonry.api" 25 | -------------------------------------------------------------------------------- /game/game/packets/restart_response.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | 8 | class RestartResponse(GameServerPacket): 9 | type: ctype.int8 = 95 10 | ok: ctype.int32 = 0 11 | message: str 12 | 13 | def encode(self, session): 14 | encoded = bytearray() 15 | 16 | extend_bytearray( 17 | encoded, 18 | [ 19 | self.type, 20 | self.ok, 21 | self.message, 22 | ], 23 | ) 24 | 25 | return encoded 26 | -------------------------------------------------------------------------------- /game/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "game" 3 | version = "0.1.0" 4 | description = "Lineage2 Interlude game server emulator" 5 | authors = ["Yury Sokov "] 6 | license = "MIT" 7 | packages = [ 8 | {include = "game", from = "."}, 9 | ] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | pycryptodomex = "^3.12.0" 14 | aiojsonapi = "^0.3.1633554756" 15 | APScheduler = "^3.8.1" 16 | motor = "^3.0.0" 17 | typeguard = "^2.13.3" 18 | python-dotenv = "^0.19.2" 19 | pydantic = "^1.10.7" 20 | 21 | [tool.poetry.dev-dependencies] 22 | 23 | [build-system] 24 | requires = ["poetry-core>=1.0.0"] 25 | build-backend = "poetry.core.masonry.api" 26 | -------------------------------------------------------------------------------- /common/common/packet.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class Packet(BaseModel): 8 | type: ctype.int8 9 | 10 | @abstractmethod 11 | def encode(self, session, strings_format="utf-8"): 12 | return super().encode(strings_format=strings_format) 13 | 14 | @classmethod 15 | @abstractmethod 16 | def parse(cls, data, client): 17 | pass 18 | 19 | @classmethod 20 | @abstractmethod 21 | def decode(cls, data, client, **kwargs): 22 | pass 23 | 24 | def __repr__(self): 25 | return f"{self.__class__.__name__}({self.__dict__})" 26 | -------------------------------------------------------------------------------- /game/game/packets/delete_object.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.object.object import L2Object 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class DeleteObject(GameServerPacket): 10 | type: ctype.int8 = 18 11 | obj: L2Object 12 | 13 | def encode(self, session): 14 | encoded = bytearray() 15 | extend_bytearray( 16 | encoded, 17 | [ 18 | self.type, 19 | self.obj.id, 20 | ctype.int32(0), 21 | ], 22 | ) 23 | return encoded 24 | -------------------------------------------------------------------------------- /common/common/exceptions.py: -------------------------------------------------------------------------------- 1 | class Error(Exception): 2 | def __init__(self, message): 3 | super().__init__(message) 4 | 5 | 6 | class ChecksumMismatch(Error): 7 | def __init__(self): 8 | super().__init__("Checksum mismatch.") 9 | 10 | 11 | class RequestLengthDoesntMatch(Error): 12 | def __init__(self): 13 | super().__init__("Requests length byte value doesn't match actual data size.") 14 | 15 | 16 | class UnknownAction(Error): 17 | def __init__(self): 18 | super().__init__("Unknown action.") 19 | 20 | 21 | class DocumentNotFound(Error): 22 | def __init__(self): 23 | super().__init__("Specified document doesn't exist.") 24 | -------------------------------------------------------------------------------- /game/game/packets/char_templates.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from game.static.character_template import StaticCharacterTemplate 7 | 8 | from .base import GameServerPacket 9 | 10 | 11 | class CharTemplates(GameServerPacket): 12 | type: ctype.int8 = 23 13 | templates: list[StaticCharacterTemplate] = Field(default_factory=list) 14 | 15 | def encode(self, session): 16 | result = bytearray(self.type) 17 | result.extend(ctype.int32(len(self.templates))) 18 | 19 | for template in self.templates: 20 | result.extend(template.encode()) 21 | return result 22 | -------------------------------------------------------------------------------- /game/game/static/cache.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import defaultdict 3 | from typing import ClassVar 4 | 5 | from common.json import JsonDecoder 6 | from common.misc import Singleton 7 | from common.model import BaseModel 8 | 9 | 10 | class StaticDataCache(BaseModel): 11 | data: ClassVar[defaultdict] = defaultdict(list) 12 | _instance = None 13 | 14 | def read(self, filepath, static_model): 15 | if filepath not in self.data: 16 | with open(filepath) as file: 17 | for item in json.loads(file.read(), cls=JsonDecoder): 18 | self.data[filepath].append(static_model(**item)) 19 | return self.data[filepath] 20 | -------------------------------------------------------------------------------- /login/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "login" 3 | version = "0.1.0" 4 | description = "Lineage2 Interlude game server emulator" 5 | authors = ["Yury Sokov "] 6 | license = "MIT" 7 | packages = [ 8 | {include = "login", from = "."}, 9 | ] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.10" 13 | pycryptodomex = "^3.12.0" 14 | aiojsonapi = "^0.3.1633554756" 15 | APScheduler = "^3.8.1" 16 | motor = "^3.0.0" 17 | blowfish = "^0.6.1" 18 | typeguard = "^2.13.3" 19 | python-dotenv = "^0.19.2" 20 | pydantic = "^1.10.7" 21 | 22 | [tool.poetry.dev-dependencies] 23 | 24 | 25 | [build-system] 26 | requires = ["poetry-core>=1.0.0"] 27 | build-backend = "poetry.core.masonry.api" 28 | -------------------------------------------------------------------------------- /game/game/models/structures/character/equipped_items.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class EquippedItems(BaseModel): 6 | under: ctype.int32 = 0 7 | left_ear: ctype.int32 = 0 8 | right_ear: ctype.int32 = 0 9 | necklace: ctype.int32 = 0 10 | right_finger: ctype.int32 = 0 11 | left_finger: ctype.int32 = 0 12 | head: ctype.int32 = 0 13 | right_hand: ctype.int32 = 0 14 | left_hand: ctype.int32 = 0 15 | gloves: ctype.int32 = 0 16 | chest: ctype.int32 = 0 17 | legs: ctype.int32 = 0 18 | feet: ctype.int32 = 0 19 | back: ctype.int32 = 0 20 | double_handed: ctype.int32 = 0 21 | hair: ctype.int32 = 0 22 | -------------------------------------------------------------------------------- /game/game/api/party.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.states 3 | from common.api_handlers import l2_request_handler 4 | from common.ctype import ctype 5 | from common.misc import decode_str 6 | from common.template import Parameter, Template 7 | 8 | 9 | @l2_request_handler( 10 | game.constants.GAME_REQUEST_JOIN_PARTY, 11 | Template( 12 | [ 13 | Parameter( 14 | id="name", 15 | start=0, 16 | type=str, 17 | func=decode_str(), 18 | ), 19 | Parameter(id="item_distribution", start="$text.stop", length=4, type=ctype.int32), 20 | ] 21 | ), 22 | ) 23 | async def join_party(request): 24 | pass 25 | -------------------------------------------------------------------------------- /game/game/packets/teleport_to_location.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | 5 | from ..models.structures.object.position import Position 6 | from .base import GameServerPacket 7 | 8 | 9 | class TeleportToLocation(GameServerPacket): 10 | type: ctype.int8 = 56 11 | 12 | character_id: ctype.int32 13 | position: Position 14 | 15 | def encode(self, session): 16 | encoded = bytearray(self.type) 17 | 18 | encoded.extend( 19 | [ 20 | self.character_id, 21 | self.position.point3d.x, 22 | self.position.point3d.y, 23 | self.position.point3d.z, 24 | ] 25 | ) 26 | return encoded 27 | -------------------------------------------------------------------------------- /common/common/application.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import sys 4 | 5 | LOG = logging.getLogger(f"L2py.{__name__}") 6 | 7 | 8 | class Application: 9 | def __init__(self, modules): 10 | self.modules = modules 11 | 12 | def run(self, config, loop=None, log_level=logging.INFO, cleanup_task=None): 13 | logging.basicConfig(stream=sys.stdout, level=log_level) 14 | 15 | loop = loop if loop is not None else asyncio.get_event_loop() 16 | for module in self.modules: 17 | module.start(config=config.get(module.name, {}), loop=loop) 18 | try: 19 | loop.run_forever() 20 | except KeyboardInterrupt: 21 | if cleanup_task: 22 | cleanup_task() 23 | raise 24 | -------------------------------------------------------------------------------- /game/game/models/structures/npc/template.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from game.models.structures.character.template import CharacterTemplate 3 | 4 | 5 | class NpcTemplate(CharacterTemplate): 6 | id: ctype.int32 7 | template_id: ctype.int32 8 | type: str 9 | name: str 10 | server_side_name: ctype.bool 11 | title: str 12 | server_side_title: ctype.bool 13 | sex: str 14 | level: ctype.int8 15 | reward_exp: ctype.int32 16 | reward_sp: ctype.int32 17 | aggro_range: ctype.int32 18 | right_hand: ctype.int32 19 | left_hand: ctype.int32 20 | armor: ctype.int32 21 | faction_id: str 22 | faction_range: ctype.int32 23 | absorb_level: ctype.int32 24 | absorb_type: ctype.int32 25 | race: ctype.int32 26 | -------------------------------------------------------------------------------- /game/game/packets/friend_message.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | 8 | class FriendMessage(GameServerPacket): 9 | type: ctype.int8 = 253 10 | recipient_name: str 11 | sender_name: str 12 | message: str 13 | 14 | def encode(self, session): 15 | encoded = bytearray(self.type) 16 | 17 | extend_bytearray( 18 | encoded, 19 | [ 20 | ctype.int32(0), # not used, doesn't work without it 21 | self.recipient_name, 22 | self.sender_name, 23 | self.message, 24 | ], 25 | ) 26 | 27 | return encoded 28 | -------------------------------------------------------------------------------- /game/game/packets/creature_say.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | 8 | class CreatureSay(GameServerPacket): 9 | type: ctype.int8 = 74 10 | object_id: ctype.int32 11 | text_type: ctype.int32 12 | character_name: str 13 | text: str 14 | 15 | def encode(self, session): 16 | encoded = bytearray() 17 | 18 | extend_bytearray( 19 | encoded, 20 | [ 21 | self.type, 22 | self.object_id, 23 | self.text_type, 24 | self.character_name, 25 | self.text, 26 | ], 27 | ) 28 | 29 | return encoded 30 | -------------------------------------------------------------------------------- /game/game/packets/system_message.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.system_message import SystemMessage 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class SystemMessagePacket(GameServerPacket): 10 | type: ctype.int8 = 100 11 | message: SystemMessage 12 | 13 | def encode(self, session): 14 | encoded = bytearray(self.type) 15 | 16 | if not self.message: 17 | return encoded 18 | 19 | extend_bytearray(encoded, [self.message.type, ctype.int32(len(self.message.data))]) 20 | 21 | for data in self.message.data: 22 | extend_bytearray(encoded, [data.type, *data.value]) 23 | 24 | return encoded 25 | -------------------------------------------------------------------------------- /login/login/middleware/encryption.py: -------------------------------------------------------------------------------- 1 | from common.middleware.middleware import Middleware 2 | from common.request import Request 3 | from common.response import Response 4 | from login.session import LoginSession 5 | 6 | 7 | class EncryptionMiddleware(Middleware): 8 | @classmethod 9 | def before(cls, session: LoginSession, request: Request): 10 | """Decrypts request data.""" 11 | 12 | if session.blowfish_enabled: 13 | request.data = session.blowfish_key.decrypt(request.data) 14 | 15 | @classmethod 16 | def after(cls, session: LoginSession, response: Response): 17 | """Encrypts response data.""" 18 | 19 | response.data = session.blowfish_key.encrypt( 20 | response.data, static_key=not session.blowfish_enabled 21 | ) 22 | -------------------------------------------------------------------------------- /common/common/session.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from threading import Lock 3 | 4 | 5 | class Session: 6 | state: type 7 | STORAGE: dict 8 | protocol: "common.transport.protocol.TCPProtocol" 9 | 10 | def __init__(self): 11 | self.uuid = uuid.uuid4() 12 | self.lock_before = Lock() 13 | self.lock_after = Lock() 14 | 15 | self.account: "Account" = None 16 | 17 | def set_state(self, new_state): 18 | """Sets new session state. 19 | 20 | :param new_state: 21 | :return: 22 | """ 23 | self.state = new_state 24 | 25 | def send_packet(self, packet): 26 | from common.response import Response 27 | 28 | response = Response(packet=packet, session=self) 29 | return self.protocol.transport.write(response) 30 | -------------------------------------------------------------------------------- /game/game/models/structures/character/effects.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Effects(BaseModel): 6 | is_afraid: ctype.bool = False 7 | is_confused: ctype.bool = False 8 | is_faking_death: ctype.bool = False 9 | is_flying: ctype.bool = False 10 | is_muted: ctype.bool = False 11 | is_physically_muted: ctype.bool = False 12 | is_dead: ctype.bool = False 13 | is_immobilized: ctype.bool = False 14 | is_overloaded: ctype.bool = False 15 | is_paralyzed: ctype.bool = False 16 | is_riding: ctype.bool = False 17 | is_pending_revive: ctype.bool = False 18 | is_rooted: ctype.bool = False 19 | is_sleeping: ctype.bool = False 20 | is_stunned: ctype.bool = False 21 | is_betrayed: ctype.bool = False 22 | -------------------------------------------------------------------------------- /game/game/api/say.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.states 3 | from common.api_handlers import l2_request_handler 4 | from common.ctype import ctype 5 | from common.misc import decode_str 6 | from common.template import Parameter, Template 7 | 8 | 9 | @l2_request_handler( 10 | game.constants.GAME_REQUEST_SAY2, 11 | Template( 12 | [ 13 | Parameter( 14 | id="text", 15 | start=0, 16 | type=str, 17 | func=decode_str(), 18 | ), 19 | Parameter(id="type", start="$text.stop", length=4, type=ctype.int32), 20 | ] 21 | ), 22 | ) 23 | async def say2(request): 24 | await request.session.character.say( 25 | request.validated_data["type"], request.validated_data["text"] 26 | ) 27 | -------------------------------------------------------------------------------- /game/game/packets/shortcut_register.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.shortcut import Shortcut 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class ShortcutRegister(GameServerPacket): 10 | type: ctype.int8 = 68 # 0x44 11 | shortcut: Shortcut 12 | 13 | def encode(self, session): 14 | encoded = bytearray() 15 | extend_bytearray( 16 | encoded, 17 | [ 18 | self.type, 19 | self.shortcut.type, # (item=1, skill=2, action=3, macro=4, recipe=5) 20 | self.shortcut.slot + self.shortcut.page * 12, 21 | self.shortcut.id, 22 | ], 23 | ) 24 | 25 | return encoded 26 | -------------------------------------------------------------------------------- /common/common/middleware/length.py: -------------------------------------------------------------------------------- 1 | from common import exceptions 2 | from common.ctype import ctype 3 | from common.middleware.middleware import Middleware 4 | from common.request import Request 5 | from common.response import Response 6 | from common.session import Session 7 | 8 | 9 | class DataLengthMiddleware(Middleware): 10 | @classmethod 11 | def before(cls, session: Session, request: Request): 12 | packet_length = ctype.uint16(request.data[:2]) 13 | 14 | if packet_length != len(request.data): 15 | raise exceptions.RequestLengthDoesntMatch() 16 | request.data = request.data[2:] 17 | 18 | @classmethod 19 | def after(cls, session: Session, response: Response): 20 | packet_len = ctype.int16(2 + len(response.data)) 21 | response.data = bytearray(bytes(packet_len) + response.data) 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: PR static code check 2 | 3 | on: 4 | pull_request: 5 | types: [assigned, opened, synchronize, reopened] 6 | concurrency: 7 | group: ${{ github.head_ref }} 8 | cancel-in-progress: true 9 | jobs: 10 | isort: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Prepare environment 17 | uses: ./.github/actions/poetry 18 | 19 | - name: Check imports with isort 20 | shell: bash 21 | run: | 22 | poetry run isort -c . 23 | 24 | black: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v2 29 | 30 | - name: Prepare environment 31 | uses: ./.github/actions/poetry 32 | 33 | - name: Check formatting with black 34 | shell: bash 35 | run: | 36 | poetry run black --check . 37 | -------------------------------------------------------------------------------- /game/game/application.py: -------------------------------------------------------------------------------- 1 | import common 2 | import common.middleware 3 | from common.application import Application 4 | from common.application_modules.http import HTTPServerModule 5 | from common.application_modules.scheduler import ScheduleModule 6 | from common.application_modules.tcp import TCPServerModule 7 | from common.json import JsonEncoder 8 | from game.middleware.xor import XORGameMiddleware 9 | from game.protocol import Lineage2GameProtocol 10 | 11 | GAME_SERVER_APPLICATION = Application( 12 | [ 13 | TCPServerModule( 14 | "game_tcp", 15 | Lineage2GameProtocol, 16 | middleware=[ 17 | common.middleware.length.DataLengthMiddleware, 18 | XORGameMiddleware, 19 | ], 20 | ), 21 | HTTPServerModule("game_web", json_encoder=JsonEncoder), 22 | ScheduleModule(), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /game/game/packets/change_wait_type.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | 6 | from ..models.structures.object.position import Position 7 | from .base import GameServerPacket 8 | 9 | 10 | class ChangeWaitType(GameServerPacket): 11 | type: ctype.int8 = 47 12 | 13 | character_id: ctype.int32 14 | move_type: ctype.int32 15 | position: Position 16 | 17 | def encode(self, session): 18 | encoded = bytearray() 19 | 20 | extend_bytearray( 21 | encoded, 22 | [ 23 | self.type, 24 | self.character_id, 25 | self.move_type, 26 | self.position.point3d.x, 27 | self.position.point3d.y, 28 | self.position.point3d.z, 29 | ], 30 | ) 31 | return encoded 32 | -------------------------------------------------------------------------------- /login/login/runner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import common # noqa: F401 4 | import login.api # noqa: F401 5 | from login.application import LOGIN_SERVER_APPLICATION 6 | from login.config import ( 7 | LOGIN_SERVER_API_HOST, 8 | LOGIN_SERVER_API_PORT, 9 | LOGIN_SERVER_HOST, 10 | LOGIN_SERVER_PORT, 11 | loop, 12 | ) 13 | 14 | LOG = logging.getLogger(f"L2py.login") 15 | 16 | 17 | def main(): 18 | LOGIN_SERVER_APPLICATION.run( 19 | { 20 | "login_web": { 21 | "host": LOGIN_SERVER_API_HOST, 22 | "port": LOGIN_SERVER_API_PORT, 23 | }, 24 | "login_tcp": { 25 | "host": LOGIN_SERVER_HOST, 26 | "port": LOGIN_SERVER_PORT, 27 | }, 28 | }, 29 | loop=loop, 30 | log_level=logging.DEBUG, 31 | ) 32 | 33 | 34 | if __name__ == "__main__": 35 | main() 36 | -------------------------------------------------------------------------------- /common/common/config.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from functools import cached_property 4 | 5 | import dotenv 6 | from bson import ObjectId 7 | from pydantic import Extra 8 | 9 | from common.ctype import _CType, ctype 10 | from common.misc import Singleton 11 | 12 | dotenv.load_dotenv() 13 | 14 | 15 | class Config(metaclass=Singleton): 16 | def __init__(self): 17 | self.loop = asyncio.get_event_loop() 18 | self.MONGO_URI = os.environ.get("MONGO_URI", "localhost") 19 | 20 | 21 | class PydanticConfig: 22 | validate_all = True 23 | extra = Extra.forbid 24 | validate_assignment = True 25 | underscore_attrs_are_private = True 26 | arbitrary_types_allowed = True 27 | 28 | json_encoders = { 29 | ctype.char: lambda value: int.from_bytes(value.value, "big"), 30 | _CType: lambda value: value.value, 31 | ObjectId: lambda value: str(value), 32 | } 33 | -------------------------------------------------------------------------------- /game/game/packets/target_unselected.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.object.position import Position 6 | from game.packets.base import GameServerPacket 7 | 8 | if TYPE_CHECKING: 9 | from game.session import GameSession 10 | 11 | 12 | class TargetUnselected(GameServerPacket): 13 | type: ctype.int8 = 42 14 | target_id: ctype.int32 15 | position: Position 16 | 17 | def encode(self, session: "GameSession"): 18 | encoded = bytearray() 19 | 20 | extend_bytearray( 21 | encoded, 22 | [ 23 | self.type, 24 | self.target_id, 25 | self.position.point3d.x, 26 | self.position.point3d.y, 27 | self.position.point3d.z, 28 | ], 29 | ) 30 | return encoded 31 | -------------------------------------------------------------------------------- /game/game/models/structures/item/weapon.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from game.models.structures.skill.skill import Skill 7 | 8 | from .item import Item 9 | 10 | 11 | class Weapon(Item): 12 | soulshot_count: ctype.int32 13 | spiritshot_count: ctype.int32 14 | physical_damage: ctype.int32 15 | random_damage: ctype.int32 16 | critical: ctype.int32 17 | hit_modifier: ctype.double 18 | avoid_modifier: ctype.int32 19 | shield_defense_rate: ctype.double 20 | attack_speed: ctype.int32 21 | attack_reuse: ctype.int32 22 | mp_consumption: ctype.int32 23 | magic_damage: ctype.int32 24 | 25 | passive_skill: typing.Union[None, Skill] = None 26 | enchant4_skill: typing.Union[None, Skill] = None 27 | 28 | skills_on_hit: list[Skill] = Field(default_factory=list) 29 | skills_on_cast: list[Skill] = Field(default_factory=list) 30 | -------------------------------------------------------------------------------- /.github/actions/poetry/action.yml: -------------------------------------------------------------------------------- 1 | name: poetry 2 | description: Prepares poetry evnironment 3 | runs: 4 | using: composite 5 | 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: Set up Python 3.11 9 | uses: actions/setup-python@v2 10 | with: 11 | python-version: '3.11' 12 | 13 | - name: Install and configure Poetry 14 | shell: bash 15 | run: | 16 | curl -sSL https://install.python-poetry.org | python3.10 - 17 | export PATH="$HOME/.poetry/bin:$PATH" 18 | poetry config virtualenvs.in-project true 19 | 20 | - name: Check for poetry cache 21 | uses: actions/cache@v2 22 | id: cache-venv 23 | with: 24 | path: .venv 25 | key: ${{ runner.os }}-venv-${{ hashFiles('**/poetry.lock') }} 26 | 27 | - name: Make poetry venv 28 | shell: bash 29 | run: | 30 | poetry install 31 | if: steps.cache-venv.outputs.cache-hit != 'true' 32 | -------------------------------------------------------------------------------- /game/game/packets/target_selected.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.object.object import L2Object 6 | from game.packets.base import GameServerPacket 7 | 8 | if TYPE_CHECKING: 9 | from game.session import GameSession 10 | 11 | 12 | class TargetSelected(GameServerPacket): 13 | type: ctype.int8 = 41 14 | me: L2Object 15 | target: L2Object 16 | 17 | def encode(self, session: "GameSession"): 18 | encoded = bytearray() 19 | 20 | extend_bytearray( 21 | encoded, 22 | [ 23 | self.type, 24 | self.me.id, 25 | self.target.id, 26 | self.me.position.point3d.x, 27 | self.me.position.point3d.y, 28 | self.me.position.point3d.z, 29 | ], 30 | ) 31 | return encoded 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "l2py" 3 | version = "0.1.0" 4 | description = "Lineage2 Interlude server emulator" 5 | authors = ["Yury Sokov "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.11" 10 | pycryptodomex = "^3.12.0" 11 | aiojsonapi = "^0.3.1633554756" 12 | APScheduler = "^3.8.1" 13 | motor = "^3.0.0" 14 | blowfish = "^0.6.1" 15 | python-dotenv = "^0.19.2" 16 | typeguard = "^2.13.3" 17 | pydantic = "^1.10.7" 18 | aio-pika = "^9.0.5" 19 | 20 | [tool.poetry.dev-dependencies] 21 | black = "^22.3" 22 | isort = "^5.10.1" 23 | pylint = "^2.12.2" 24 | pytest = "^6.2.5" 25 | 26 | [build-system] 27 | requires = ["poetry-core>=1.0.0"] 28 | build-backend = "poetry.core.masonry.api" 29 | 30 | [tool.isort] 31 | profile = "black" 32 | multi_line_output = 3 33 | line_length = 99 34 | 35 | [tool.black] 36 | line_length = 99 37 | target_version = ["py310"] 38 | 39 | [tool.pylint] 40 | target_versions = ["py310"] 41 | max-line-length = 99 42 | -------------------------------------------------------------------------------- /game/game/packets/etc_status_update.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | if TYPE_CHECKING: 8 | from game.models.character import Character 9 | from game.session import GameSession 10 | 11 | 12 | class EtcStatusUpdate(GameServerPacket): 13 | type: ctype.int8 = 243 14 | character: "Character" 15 | 16 | def encode(self, session: "GameSession"): 17 | encoded = bytearray() 18 | 19 | extend_bytearray( 20 | encoded, 21 | [ 22 | self.type, 23 | self.character.weight_penalty, 24 | ctype.int32(0), 25 | ctype.int32(0), 26 | self.character.exp_penalty, 27 | self.character.exp_protected, 28 | self.character.death_penalty, 29 | ], 30 | ) 31 | return encoded 32 | -------------------------------------------------------------------------------- /login/login/application.py: -------------------------------------------------------------------------------- 1 | import common 2 | import common.middleware.length 3 | import login.middleware 4 | from common.application import Application 5 | from common.application_modules.http import HTTPServerModule 6 | from common.application_modules.tcp import TCPServerModule 7 | from common.json import JsonDecoder, JsonEncoder 8 | from login.protocol import Lineage2LoginProtocol 9 | 10 | LOGIN_SERVER_APPLICATION = Application( 11 | [ 12 | HTTPServerModule("login_web", json_encoder=JsonEncoder, json_decoder=JsonDecoder), 13 | TCPServerModule( 14 | "login_tcp", 15 | Lineage2LoginProtocol, 16 | middleware=[ 17 | common.middleware.length.DataLengthMiddleware, 18 | login.middleware.EncryptionMiddleware, 19 | login.middleware.ChecksumMiddleware, 20 | login.middleware.XORMiddleware, 21 | login.middleware.PaddingMiddleware, 22 | ], 23 | ), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /game/game/api/attack.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.packets 3 | import game.states 4 | from common.api_handlers import l2_request_handler 5 | from common.ctype import ctype 6 | from common.response import Response 7 | from common.template import Parameter, Template 8 | from game.models.world import WORLD 9 | 10 | 11 | @l2_request_handler( 12 | game.constants.GAME_REQUEST_ATTACK, 13 | Template( 14 | [ 15 | Parameter(id="object_id", start=0, length=4, type=ctype.int), 16 | Parameter(id="shift_flag", start=16, length=1, type=ctype.bool), 17 | ] 18 | ), 19 | ) 20 | async def attack(request): 21 | character = request.session.character 22 | attack_packet = game.packets.Attack( 23 | character.id, 24 | False, 25 | ctype.int32(0), 26 | character.position, 27 | game.packets.Attack.Hit(target_id=request.validated_data["object_id"], damage=1), 28 | ) 29 | request.session.send_packet(attack_packet) 30 | WORLD.broadcast_attack(character, attack_packet) 31 | -------------------------------------------------------------------------------- /game/game/session.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from common.ctype import ctype 4 | from common.session import Session 5 | from game.keys.xor_key import GameXorKey 6 | from game.models.world import WORLD 7 | 8 | if TYPE_CHECKING: 9 | from game.models.character import Character 10 | 11 | 12 | class GameSession(Session): 13 | def __init__(self, protocol): 14 | super().__init__() 15 | 16 | self.id = ctype.int32.random() 17 | self.state = None 18 | self.protocol = protocol 19 | self.xor_key = GameXorKey() 20 | self.encryption_enabled = False 21 | self.session_id = None 22 | self.blowfish_enabled = False 23 | self.character: Character = None 24 | 25 | def set_character(self, character: "Character"): 26 | self.character = character 27 | 28 | def logout_character(self): 29 | if self.character is not None: 30 | WORLD.exit(self) 31 | self.character = None 32 | 33 | def __hash__(self): 34 | return hash(self.uuid) 35 | -------------------------------------------------------------------------------- /game/game/packets/ex_storage_max_count.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.packets.base import GameServerPacket 6 | 7 | if TYPE_CHECKING: 8 | from game.models.character import Character 9 | from game.session import GameSession 10 | 11 | 12 | class ExStorageMaxCount(GameServerPacket): 13 | type: ctype.int8 = 254 14 | character: "Character" 15 | 16 | def encode(self, session: "GameSession"): 17 | encoded = bytearray() 18 | 19 | extend_bytearray( 20 | encoded, 21 | [ 22 | self.type, 23 | ctype.int32(0x2E), 24 | self.character.inventory_max, 25 | self.character.warehouse_max, 26 | self.character.freight_max, 27 | self.character.private_sell_max, 28 | self.character.private_buy_max, 29 | self.character.dwarf_receipt_max, 30 | self.character.common_receipt_max, 31 | ], 32 | ) 33 | 34 | return encoded 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Yury Sokov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /game/game/keys/xor_key.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import random 3 | import typing 4 | 5 | from common.ctype import ctype 6 | 7 | 8 | class GameXorKey: 9 | CRYPT_KEYS_COUNT = 20 10 | CRYPT_KEYS: typing.List[bytearray] = [bytearray(b"\x00" * 16) for _ in range(CRYPT_KEYS_COUNT)] 11 | 12 | for i in range(CRYPT_KEYS_COUNT): 13 | # for j in range(len(CRYPT_KEYS[i])): 14 | # CRYPT_KEYS[i][j] = get_random(ctype.int8) 15 | 16 | CRYPT_KEYS[i][8]: ctype.int8 = 200 17 | CRYPT_KEYS[i][9]: ctype.int8 = 39 18 | CRYPT_KEYS[i][10]: ctype.int8 = 147 19 | CRYPT_KEYS[i][11]: ctype.int8 = 1 20 | CRYPT_KEYS[i][12]: ctype.int8 = 161 21 | CRYPT_KEYS[i][13]: ctype.int8 = 108 22 | CRYPT_KEYS[i][14]: ctype.int8 = 49 23 | CRYPT_KEYS[i][15]: ctype.int8 = 151 24 | 25 | def __init__(self): 26 | key = self.random_key() 27 | self.outgoing_key: bytearray = copy.deepcopy(key) 28 | self.incoming_key: bytearray = copy.deepcopy(key) 29 | 30 | @classmethod 31 | def random_key(cls) -> bytearray: 32 | return cls.CRYPT_KEYS[random.randrange(0, len(cls.CRYPT_KEYS))] 33 | -------------------------------------------------------------------------------- /game/game/packets/char_move_to_location.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.object.position import Position 6 | from game.packets.base import GameServerPacket 7 | from game.session import GameSession 8 | 9 | if TYPE_CHECKING: 10 | from game.models.character import Character 11 | 12 | 13 | class CharMoveToLocation(GameServerPacket): 14 | type: ctype.int8 = 1 15 | character: "Character" 16 | new_position: Position 17 | 18 | def encode(self, session: GameSession): 19 | encoded = bytearray() 20 | 21 | extend_bytearray( 22 | encoded, 23 | [ 24 | self.type, 25 | self.character.id, 26 | self.new_position.point3d.x, 27 | self.new_position.point3d.y, 28 | self.new_position.point3d.z, 29 | self.character.position.point3d.x, 30 | self.character.position.point3d.y, 31 | self.character.position.point3d.z, 32 | ], 33 | ) 34 | return encoded 35 | -------------------------------------------------------------------------------- /common/common/models/id_factory.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import ClassVar 3 | 4 | import bson 5 | 6 | from common.ctype import ctype 7 | from common.document import Document 8 | 9 | 10 | class IDFactory(Document): 11 | __collection__: ClassVar[str] = "id_factory" 12 | __database__: ClassVar[str] = "l2py" 13 | 14 | NAME_ITEMS: ClassVar[str] = "items" 15 | NAME_CHARACTERS: ClassVar[str] = "characters" 16 | NAME_ACCOUNTS: ClassVar[str] = "accounts" 17 | 18 | name: str 19 | counter: ctype.int32 = 1 20 | 21 | @classmethod 22 | async def get_new_id(cls, object_type_name: str): 23 | item_id_factory = await cls.one(add_query={"name": object_type_name}, required=False) 24 | if item_id_factory is None: 25 | item_id_factory = cls(name=object_type_name, _id=str(bson.ObjectId())) 26 | await item_id_factory.insert() 27 | asyncio.Task(item_id_factory.increment()) 28 | return item_id_factory.counter 29 | 30 | async def increment(self): 31 | self.collection().update_one( 32 | {self.primary_key_field_name: self.primary_key}, {"$inc": {"counter": 1}} 33 | ) 34 | -------------------------------------------------------------------------------- /login/login/middleware/xor.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.middleware.middleware import Middleware 3 | from login.packets.init import Init 4 | 5 | 6 | class XORMiddleware(Middleware): 7 | @staticmethod 8 | def encrypt(data: bytearray, key: ctype.int32): 9 | stop = len(data) - 8 10 | start = 4 11 | ecx = key 12 | 13 | for pos in range(start, stop, 4): 14 | edx = ctype.int32(data[pos : pos + 4]) 15 | 16 | ecx += edx 17 | edx ^= ecx 18 | 19 | data[pos : pos + 4] = bytes(edx) 20 | 21 | data[-8:-4] = bytes(ecx) 22 | 23 | @staticmethod 24 | def decrypt(data: bytearray, key: ctype.int32): 25 | stop = 2 26 | pos = len(data) - 12 27 | 28 | ecx = key 29 | 30 | while stop < pos: 31 | edx = data[pos : pos + 4] 32 | 33 | edx ^= ecx 34 | ecx -= edx 35 | 36 | data[pos : pos + 4] = bytes(edx) 37 | pos -= 4 38 | 39 | @classmethod 40 | def after(cls, session, response): 41 | if isinstance(response.packet, Init): 42 | cls.encrypt(response.data, session.xor_key.key) 43 | -------------------------------------------------------------------------------- /utils/xml_to_json.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import argparse 4 | import glob 5 | import json 6 | from xml.etree.ElementTree import fromstring 7 | 8 | from xmljson import parker 9 | 10 | from utils.common import JsonEncoder 11 | 12 | 13 | def convert_file_xml_to_json(file_path: str): 14 | with open(file_path) as xml_file: 15 | xml_data = fromstring(xml_file.read()) 16 | return json.dumps(parker.data(xml_data), cls=JsonEncoder, indent=4) 17 | 18 | 19 | def main(): 20 | parser = argparse.ArgumentParser() 21 | 22 | parser.add_argument("--file-path") 23 | parser.add_argument("--folder-path") 24 | 25 | args = parser.parse_args() 26 | if args.file_path: 27 | with open(args.file_path.replace("xml", "json"), "w") as json_file: 28 | json_file.write(convert_file_xml_to_json(args.file_path)) 29 | elif args.folder_path: 30 | for file_path in glob.glob(f"{args.folder_path}/*.xml"): 31 | print(file_path) 32 | with open(file_path.replace("xml", "json"), "w") as json_file: 33 | json_file.write(convert_file_xml_to_json(file_path)) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /game/game/packets/friend_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from common.misc import extend_bytearray 7 | from game.models.character import Character 8 | from game.packets.base import GameServerPacket 9 | 10 | 11 | class FriendList(GameServerPacket): 12 | type: ctype.int8 = 250 13 | friends: list[Character] = Field(default_factory=list) 14 | 15 | def encode(self, session): 16 | encoded = bytearray(self.type) 17 | 18 | if not self.friends: 19 | return encoded 20 | 21 | extend_bytearray(encoded, [ctype.int16(len(self.friends))]) 22 | 23 | for character in self.friends: 24 | is_online = ctype.int32(1) if character.session else ctype.int32(0) 25 | 26 | extend_bytearray( 27 | encoded, 28 | [ 29 | ctype.int16(0), # not used, doesn't work without it 30 | character.id, 31 | character.name, 32 | is_online, 33 | ctype.int16(0), # not used, doesn't work without it 34 | ], 35 | ) 36 | 37 | return encoded 38 | -------------------------------------------------------------------------------- /game/game/packets/acquire_skill_info.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | from .base import GameServerPacket 7 | 8 | 9 | class Requirement(BaseModel): 10 | item_id: ctype.int32 11 | count: ctype.int32 12 | type: ctype.int32 13 | unk: ctype.int32 14 | 15 | 16 | class AcquireSkillInfo(GameServerPacket): 17 | type: ctype.int8 = 193 18 | requirements: list[Requirement] 19 | id: ctype.int32 20 | level: ctype.int32 21 | sp_cost: ctype.int32 22 | mode: ctype.int32 23 | 24 | def encode(self, session): 25 | encoded = self.type.encode() 26 | 27 | ordered_data = [ 28 | self.id, 29 | self.level, 30 | self.sp_cost, 31 | self.mode, 32 | ctype.int32(len(self.requirements)), 33 | ] 34 | 35 | for requirement in self.requirements: 36 | ordered_data += [ 37 | requirement.type, 38 | requirement.item_id, 39 | requirement.count, 40 | requirement.unk, 41 | ] 42 | for item in ordered_data: 43 | encoded.append(item) 44 | return encoded 45 | -------------------------------------------------------------------------------- /common/common/models/game_server.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import ClassVar 3 | 4 | from common.ctype import ctype 5 | from common.document import Document 6 | 7 | 8 | class GameServer(Document): 9 | __collection__: ClassVar[str] = "game_servers" 10 | __database__: ClassVar[str] = "l2py" 11 | 12 | server_id: ctype.char 13 | host: str 14 | port: ctype.uint32 15 | 16 | age_limit: ctype.char = 13 17 | is_pvp: ctype.bool = False 18 | online_count: ctype.short = 0 19 | max_online: ctype.short = 1000 20 | is_online: ctype.bool = False 21 | type: ctype.int32 = 1 22 | brackets: ctype.char = False 23 | last_alive: ctype.long = 0 24 | 25 | @property 26 | def is_full(self) -> ctype.bool: 27 | return self.online_count >= self.max_online 28 | 29 | @property 30 | def host_as_bytearray(self) -> bytearray: 31 | return bytearray([int(i) for i in self.host.split(".")]) 32 | 33 | @property 34 | def server_is_alive(self) -> ctype.bool: 35 | return ctype.bool(self.last_alive >= time.time() - 15) 36 | 37 | @classmethod 38 | async def one(cls, server_id, **kwargs) -> "GameServer": 39 | return await super().one(add_query={"server_id": int(server_id)}, **kwargs) 40 | -------------------------------------------------------------------------------- /game/game/models/structures/object/playable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Optional 4 | 5 | import game.packets 6 | from game.broadcaster import Broadcaster 7 | from game.models.structures.object.object import L2Object 8 | 9 | 10 | class Playable(L2Object): 11 | target: Optional[Playable] = None 12 | 13 | @Broadcaster.broadcast(lambda self: game.packets.TargetSelected(me=self, target=self.target)) 14 | async def set_target(self, target: Playable): 15 | self.target = target 16 | 17 | @Broadcaster.broadcast( 18 | lambda self: game.packets.TargetUnselected(target_id=self.id, position=self.position) 19 | ) 20 | async def unset_target(self): 21 | self.target = None 22 | 23 | # @Broadcaster.broadcast(lambda: True) 24 | async def attack(self): 25 | pass 26 | 27 | @Broadcaster.broadcast( 28 | pass_args_kwargs=True, 29 | packet_constructor=lambda self, text_type, text: game.packets.CreatureSay( 30 | object_id=self.id, 31 | text_type=text_type, 32 | character_name=self.name, 33 | text=text, 34 | ), 35 | to_me=True, 36 | ) 37 | async def say(self, text_type, text): 38 | pass 39 | -------------------------------------------------------------------------------- /login/login/keys/blowfish.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from blowfish import Cipher 4 | 5 | 6 | class BlowfishKey: 7 | static = bytes( 8 | b"\x6b" 9 | b"\x60" 10 | b"\xCB" 11 | b"\x5b" 12 | b"\x82" 13 | b"\xce" 14 | b"\x90" 15 | b"\xb1" 16 | b"\xcc" 17 | b"\x2b" 18 | b"\x6c" 19 | b"\x55" 20 | b"\x6c" 21 | b"\x6c" 22 | b"\x6c" 23 | b"\x6c" 24 | ) 25 | 26 | def __init__(self, key=None): 27 | self.key = key if key else self.static 28 | self.static_encoder = Cipher(self.static, byte_order="little") 29 | 30 | @property 31 | def encoder(self): 32 | return Cipher(self.key, byte_order="little") 33 | 34 | @classmethod 35 | def generate(cls): 36 | return cls(os.urandom(16)) 37 | 38 | def decrypt(self, data): 39 | return bytearray(b"".join(self.encoder.decrypt_ecb(bytes(data)))) 40 | 41 | def encrypt(self, data: bytearray, static_key=False): 42 | if static_key: 43 | encrypted = bytearray(b"".join(self.static_encoder.encrypt_ecb(data))) 44 | else: 45 | encrypted = bytearray(b"".join(self.encoder.encrypt_ecb(bytes(data)))) 46 | return encrypted 47 | -------------------------------------------------------------------------------- /game/game/packets/item_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.item import Item 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class ItemList(GameServerPacket): 10 | type: ctype.int8 = 27 11 | 12 | items: list[Item] 13 | show_window: ctype.int8 = 0 14 | 15 | def encode(self, session): 16 | encoded = bytearray(self.type) 17 | 18 | encoded.extend(bytes(self.show_window)) 19 | encoded.extend(bytes(ctype.int8(len(self.items)))) 20 | 21 | for item in self.items: 22 | extend_bytearray( 23 | encoded, 24 | [ 25 | item.type, 26 | item.object_id, 27 | item.id, 28 | item.count, 29 | item.special_type, 30 | item.inventory_type, 31 | item.is_equipped, 32 | item.body_part, 33 | item.enchant_level, 34 | item.crystal_type, 35 | item.augmentation, 36 | item.mana, 37 | ], 38 | ) 39 | 40 | return encoded 41 | -------------------------------------------------------------------------------- /common/common/application_modules/tcp.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from common.application_modules.module import ApplicationModule 5 | 6 | LOG = logging.getLogger(f"L2py.{__name__}") 7 | 8 | 9 | class TCPServerModule(ApplicationModule): 10 | """TCP requests handler module. 11 | 12 | Serves requests passed to TCP port. 13 | """ 14 | 15 | def __init__(self, name, protocol, middleware=None): 16 | super().__init__(name) 17 | self.protocol = protocol 18 | self.middleware = middleware or [] 19 | self.loop = None 20 | 21 | def start(self, config, loop): 22 | async def inner_start(): 23 | server = await loop.create_server( 24 | lambda: self.protocol(self.loop, self.middleware), 25 | config["host"], 26 | config["port"], 27 | ) 28 | 29 | LOG.info( 30 | f"Starting L2 %s server on %s:%s", 31 | self.name, 32 | config["host"], 33 | config["port"], 34 | ) 35 | async with server: 36 | await server.start_serving() 37 | while True: 38 | await asyncio.sleep(3600) 39 | 40 | return loop.create_task(inner_start()) 41 | -------------------------------------------------------------------------------- /login/bin/register_game_server: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import argparse 4 | import asyncio 5 | 6 | import sitecustomize # noqa 7 | from common.models.game_server import GameServer 8 | 9 | 10 | async def create_server(args): 11 | 12 | server = GameServer( 13 | server_id=args.server_id, 14 | host=str(args.host), 15 | port=args.port, 16 | is_pvp=args.pvp if args.pvp is not None else False, 17 | max_online=args.max_online if args.max_online is not None else 1000, 18 | brackets=args.brackets if args.brackets is not None else False, 19 | ) 20 | 21 | await server.insert() 22 | print(server.dict()) 23 | 24 | 25 | def main(): 26 | parser = argparse.ArgumentParser() 27 | 28 | parser.add_argument("host", help="game server host IP address") 29 | parser.add_argument("port", help="game server port", type=int) 30 | parser.add_argument("server_id", help="game server id", type=int) 31 | parser.add_argument("--pvp") 32 | parser.add_argument("--max-online", type=int) 33 | parser.add_argument("--brackets", type=bool) 34 | 35 | args = parser.parse_args() 36 | 37 | loop = asyncio.new_event_loop() 38 | loop.run_until_complete(create_server(args)) 39 | 40 | 41 | if __name__ == "__main__": 42 | main() 43 | -------------------------------------------------------------------------------- /game/game/static/weapon.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | 6 | 7 | class Weapon(BaseModel): 8 | item_id: ctype.int32 9 | name: str 10 | bodypart: str 11 | crystallizable: bool 12 | weight: ctype.int32 13 | soulshots: ctype.char 14 | spiritshots: ctype.char 15 | material: str 16 | crystal_type: Optional[str] 17 | physical_damage: ctype.int32 18 | random_damage: ctype.int32 19 | critical: ctype.int32 20 | hit_modify: ctype.int32 21 | avoid_modify: ctype.int32 22 | shield_defense: ctype.int32 23 | shield_defense_rate: ctype.int32 24 | attack_speed: ctype.int32 25 | mp_consume: ctype.int32 26 | magic_damage: ctype.int32 27 | duration: ctype.int32 28 | price: ctype.int32 29 | crystal_count: ctype.int32 30 | sellable: ctype.char 31 | droppable: ctype.char 32 | destroyable: ctype.char 33 | tradeable: ctype.char 34 | item_skill_id: ctype.int32 35 | item_skill_level: ctype.int32 36 | weapon_type: str 37 | on_cast_skill_id: ctype.int32 38 | on_cast_skill_level: ctype.int32 39 | on_cast_skill_chance: ctype.int32 40 | on_crit_skill_id: ctype.int32 41 | on_crit_skill_level: ctype.int32 42 | on_crit_skill_chance: ctype.int32 43 | -------------------------------------------------------------------------------- /game/game/protocol.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from common.api_handlers import handle_request 4 | from common.transport.protocol import TCPProtocol 5 | from game.config import GameConfig 6 | from game.request import GameRequest 7 | from game.session import GameSession 8 | from game.states import Connected 9 | 10 | LOG = logging.getLogger(f"l2py.{__name__}") 11 | 12 | 13 | class Lineage2GameProtocol(TCPProtocol): 14 | session_cls = GameSession 15 | 16 | def connection_made(self, transport): 17 | super().connection_made(transport) 18 | 19 | LOG.info( 20 | "New connection from %s:%s", 21 | *self.transport.peer, 22 | ) 23 | self.session.set_state(Connected) 24 | 25 | @TCPProtocol.make_async 26 | async def data_received(self, data: bytes): 27 | for request in self.transport.read(data, request_cls=GameRequest): 28 | GameConfig().loop.create_task(self.proceed_request(request)) 29 | 30 | async def proceed_request(self, request): 31 | response = await handle_request(request) 32 | if response: 33 | LOG.debug( 34 | "Sending packet to %s:%s", 35 | *self.transport.peer, 36 | ) 37 | self.transport.write(response) 38 | 39 | def connection_lost(self, exc) -> None: 40 | self.session.logout_character() 41 | -------------------------------------------------------------------------------- /common/common/application_modules/http.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from aiohttp import web 5 | 6 | from common.application_modules.module import ApplicationModule 7 | 8 | LOG = logging.getLogger(f"L2py.{__name__}") 9 | 10 | 11 | class HTTPServerModule(ApplicationModule): 12 | """JSON HTTP application module. 13 | 14 | Serves web API requests. 15 | """ 16 | 17 | def __init__( 18 | self, 19 | name, 20 | middleware=None, 21 | json_encoder=json.JSONEncoder, 22 | json_decoder=json.JSONDecoder, 23 | ): 24 | super().__init__(name) 25 | self.middleware = middleware 26 | self.json_encoder = json_encoder 27 | self.json_decoder = json_decoder 28 | 29 | def start(self, config, loop): 30 | from aiojsonapi.config import config as aioconfig 31 | from aiojsonapi.routes import routes 32 | 33 | aioconfig.json_encoder = self.json_encoder 34 | aioconfig.json_decoder = self.json_decoder 35 | 36 | app = web.Application(middlewares=self.middleware) 37 | app.add_routes(routes) 38 | runner = web.AppRunner(app) 39 | 40 | loop.run_until_complete(runner.setup()) 41 | site = web.TCPSite(runner, config["host"], config["port"]) 42 | LOG.info("Starting Web server on %s:%s", config["host"], config["port"]) 43 | loop.run_until_complete(site.start()) 44 | -------------------------------------------------------------------------------- /game/game/models/structures/character/status.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | from common.model import BaseModel 3 | 4 | 5 | class Status(BaseModel): 6 | cp: ctype.int32 = 0 7 | hp: ctype.int32 = 0 8 | mp: ctype.int32 = 0 9 | 10 | weight_load: ctype.int32 = 0 11 | 12 | is_faking_death: ctype.bool = False 13 | is_in_combat: ctype.bool = False 14 | is_pvp: ctype.bool = False 15 | is_running: ctype.bool = False 16 | is_sitting: ctype.bool = False 17 | is_hero: ctype.bool = False 18 | is_noble: ctype.bool = False 19 | is_private_store: ctype.bool = False 20 | is_dwarf_craft_store: ctype.bool = False 21 | is_mounted: ctype.bool = False 22 | is_fishing: ctype.bool = False 23 | is_invulnerable: ctype.bool = False 24 | is_teleporting: ctype.bool = False 25 | is_betrayed: ctype.bool = False 26 | is_afraid: ctype.bool = False 27 | is_confused: ctype.bool = False 28 | is_flying: ctype.bool = False 29 | is_muted: ctype.bool = False 30 | is_physically_muted: ctype.bool = False 31 | is_dead: ctype.bool = False 32 | is_immobilized: ctype.bool = False 33 | is_overloaded: ctype.bool = False 34 | is_paralyzed: ctype.bool = False 35 | is_riding: ctype.bool = False 36 | is_pending_revive: ctype.bool = False 37 | is_rooted: ctype.bool = False 38 | is_sleeping: ctype.bool = False 39 | is_stunned: ctype.bool = False 40 | -------------------------------------------------------------------------------- /game/game/static/npc.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional 2 | 3 | from common.ctype import ctype 4 | from game.static.static import StaticData 5 | 6 | 7 | class NpcStatic(StaticData): 8 | id: ctype.int32 9 | name: str 10 | title: str 11 | cls: str 12 | collision_radius: ctype.int32 13 | collision_height: ctype.int32 14 | level: ctype.int32 15 | sex: ctype.int32 16 | type: str 17 | attackrange: ctype.int32 18 | hp: ctype.int32 19 | mp: ctype.int32 20 | hp_regeneration: ctype.float 21 | mp_regeneration: ctype.float 22 | STR: ctype.int32 23 | CON: ctype.int32 24 | DEX: ctype.int32 25 | INT: ctype.int32 26 | WIT: ctype.int32 27 | MEN: ctype.int32 28 | exp: ctype.int32 29 | sp: ctype.int32 30 | patk: ctype.int32 31 | pdef: ctype.int32 32 | matk: ctype.int32 33 | mdef: ctype.int32 34 | atkspd: ctype.int32 35 | aggro: ctype.bool 36 | matkspd: ctype.int32 37 | rhand: ctype.int32 38 | lhand: ctype.int32 39 | armor: ctype.int32 40 | walkspd: ctype.int32 41 | runspd: ctype.int32 42 | faction_id: Optional[ctype.int32] 43 | faction_range: ctype.int32 44 | absorb_level: ctype.int32 45 | absorb_type: str 46 | id_template: ctype.int32 47 | server_side_name: ctype.int32 48 | server_side_title: ctype.int32 49 | is_undead: ctype.bool 50 | 51 | filepath: ClassVar[str] = "static/sql/npc.json" 52 | -------------------------------------------------------------------------------- /login/login/packets/server_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from common.models import GameServer 7 | 8 | from .base import LoginServerPacket 9 | 10 | 11 | class ServerList(LoginServerPacket): 12 | type: ctype.char = 4 13 | servers: list[GameServer] = Field(default_factory=list) 14 | 15 | def encode(self, session, strings_format="utf8"): 16 | account = session.account 17 | arr = bytearray(self.type) 18 | arr.extend(ctype.char(len(self.servers))) 19 | arr.extend(ctype.char(0) if account.last_server is None else account.last_server) 20 | for server in self.servers: 21 | arr.extend( 22 | server.encode( 23 | include=[ 24 | "server_id", 25 | "host_as_bytearray", 26 | "port", 27 | "age_limit", 28 | "is_pvp", 29 | "online_count", 30 | "max_online", 31 | "server_is_alive", 32 | "type", 33 | "brackets", 34 | ], 35 | strings_format=strings_format, 36 | ) 37 | ) 38 | print("Arr len", len(arr)) 39 | arr.append(0) 40 | print(arr) 41 | return arr 42 | -------------------------------------------------------------------------------- /game/game/api/shortcuts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import game.constants 4 | import game.packets 5 | from common.api_handlers import l2_request_handler 6 | from common.ctype import ctype 7 | from common.template import Parameter, Template 8 | from game.models import Character 9 | from game.models.structures.shortcut import Shortcut 10 | from game.request import GameRequest 11 | 12 | LOG = logging.getLogger(f"l2py.{__name__}") 13 | 14 | 15 | @l2_request_handler( 16 | game.constants.GAME_REQUEST_SHORTCUT_REG, 17 | Template( 18 | [ 19 | Parameter(id="type", start=0, length=4, type=ctype.int32), 20 | Parameter(id="slot", start="$type.stop", length=4, type=ctype.int32), 21 | Parameter(id="id", start="$slot.stop", length=4, type=ctype.int32), 22 | ] 23 | ), 24 | states="*", 25 | ) 26 | async def request_short_cut_reg(request: GameRequest): 27 | LOG.debug("saving shortcut", request) 28 | character: Character = request.session.character 29 | shortcut = Shortcut( 30 | slot=ctype.int32(request.validated_data["slot"] % 12), 31 | page=ctype.int32(request.validated_data["slot"] / 12), 32 | type=request.validated_data["type"], 33 | id=request.validated_data["id"], 34 | level=ctype.int32(-1), # TODO: add level support 35 | ) 36 | character.shortcuts.append(shortcut) 37 | character.update_shortcut(request.session, shortcut) 38 | await character.commit_changes(fields=["shortcuts"]) 39 | -------------------------------------------------------------------------------- /common/common/transport/protocol.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import logging 4 | from abc import ABCMeta, abstractmethod 5 | from asyncio import transports 6 | 7 | from common.request import Request 8 | from common.response import Response 9 | from common.session import Session 10 | from common.transport.packet_transport import PacketTransport 11 | 12 | LOG = logging.getLogger("l2py." + __name__) 13 | 14 | 15 | class TCPProtocol(asyncio.Protocol, metaclass=ABCMeta): 16 | session_cls: type = Session 17 | session: session_cls 18 | request_cls: type = Request 19 | response_cls: type = Response 20 | transport: PacketTransport 21 | 22 | def __init__(self, loop, middleware, *args, **kwargs): 23 | super().__init__(*args, **kwargs) 24 | self.loop = loop 25 | self.middleware = middleware 26 | 27 | @staticmethod 28 | def make_async(func): 29 | @functools.wraps(func) 30 | async def async_wrap(protocol, data): 31 | return await func(protocol, data) 32 | 33 | @functools.wraps(func) 34 | def wrap(protocol, data): 35 | return asyncio.Task(async_wrap(protocol, data), loop=protocol.loop) 36 | 37 | return wrap 38 | 39 | def connection_made(self, transport: transports.BaseTransport) -> None: 40 | self.session = self.session_cls(self) 41 | self.transport = PacketTransport(transport, self.session, self.middleware) 42 | 43 | @abstractmethod 44 | async def data_received(self, data: bytes) -> None: 45 | pass 46 | -------------------------------------------------------------------------------- /game/game/broadcaster.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | 4 | class Broadcaster: 5 | @classmethod 6 | def broadcast( 7 | cls, 8 | packet_constructor, 9 | radius=500, 10 | to_me=False, 11 | pass_args_kwargs=False, 12 | specific_ids=None, 13 | ): 14 | def inner(f): 15 | @functools.wraps(f) 16 | async def wrap(obj, *args, **kwargs): 17 | from game.models.world import WORLD 18 | 19 | result = await f(obj, *args, **kwargs) 20 | 21 | if pass_args_kwargs: 22 | packet = packet_constructor(obj, *args, **kwargs) 23 | else: 24 | packet = packet_constructor(obj) 25 | 26 | for session in WORLD.players_sessions_nearby( 27 | obj.position, 28 | me=obj if not to_me else None, 29 | radius=radius, 30 | ): 31 | session.send_packet(packet) 32 | return result 33 | 34 | return wrap 35 | 36 | return inner 37 | 38 | @classmethod 39 | def broadcast_packet( 40 | cls, 41 | packet, 42 | obj, 43 | radius=500, 44 | to_me=False, 45 | specific_ids=None, 46 | ): 47 | from game.models.world import WORLD 48 | 49 | for session in WORLD.players_sessions_nearby( 50 | obj.position, 51 | me=obj if not to_me else None, 52 | radius=radius, 53 | ): 54 | session.send_packet(packet) 55 | -------------------------------------------------------------------------------- /common/common/transport/packet_transport.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from common.ctype import ctype 4 | from common.request import Request 5 | from common.response import Response 6 | 7 | LOG = logging.getLogger(f"l2py.{__name__}") 8 | 9 | 10 | class PacketTransport: 11 | def __init__(self, transport, session, middleware): 12 | self._transport = transport 13 | self.session = session 14 | self.middleware = middleware 15 | 16 | @property 17 | def peer(self): 18 | return self._transport.get_extra_info("peername") 19 | 20 | def read(self, data: bytes, request_cls: type = Request): 21 | data = bytearray(data) 22 | requests = [] 23 | while True: 24 | if data: 25 | packet_len: ctype.int16 = int(ctype.uint16(data[0:2])) 26 | request = request_cls(raw_data=data[:packet_len], session=self.session) 27 | requests.append(request) 28 | data = data[packet_len:] 29 | for middleware in self.middleware: 30 | middleware.before(self.session, request) 31 | else: 32 | break 33 | return requests 34 | 35 | def write(self, response: Response): 36 | for middleware in self.middleware[::-1]: 37 | middleware.after(self.session, response) 38 | LOG.debug(f"SENDING: %s %s", response.packet, len(bytes(response.data))) 39 | LOG.debug(bytes(response.data)) 40 | return self._transport.write(bytes(response.data)) 41 | 42 | def close(self): 43 | return self._transport.close() 44 | -------------------------------------------------------------------------------- /login/login/middleware/checksum.py: -------------------------------------------------------------------------------- 1 | from common import exceptions 2 | from common.ctype import ctype 3 | from common.middleware.middleware import Middleware 4 | from common.request import Request 5 | from common.response import Response 6 | from login.packets.init import Init 7 | from login.session import LoginSession 8 | 9 | 10 | class ChecksumMiddleware(Middleware): 11 | @staticmethod 12 | def verify_checksum(data: bytearray) -> bool: 13 | 14 | if len(data) % 4 != 0: 15 | return False 16 | 17 | checksum = ctype.int32(0) 18 | 19 | for i in range(0, len(data) - 4, 4): 20 | checksum ^= ctype.int32(data[i : i + 4]) 21 | 22 | check = ctype.int32(data[-4:]) 23 | 24 | return check == checksum 25 | 26 | @staticmethod 27 | def add_checksum(response_data: bytearray): 28 | """Adds checksum to response.""" 29 | 30 | checksum = ctype.int32(0) 31 | 32 | for i in range(0, len(response_data) - 4, 4): 33 | checksum ^= ctype.int32(response_data[i : i + 4]) 34 | 35 | response_data[-4:] = bytearray(bytes(checksum)) 36 | 37 | @classmethod 38 | def before(cls, session: LoginSession, request: Request): 39 | """Checks that requests checksum match.""" 40 | 41 | if not cls.verify_checksum(request.data): 42 | raise exceptions.ChecksumMismatch() 43 | 44 | @classmethod 45 | def after(cls, session: LoginSession, response: Response): 46 | """Adds checksum to response data.""" 47 | 48 | if not isinstance(response.packet, Init): 49 | cls.add_checksum(response.data) 50 | -------------------------------------------------------------------------------- /game/game/middleware/xor.py: -------------------------------------------------------------------------------- 1 | import game.session 2 | from common.ctype import ctype 3 | from common.middleware.middleware import Middleware 4 | from common.response import Response 5 | 6 | 7 | class XORGameMiddleware(Middleware): 8 | @classmethod 9 | def before(cls, session: game.session.GameSession, request: Response): 10 | with session.lock_before: 11 | if session.encryption_enabled: 12 | temp1 = ctype.uint32(0) 13 | 14 | for i in range(0, len(request.data)): 15 | temp2 = ctype.uint8(request.data[i]) 16 | request.data[i] = int(temp2 ^ session.xor_key.incoming_key[i & 15] ^ temp1) 17 | temp1 = temp2 18 | key_chunk = ctype.uint32(session.xor_key.incoming_key[8:12]) 19 | key_chunk += len(request.data) 20 | session.xor_key.incoming_key[8:12] = bytes(key_chunk) 21 | 22 | @classmethod 23 | def after(cls, session: game.session.GameSession, response: Response): 24 | with session.lock_after: 25 | if session.encryption_enabled: 26 | temp1 = ctype.uint32(0) 27 | 28 | for i in range(0, len(response.data)): 29 | temp2 = ctype.uint8(response.data[i]) 30 | response.data[i] = int(temp2 ^ session.xor_key.outgoing_key[i & 15] ^ temp1) 31 | temp1 = response.data[i] 32 | 33 | key_chunk = ctype.uint32(session.xor_key.outgoing_key[8:12]) 34 | key_chunk += len(response.data) 35 | session.xor_key.outgoing_key[8:12] = bytes(key_chunk) 36 | -------------------------------------------------------------------------------- /game/game/runner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import common # noqa: F401 4 | import game.api # noqa: F401 5 | import game.periodic_tasks # noqa: F401 6 | from game.application import GAME_SERVER_APPLICATION 7 | from game.config import GameConfig 8 | from game.models.world import WORLD 9 | 10 | LOG = logging.getLogger(f"L2py.game") 11 | 12 | 13 | def update_refs(): 14 | import game.packets 15 | 16 | game.packets.CharSelected.update_forward_refs(Character=game.models.Character) 17 | game.packets.CharInfo.update_forward_refs(Character=game.models.Character) 18 | game.packets.CharList.update_forward_refs(Character=game.models.Character) 19 | game.packets.EtcStatusUpdate.update_forward_refs(Character=game.models.Character) 20 | game.packets.ExStorageMaxCount.update_forward_refs(Character=game.models.Character) 21 | game.packets.UserInfo.update_forward_refs(Character=game.models.Character) 22 | game.packets.CharMoveToLocation.update_forward_refs(Character=game.models.Character) 23 | 24 | 25 | def main(): 26 | GAME_SERVER_APPLICATION.run( 27 | { 28 | "game_web": { 29 | "host": GameConfig().GAME_SERVER_API_HOST, 30 | "port": GameConfig().GAME_SERVER_API_PORT, 31 | }, 32 | "game_tcp": { 33 | "host": GameConfig().GAME_SERVER_HOST, 34 | "port": GameConfig().GAME_SERVER_PORT, 35 | }, 36 | }, 37 | log_level=logging.DEBUG, 38 | loop=GameConfig().loop, 39 | cleanup_task=WORLD.shutdown, 40 | ) 41 | 42 | 43 | if __name__ == "__main__": 44 | update_refs() 45 | main() 46 | -------------------------------------------------------------------------------- /game/game/packets/macros_list.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.macro import Macro 6 | from game.packets.base import GameServerPacket 7 | 8 | 9 | class MacrosList(GameServerPacket): 10 | type: ctype.int8 = 231 11 | macro: Optional[Macro] = None 12 | total_macros: ctype.int32 = 0 13 | revision: ctype.int32 = 0 14 | 15 | def encode(self, session): 16 | encoded = bytearray() 17 | 18 | extend_bytearray( 19 | encoded, 20 | [ 21 | self.type, 22 | self.revision, 23 | ctype.int8(0), 24 | ctype.int8(self.total_macros), 25 | ctype.bool(self.macro), 26 | ], 27 | ) 28 | 29 | if self.macro: 30 | extend_bytearray( 31 | encoded, 32 | [ 33 | self.macro.id, 34 | self.macro.name, 35 | self.macro.description, 36 | self.macro.acronym, 37 | self.macro.icon, 38 | ctype.int8(len(self.macro.entries)), 39 | ], 40 | ) 41 | for entry in self.macro.entries: 42 | extend_bytearray( 43 | encoded, 44 | [ 45 | entry.entry_id, 46 | entry.type, 47 | entry.skill_id, 48 | entry.shortcut_id, 49 | entry.command, 50 | ], 51 | ) 52 | return encoded 53 | -------------------------------------------------------------------------------- /login/login/crypt/xor.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from common.ctype import ctype 4 | 5 | 6 | def xor_encrypt_login(func): 7 | def xor(data: bytearray, key: ctype.int32): 8 | 9 | stop: ctype.int32 = len(data) - 8 10 | start: ctype.int32 = 4 11 | ecx: ctype.int32 = key 12 | 13 | for pos in range(start, stop, 4): 14 | edx: ctype.int32 = data[pos] & 255 15 | edx |= (data[pos + 1] & 255) << 8 16 | edx |= (data[pos + 2] & 255) << 16 17 | edx |= (data[pos + 3] & 255) << 24 18 | 19 | ecx += edx 20 | edx ^= ecx 21 | 22 | data[pos : pos + 4] = edx 23 | 24 | data[-8:-4] = ecx 25 | 26 | return data 27 | 28 | @functools.wraps(func) 29 | def wrap(packet, session): 30 | data = func(packet, session) 31 | return xor(data, session.xor_key.key) 32 | 33 | return wrap 34 | 35 | 36 | def xor_decrypt_login(func): 37 | def xor(raw: bytearray, key: ctype.int32): 38 | stop: ctype.int32 = 2 39 | pos: ctype.int32 = len(raw) - 12 40 | ecx: ctype.int32 = key 41 | 42 | while stop < pos: 43 | edx: ctype.int32 = raw[pos] & 255 44 | edx |= (raw[pos + 1] & 255) << 8 45 | edx |= (raw[pos + 2] & 255) << 16 46 | edx |= (raw[pos + 3] & 255) << 24 47 | 48 | edx ^= ecx 49 | ecx -= edx 50 | 51 | raw[pos : pos + 4] = edx 52 | pos -= 4 53 | 54 | return raw 55 | 56 | def wrap(packet_cls, data, *args, **kwargs): 57 | decrypted = xor(data, list(data[-8:-4])) 58 | return func(packet_cls, decrypted, *args, **kwargs) 59 | 60 | return wrap 61 | -------------------------------------------------------------------------------- /common/tests/test_ctype.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | 4 | def test_ctypes(): 5 | """ 6 | This test check if all ctypes attributes present, 7 | do they properly work and named. 8 | """ 9 | 10 | c_types = [eval(f"ctype.{i}") for i in dir(ctype) if i[0:2] != "__"] 11 | assert ( 12 | len(c_types) == 21 13 | ), "Amount of types are changed, convert_dict should be updated or ctypes restored." 14 | 15 | convert_dict = { 16 | "int": (2_147_483_647, 2_147_483_647), 17 | "int8": (127, 127), 18 | "int16": (32767, 32767), 19 | "int32": (2_147_483_647, 2_147_483_647), 20 | "int64": (9_223_372_036_854_775_807, 9_223_372_036_854_775_807), 21 | "uint": (2_147_483_647, 2_147_483_647), 22 | "uint8": (255, 255), 23 | "uint16": (32767, 32767), 24 | "uint32": (2_147_483_647, 2_147_483_647), 25 | "uint64": (9_223_372_036_854_775_807, 9_223_372_036_854_775_807), 26 | "bool": (1, True), 27 | "byte": (10000, 16), 28 | "char": (bytes(1), b"\x00"), 29 | "double": (2.000000000000001, 2.000000000000001), 30 | "float": (2.000001, 2.0000009536743164), 31 | "short": (10000, 10000), 32 | "long": (-2147483647, -2147483647), 33 | "longlong": (9_223_372_036_854_775_807, 9_223_372_036_854_775_807), 34 | "ushort": (10000, 10000), 35 | "ulong": (2147483647, 2147483647), 36 | "ulonglong": (9_223_372_036_854_775_807, 9_223_372_036_854_775_807), 37 | } 38 | 39 | for key in convert_dict: 40 | value = eval(f"ctype.{key}({convert_dict[key][0]})") 41 | assert convert_dict[key][1] == value, f"{key}: {convert_dict[key][1]} != {value}" 42 | -------------------------------------------------------------------------------- /common/common/application_modules/scheduler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import apscheduler.executors.asyncio 4 | import apscheduler.jobstores.base 5 | import apscheduler.jobstores.mongodb 6 | import apscheduler.schedulers.asyncio 7 | 8 | from common.application_modules.module import ApplicationModule 9 | from common.document import Document 10 | 11 | scheduler_job_store = { 12 | "default": apscheduler.jobstores.mongodb.MongoDBJobStore( 13 | client=Document.sync_client(), database="l2py" 14 | ) 15 | } 16 | scheduler_executors = {"default": apscheduler.executors.asyncio.AsyncIOExecutor()} 17 | 18 | scheduler = apscheduler.schedulers.asyncio.AsyncIOScheduler( 19 | jobstores=scheduler_job_store, executors=scheduler_executors 20 | ) 21 | 22 | 23 | class ScheduleModule(ApplicationModule): 24 | _jobs = [] 25 | 26 | def __init__(self): 27 | super().__init__("scheduler") 28 | self.scheduler = scheduler 29 | 30 | def start(self, config, loop): 31 | self.scheduler._eventloop = loop 32 | self.scheduler.start() 33 | self._add_jobs() 34 | logging.getLogger("apscheduler.executors.default").setLevel(logging.WARNING) 35 | 36 | def _add_jobs(self): 37 | for job in self._jobs: 38 | try: 39 | self.scheduler.add_job(job["f"], *job["args"], **job["kwargs"]) 40 | except apscheduler.jobstores.base.ConflictingIdError: 41 | pass 42 | 43 | @classmethod 44 | def job(cls, *args, **kwargs): 45 | def wrap(f): 46 | kwargs.update({"name": f.__name__, "id": f.__name__}) 47 | cls._jobs.append({"f": f, "args": args, "kwargs": kwargs}) 48 | return f 49 | 50 | return wrap 51 | -------------------------------------------------------------------------------- /login/login/keys/session.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from common.ctype import ctype 4 | 5 | 6 | class SessionKey: 7 | def __init__( 8 | self, 9 | login_ok1: ctype.int32 = None, 10 | login_ok2: ctype.int32 = None, 11 | play_ok1: ctype.int32 = None, 12 | play_ok2: ctype.int32 = None, 13 | ): 14 | self.login_ok1: ctype.int32 = ctype.int32.random() if login_ok1 is None else login_ok1 15 | self.login_ok2: ctype.int32 = ctype.int32.random() if login_ok2 is None else login_ok2 16 | 17 | self.login_ok2: ctype.int32 = ctype.int32.random() if login_ok2 is None else login_ok2 18 | self.play_ok1: ctype.int32 = ctype.int32.random() if play_ok1 is None else play_ok1 19 | self.play_ok2: ctype.int32 = ctype.int32.random() if play_ok2 is None else play_ok2 20 | 21 | def __eq__(self, other): 22 | if isinstance(other, SessionKey): 23 | if ( 24 | self.login_ok1 == other.login_ok1 25 | and self.login_ok2 == other.login_ok2 26 | and self.play_ok1 == other.play_ok1 27 | and self.play_ok2 == other.play_ok2 28 | ): 29 | return True 30 | else: 31 | return False 32 | 33 | def verify_login(self, login_ok1, login_ok2): 34 | """Verifies that login session match.""" 35 | 36 | if self.login_ok1 == login_ok1 and self.login_ok2 == login_ok2: 37 | return True 38 | return False 39 | 40 | def to_dict(self): 41 | return { 42 | "login_ok1": self.login_ok1, 43 | "login_ok2": self.login_ok2, 44 | "play_ok1": self.play_ok1, 45 | "play_ok2": self.play_ok2, 46 | } 47 | -------------------------------------------------------------------------------- /game/game/models/structures/experience.py: -------------------------------------------------------------------------------- 1 | from common.ctype import ctype 2 | 3 | level_exp = [ 4 | 0, 5 | 68, 6 | 363, 7 | 1168, 8 | 2884, 9 | 6038, 10 | 11287, 11 | 19423, 12 | 31378, 13 | 48229, 14 | 71201, 15 | 101676, 16 | 141192, 17 | 191452, 18 | 254327, 19 | 331864, 20 | 426284, 21 | 539995, 22 | 675590, 23 | 835854, 24 | 1023775, 25 | 1242536, 26 | 1495531, 27 | 1786365, 28 | 2118860, 29 | 2497059, 30 | 2925229, 31 | 3407873, 32 | 3949727, 33 | 4555766, 34 | 5231213, 35 | 5981539, 36 | 6812472, 37 | 7729999, 38 | 8740372, 39 | 9850111, 40 | 11066012, 41 | 12395149, 42 | 13844879, 43 | 15422851, 44 | 17137002, 45 | 18995573, 46 | 21007103, 47 | 23180442, 48 | 25524751, 49 | 28049509, 50 | 30764519, 51 | 33679907, 52 | 36806133, 53 | 40153995, 54 | 45524865, 55 | 51262204, 56 | 57383682, 57 | 63907585, 58 | 70852742, 59 | 80700339, 60 | 91162131, 61 | 102265326, 62 | 114038008, 63 | 126509030, 64 | 146307211, 65 | 167243291, 66 | 189363788, 67 | 212716741, 68 | 237351413, 69 | 271973532, 70 | 308441375, 71 | 346825235, 72 | 387197529, 73 | 429632402, 74 | 474205751, 75 | 532692055, 76 | 606319094, 77 | 696376867, 78 | 804219972, 79 | 931275828, 80 | 1151275834, 81 | 1511275834, 82 | 2099275834, 83 | 4200000000, 84 | ] 85 | 86 | 87 | def get_level(exp: ctype.int64): 88 | level = 0 89 | for exp_required in level_exp: 90 | if exp >= exp_required: 91 | level += 1 92 | else: 93 | break 94 | return level 95 | -------------------------------------------------------------------------------- /game/game/models/structures/system_message.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from common.model import BaseModel 7 | 8 | 9 | class MessageValue(BaseModel): 10 | type: ClassVar[ctype.int32] 11 | value: tuple 12 | 13 | 14 | class Text(MessageValue): 15 | type: ClassVar[ctype.int32] = 0 16 | text: str 17 | 18 | @property 19 | def value(self) -> tuple: 20 | return (self.text,) 21 | 22 | 23 | class Number(MessageValue): 24 | type: ClassVar[ctype.int32] = 1 25 | number: ctype.int32 26 | 27 | @property 28 | def value(self) -> tuple: 29 | return (self.number,) 30 | 31 | 32 | class NpcName(MessageValue): 33 | type: ClassVar[ctype.int32] = 2 34 | npc_id: ctype.int32 35 | 36 | @property 37 | def value(self) -> tuple: 38 | return (self.npc_id + 1000000,) 39 | 40 | 41 | class ItemName(MessageValue): 42 | type: ClassVar[ctype.int32] = 3 43 | item_id: ctype.int32 44 | 45 | @property 46 | def value(self) -> tuple: 47 | return (self.item_id,) 48 | 49 | 50 | class SkillName(MessageValue): 51 | type: ClassVar[ctype.int32] = 4 52 | skill_id: ctype.int32 53 | skill_lvl: ctype.int32 54 | 55 | @property 56 | def value(self) -> tuple: 57 | return self.skill_id, self.skill_lvl 58 | 59 | 60 | class ZoneName(MessageValue): 61 | type: ClassVar[ctype.int32] = 7 62 | x: ctype.int32 63 | y: ctype.int32 64 | z: ctype.int32 65 | 66 | @property 67 | def value(self) -> tuple: 68 | return self.x, self.y, self.z 69 | 70 | 71 | class SystemMessage(BaseModel): 72 | type: ctype.int32 73 | data: tuple[MessageValue, ...] = Field(default_factory=tuple) 74 | -------------------------------------------------------------------------------- /common/common/api_handlers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import functools 3 | import logging 4 | 5 | from common.request import Request 6 | from common.response import Response 7 | 8 | _HANDLERS = {} 9 | 10 | 11 | LOG = logging.getLogger(f"l2py.{__name__}") 12 | 13 | 14 | def parse_data(request_template, f): 15 | async def wrap(request: Request): 16 | template = copy.deepcopy(request_template) 17 | request.validated_data = template.parse_request(request.data) 18 | return await f(request) 19 | 20 | return wrap 21 | 22 | 23 | def l2_request_handler(action, template, states="*"): 24 | def wrapper(f): 25 | @functools.wraps(f) 26 | def inner(request, *args, **kwargs): 27 | LOG.info( 28 | "Request from %s: %s[%s] %s", 29 | request.session.uuid, 30 | f.__name__, 31 | action, 32 | request.validated_data, 33 | ) 34 | return f(request, *args, **kwargs) 35 | 36 | _HANDLERS[action] = { 37 | "handler": parse_data(template, inner), 38 | "states": states, 39 | } 40 | return inner 41 | 42 | return wrapper 43 | 44 | 45 | async def handle_request(request: Request): 46 | action_id, request.data = request.data[0], bytearray(request.data[1:]) 47 | LOG.debug("Looking for action with ID %s", action_id) 48 | if action_id in _HANDLERS: 49 | params = _HANDLERS[action_id] 50 | if params["states"] == "*" or request.session.state in params["states"]: 51 | result = await params["handler"](request) 52 | if result is None: 53 | return 54 | if isinstance(result, Response): 55 | return result 56 | return Response(packet=result, session=request.session) 57 | -------------------------------------------------------------------------------- /game/game/api/world.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.packets 3 | import game.states 4 | from common.api_handlers import l2_request_handler 5 | from common.template import Template 6 | from game.constants import WELCOME_TO_LINEAGE 7 | from game.models.structures.system_message import SystemMessage 8 | from game.models.world import WORLD 9 | from game.request import GameRequest 10 | from game.session import GameSession 11 | 12 | 13 | @l2_request_handler( 14 | game.constants.GAME_REQUEST_ENTER_WORLD, 15 | Template([]), 16 | states=[game.states.CharacterSelected], 17 | ) 18 | async def enter_world(request: GameRequest): 19 | character = request.session.character 20 | character.session = request.session 21 | 22 | request.session.send_packet(game.packets.EtcStatusUpdate(character=character)) 23 | 24 | request.session.send_packet(game.packets.ExStorageMaxCount(character=character)) 25 | 26 | request.session.send_packet(game.packets.UserInfo(character=character)) 27 | await character.spawn() 28 | WORLD.notify_me_about_others_nearby(request.session, character) 29 | 30 | request.session.send_packet(game.packets.ItemList(items=character.inventory.items)) 31 | 32 | welcome = SystemMessage(type=WELCOME_TO_LINEAGE) 33 | WORLD.send_sys_message(character, welcome) 34 | 35 | character.notify_macros(request.session) 36 | character.notify_shortcuts(request.session) 37 | 38 | await notify_friends_login(request.session) 39 | 40 | 41 | # TODO notify friends on Character logout 42 | async def notify_friends_login(session: GameSession): 43 | character = session.character 44 | 45 | online_friends = await character.notify_friends(session) 46 | if not online_friends: 47 | return 48 | 49 | for friend in online_friends: 50 | await friend.notify_friends(friend.session) 51 | # TODO SysMsg: FRIEND_S1_HAS_LOGGED_IN 52 | -------------------------------------------------------------------------------- /game/game/packets/__init__.py: -------------------------------------------------------------------------------- 1 | from .action_failed import ActionFailed 2 | from .attack import Attack 3 | from .auth_login_fail import AuthLoginFail 4 | from .chage_move_type import ChangeMoveType 5 | from .chair_sit import ChairSit 6 | from .change_wait_type import ChangeWaitType 7 | from .char_create_fail import CharCreateFail 8 | from .char_create_ok import CharCreateOk 9 | from .char_delete_fail import CharDeleteFail 10 | from .char_delete_ok import CharDeleteOk 11 | from .char_info import CharInfo 12 | from .char_list import CharList 13 | from .char_move_to_location import CharMoveToLocation 14 | from .char_selected import CharSelected 15 | from .char_templates import CharTemplates 16 | from .creature_say import CreatureSay 17 | from .crypt_init import CryptInit 18 | from .etc_status_update import EtcStatusUpdate 19 | from .ex_send_manor_list import ExSendManorList 20 | from .ex_storage_max_count import ExStorageMaxCount 21 | from .friend_invite import FriendInvite 22 | from .friend_list import FriendList 23 | from .friend_message import FriendMessage 24 | from .item_list import ItemList 25 | from .leave_world import LeaveWorld 26 | from .logout_ok import LogoutOk 27 | from .macros_list import MacrosList 28 | from .move_to_location import MoveToLocation 29 | from .my_target_selected import MyTargetSelected 30 | from .net_ping_request import NetPingRequest 31 | from .open_minimap import OpenMinimap 32 | from .quest_list import QuestList 33 | from .restart_response import RestartResponse 34 | from .server_socket_close import ServerSocketClose 35 | from .shortcut_register import ShortcutRegister 36 | from .snoop import Snoop 37 | from .social_action import SocialAction 38 | from .status_update import StatusUpdate 39 | from .system_message import SystemMessagePacket 40 | from .target_selected import TargetSelected 41 | from .target_unselected import TargetUnselected 42 | from .teleport_to_location import TeleportToLocation 43 | from .user_info import UserInfo 44 | -------------------------------------------------------------------------------- /login/login/protocol.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import login.session 4 | from common.api_handlers import handle_request 5 | from common.response import Response 6 | from common.transport.protocol import TCPProtocol 7 | from login.packets import Init 8 | from login.state import Connected 9 | 10 | LOG = logging.getLogger(f"l2py.{__name__}") 11 | 12 | 13 | class Lineage2LoginProtocol(TCPProtocol): 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, *kwargs) 16 | self.session_cls = login.session.LoginSession 17 | 18 | def connection_made(self, transport): 19 | super().connection_made(transport) 20 | 21 | LOG.debug( 22 | "New connection from %s:%s", 23 | *self.transport.peer, 24 | ) 25 | 26 | response = Response( 27 | packet=Init( 28 | session_id=self.session.id, 29 | protocol_version=self.session.protocol_version, 30 | rsa_key=self.session.rsa_key.scramble_mod(), 31 | blowfish_key=self.session.blowfish_key.key, 32 | ), 33 | session=self.session, 34 | ) 35 | self.transport.write(response) 36 | self.session.blowfish_enabled = True 37 | self.session.set_state(Connected) 38 | 39 | @TCPProtocol.make_async 40 | async def data_received(self, data: bytes): 41 | for request in self.transport.read(data): 42 | response = await handle_request(request) 43 | if response: 44 | LOG.debug( 45 | "Sending packet to %s:%s", 46 | *self.transport.peer, 47 | ) 48 | self.transport.write(response) 49 | 50 | def connection_lost(self, exc) -> None: 51 | super().connection_lost(exc) 52 | # self.session.delete() 53 | LOG.debug( 54 | "Connection lost to %s:%s", 55 | *self.transport.peer, 56 | ) 57 | -------------------------------------------------------------------------------- /login/login/keys/rsa.py: -------------------------------------------------------------------------------- 1 | from Cryptodome.PublicKey import RSA 2 | 3 | 4 | class L2RsaKey(RSA.RsaKey): 5 | def scramble_mod(self) -> bytes: 6 | n = bytearray(self.n_bytes) 7 | 8 | # step 1: 0x4d - 0x50 <-> 0x00 - 0x04 9 | for i in range(4): 10 | n[i], n[0x4D + i] = n[0x4D + i], n[i] 11 | 12 | # step 2 : xor first 0x40 bytes with last 0x40 bytes 13 | for i in range(0x40): 14 | n[i] = n[i] ^ n[0x40 + i] 15 | 16 | # step 3 : xor bytes 0x0d-0x10 with bytes 0x34-0x38 17 | for i in range(4): 18 | n[0x0D + i] = n[0x0D + i] ^ n[0x34 + i] 19 | 20 | # step 4 : xor last 0x40 bytes with first 0x40 bytes 21 | for i in range(0x40): 22 | n[0x40 + i] = n[0x40 + i] ^ n[i] 23 | 24 | return bytes(n) 25 | 26 | @classmethod 27 | def unscramble_mod(cls, n: bytearray) -> int: 28 | 29 | for i in range(0x40): 30 | n[0x40 + i] = n[0x40 + i] ^ n[i] 31 | 32 | for i in range(4): 33 | n[0x0D + i] = n[0x0D + i] ^ n[0x34 + i] 34 | 35 | for i in range(0x40): 36 | n[i] = n[i] ^ n[0x40 + i] 37 | 38 | for i in range(4): 39 | temp = n[0x00 + i] 40 | n[0x00 + i] = n[0x4D + i] 41 | n[0x4D + i] = temp 42 | 43 | return int.from_bytes(bytes(n), "big") 44 | 45 | @property 46 | def n_bytes(self): 47 | return self.n.to_bytes(128, "big") 48 | 49 | @classmethod 50 | def generate(cls, bits=1024, randfunc=None, e=65537) -> "L2RsaKey": 51 | key = RSA.generate(bits, randfunc, e) 52 | key.__class__ = cls 53 | return key 54 | 55 | def __repr__(self): 56 | return "L2" + super().__repr__() 57 | 58 | def private_decrypt(self, data: bytearray): 59 | cipher_int = int.from_bytes(data, "big") 60 | plain_int = pow(cipher_int, self.d, self.n) 61 | return plain_int.to_bytes((self.n.bit_length() - 1) // 8 + 1, "big") 62 | -------------------------------------------------------------------------------- /common/common/json.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | import bson 5 | 6 | from common.ctype import _Numeric, extras 7 | from common.model import BaseModel 8 | 9 | 10 | class JsonEncoder(json.JSONEncoder): 11 | def encode(self, o: Any): 12 | if isinstance(o, BaseModel): 13 | return {**o.dict(), "$model": o.__class__.__name__} 14 | elif isinstance(o, (bytes, str)): 15 | return str(o) 16 | elif isinstance(o, int): 17 | return int(o) 18 | elif isinstance(o, bson.ObjectId): 19 | return {"$oid": str(o)} 20 | elif isinstance(o, list): 21 | return [self.encode(item) for item in o] 22 | elif isinstance(o, _Numeric): 23 | value = o.value 24 | if isinstance(o.value, bytes): 25 | value = int.from_bytes(o.value, "big") 26 | return extras[o.__class__][0](value) 27 | if isinstance(o, dict): 28 | return o 29 | if o is None: 30 | return None 31 | return super().default(o) 32 | 33 | def encode_dict(self, o: dict): 34 | """Encodes all values in dict.""" 35 | 36 | encoded = {} 37 | for key, value in o.items(): 38 | if isinstance(value, dict): 39 | encoded[key] = self.encode_dict(value) 40 | else: 41 | encoded[key] = self.encode(value) 42 | 43 | return encoded 44 | 45 | 46 | class JsonDecoder(json.JSONDecoder): 47 | def __init__(self, *args, **kwargs): 48 | super().__init__(object_hook=self.object_hook, *args, **kwargs) 49 | 50 | def object_hook(self, data): 51 | if isinstance(data, dict): 52 | if "$oid" in data: 53 | return bson.ObjectId(data["$oid"]) 54 | elif "$model" in data: 55 | for model in BaseModel.__subclasses__(): 56 | if model.__name__ == data["$model"]: 57 | return model(**data) 58 | return data 59 | -------------------------------------------------------------------------------- /game/game/models/structures/character/template.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | from game.models.structures.character.stats import Stats 6 | from game.models.structures.object.point3d import Point3D 7 | 8 | 9 | class LevelUpIncrease(BaseModel): 10 | base: ctype.float = 0 11 | add: ctype.float = 0 12 | mod: ctype.float = 0 13 | 14 | 15 | class LevelUpGain(BaseModel): 16 | level: ctype.int32 = 0 17 | hp: LevelUpIncrease = Field(default_factory=LevelUpIncrease) 18 | cp: LevelUpIncrease = Field(default_factory=LevelUpIncrease) 19 | mp: LevelUpIncrease = Field(default_factory=LevelUpIncrease) 20 | 21 | 22 | class ClassInfo(BaseModel): 23 | id: ctype.int32 24 | name: str 25 | base_level: ctype.int32 26 | 27 | 28 | class CharacterTemplate(BaseModel): 29 | class_info: ClassInfo 30 | stats: Stats 31 | race: ctype.int32 32 | level_up_gain: LevelUpGain 33 | spawn: Point3D 34 | 35 | collision_radius: ctype.double 36 | collision_height: ctype.double 37 | load: ctype.int32 38 | 39 | mp_consume_rate: ctype.int32 40 | hp_consume_rate: ctype.int32 41 | 42 | items: list[ctype.int32] 43 | 44 | @classmethod 45 | def from_static_template(cls, template, sex): 46 | return cls( 47 | class_info=ClassInfo( 48 | id=template.class_id, 49 | name=template.class_name, 50 | base_level=template.base_level, 51 | ), 52 | stats=template.stats, 53 | race=template.race_id, 54 | level_up_gain=template.level_up_gain, 55 | spawn=template.spawn, 56 | collision_radius=template.male_collision_radius 57 | if sex 58 | else template.female_collision_radius, 59 | collision_height=template.male_collision_height 60 | if sex 61 | else template.female_collision_height, 62 | load=template.load, 63 | mp_consume_rate=0, 64 | hp_consume_rate=0, 65 | items=template.items, 66 | ) 67 | -------------------------------------------------------------------------------- /game/game/packets/attack.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from pydantic import Field 4 | 5 | from common.ctype import ctype 6 | from common.model import BaseModel 7 | from game.models.structures.object.position import Position 8 | 9 | from .base import GameServerPacket 10 | 11 | 12 | class Hit(BaseModel): 13 | target_id: ctype.int32 14 | damage: ctype.int32 15 | flags: ctype.int8 = 0 16 | 17 | 18 | class Attack(GameServerPacket): 19 | type: ctype.int8 = 5 20 | attacker_id: ctype.int32 21 | soulshot: ctype.bool 22 | grade: ctype.int32 23 | position: Position 24 | hit: Hit 25 | Hit = Hit 26 | 27 | hits: list[Hit] = Field(default_factory=list) 28 | 29 | def __init__(self, **kwargs): 30 | super().__init__(**kwargs) 31 | self.hits.append(self.hit) 32 | 33 | def add_hit( 34 | self, 35 | target_id: ctype.int32, 36 | damage: ctype.int32, 37 | have_missed: bool, 38 | is_critical: bool, 39 | is_shielded: bool, 40 | ): 41 | 42 | hit = Hit(target_id, damage) 43 | 44 | if self.soulshot: 45 | hit.flags |= 0x10 | self.grade 46 | if is_critical: 47 | hit.flags |= 0x20 48 | if is_shielded: 49 | hit.flags |= 0x40 50 | if have_missed: 51 | hit.flags |= 0x80 52 | 53 | self.hits.append(hit) 54 | 55 | def encode(self, session): 56 | encoded = self.type.encode() 57 | 58 | ordered_data = [ 59 | self.attacker_id, 60 | self.hits[0].target_id, 61 | self.hits[0].damage, 62 | self.hits[0].flags, 63 | self.position.point3d.x, 64 | self.position.point3d.y, 65 | self.position.point3d.z, 66 | ctype.int(len(self.hits) - 1), 67 | ] 68 | for hit in self.hits: 69 | ordered_data += [ 70 | hit.target_id, 71 | hit.damage, 72 | ctype.int32(hit.flags), 73 | ] 74 | for item in ordered_data: 75 | encoded.append(item) 76 | return encoded 77 | -------------------------------------------------------------------------------- /utils/sql_to_json.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import argparse 4 | import glob 5 | import json 6 | import re 7 | 8 | from mysql.connector import Error, connect 9 | 10 | from utils.common import JsonEncoder 11 | 12 | 13 | def convert_sql_to_json(file_path: str): 14 | with connect(host="localhost", user="root", password="1234", database="L2py") as connection: 15 | with open(file_path) as sql_query_file: 16 | query = sql_query_file.read() 17 | table_search = re.findall( 18 | "CREATE TABLE (?:IF NOT EXISTS )?`?(?P[\w]+)`?", query 19 | ) 20 | table_name = table_search[0] 21 | with connection.cursor() as cursor: 22 | for statement in query.split(";"): 23 | statement = statement.strip() 24 | if len(statement) > 0: 25 | cursor.execute(statement + ";") 26 | with connection.cursor() as cursor: 27 | cursor.execute(f"SELECT * FROM {table_name};") 28 | row_headers = [x[0] for x in cursor.description] 29 | rows = list(cursor.fetchall()) 30 | data = [] 31 | print(len(rows)) 32 | for row in rows: 33 | data.append(dict(zip(row_headers, row))) 34 | return json.dumps(data, cls=JsonEncoder, indent=4) 35 | 36 | 37 | def main(): 38 | parser = argparse.ArgumentParser() 39 | 40 | parser.add_argument("--file-path") 41 | parser.add_argument("--folder-path") 42 | 43 | args = parser.parse_args() 44 | if args.file_path: 45 | with open(args.file_path.replace(".sql", ".json"), "w") as json_file: 46 | json_file.write(convert_sql_to_json(args.file_path)) 47 | elif args.folder_path: 48 | for file_path in glob.glob(f"{args.folder_path}/*.sql"): 49 | print(file_path) 50 | with open(file_path.replace(".sql", ".json"), "w") as json_file: 51 | json_file.write(convert_sql_to_json(file_path)) 52 | else: 53 | print("No action specified") 54 | 55 | 56 | if __name__ == "__main__": 57 | main() 58 | -------------------------------------------------------------------------------- /common/common/misc.py: -------------------------------------------------------------------------------- 1 | class Singleton(type): 2 | _instances = {} 3 | 4 | def __call__(cls, *args, **kwargs): 5 | if cls not in cls._instances: 6 | cls._instances[cls] = super().__call__(*args, **kwargs) 7 | return cls._instances[cls] 8 | 9 | 10 | class _ClassProperty: 11 | def __init__(self, fget, fset=None): 12 | self.fget = fget 13 | self.fset = fset 14 | 15 | def __get__(self, obj, cls=None): 16 | if cls is None: 17 | cls = type(obj) 18 | return self.fget.__get__(obj, cls)() 19 | 20 | def __set__(self, obj, value): 21 | if not self.fset: 22 | raise AttributeError("can't set attribute") 23 | 24 | cls = type(obj) 25 | return self.fset.__get__(obj, cls)(value) 26 | 27 | def setter(self, func): 28 | if not isinstance(func, (classmethod, staticmethod)): 29 | func = classmethod(func) 30 | self.fset = func 31 | return self 32 | 33 | 34 | def classproperty(func): 35 | if not isinstance(func, (classmethod, staticmethod)): 36 | func = classmethod(func) 37 | return _ClassProperty(func) 38 | 39 | 40 | UTF8 = "utf-8" 41 | UTF16LE = "utf-16-le" 42 | 43 | 44 | def decode_str(encoding=UTF16LE): 45 | def inner_decode(data: bytearray): 46 | if encoding == UTF16LE: 47 | for chunk in data.split(b"\x00\x00"): 48 | if len(chunk) % 2: 49 | chunk += b"\x00" 50 | result = chunk.decode(encoding=encoding) 51 | return result, len(chunk) + 2 52 | elif encoding == UTF8: 53 | result = data.rstrip(b"\x00").decode(encoding=encoding) 54 | return data.rstrip(b"\x00").decode(encoding=encoding), len(result) + 1 55 | else: 56 | raise TypeError() 57 | 58 | return inner_decode 59 | 60 | 61 | def encode_str(text, encoding=UTF16LE): 62 | return text.encode(encoding) + b"\x00\x00" 63 | 64 | 65 | def extend_bytearray(array: bytearray, ctype_data): 66 | for item in ctype_data: 67 | if isinstance(item, str): 68 | item = encode_str(item) 69 | array.extend(bytes(item)) 70 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT_NAME = l2py 2 | PYTHON_VERSION = 3.10 3 | 4 | UNAME = $(shell uname) 5 | DOCKER = $(shell which docker) 6 | COMPOSE = $(shell which docker-compose) 7 | POETRY = $(shell which poetry) 8 | PYTHON = $(shell which python$(PYTHON_VERSION)) 9 | PWD = $(shell pwd) 10 | 11 | REQUIRED_PACKAGES := swig openssl 12 | 13 | 14 | lint: 15 | - poetry run black . --check 16 | - poetry run isort -c --profile=black . 17 | 18 | format: 19 | poetry run black . 20 | poetry run isort --profile=black . 21 | 22 | test: 23 | pytest . 24 | 25 | #install_requirements: 26 | #ifeq ($(UNAME),Darwin) 27 | # brew install openssl; \ 28 | # brew install swig; \ 29 | # export LDFLAGS="-L$(brew --prefix openssl)/lib" \ 30 | # CFLAGS="-I$(brew --prefix openssl)/include" \ 31 | # SWIG_FEATURES="-cpperraswarn -includeall -I$(brew --prefix openssl)/include" 32 | #endif 33 | 34 | install: 35 | $(info Installing poetry:) 36 | curl -sSL https://install.python-poetry.org | $(PYTHON) - 37 | @PATH="/root/.local/bin:$(PATH)" 38 | @export PATH 39 | poetry config virtualenvs.in-project true 40 | poetry install 41 | 42 | docker-build-common: 43 | cd common && $(DOCKER) build -t $(PROJECT_NAME)_common --no-cache . 44 | 45 | docker-build-login: 46 | cd login && $(DOCKER) build -t $(PROJECT_NAME)_login --no-cache . 47 | 48 | docker-build-game: 49 | cd game && $(DOCKER) build -t $(PROJECT_NAME)_game --no-cache . 50 | 51 | docker-build: docker-build-common docker-build-login docker-build-game 52 | 53 | compose-build: 54 | $(COMPOSE) build 55 | 56 | compose-exec: 57 | $(COMPOSE) exec $0 poetry exec bin/$1 58 | 59 | python: 60 | PYTHONSTARTUP=.pythonrc \ 61 | PYTHONPATH=$(PWD):$(PWD)/common:$(PWD)/login:$(PWD)/game \ 62 | python $(filter-out $@,$(MAKECMDGOALS)) 63 | 64 | compose-exec-%: 65 | $(COMPOSE) exec login poetry run $(filter-out $@,$(MAKECMDGOALS)) 66 | 67 | 68 | help: 69 | $(info compose-build) 70 | $(info docker-build-common) 71 | $(info docker-build-data) 72 | $(info docker-build-game) 73 | $(info docker-build-login) 74 | $(info docker-build) 75 | $(info format) 76 | $(info help) 77 | $(info install_requirements) 78 | $(info install) 79 | $(info lint) 80 | $(info python) 81 | $(info test) 82 | @: 83 | -------------------------------------------------------------------------------- /game/game/packets/char_selected.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, ClassVar 2 | 3 | import game.models.world 4 | from common.ctype import ctype 5 | from common.misc import encode_str, extend_bytearray 6 | from game.packets.base import GameServerPacket 7 | 8 | if TYPE_CHECKING: 9 | from game.models.character import Character 10 | from game.session import GameSession 11 | 12 | 13 | class CharSelected(GameServerPacket): 14 | type: ctype.int8 = 21 15 | character: "Character" 16 | 17 | def encode(self, session: "GameSession"): 18 | """Encodes packet to bytearray.""" 19 | 20 | encoded = bytearray(self.type) 21 | 22 | extend_bytearray( 23 | encoded, 24 | [ 25 | self.character.name, 26 | self.character.id, 27 | self.character.title, 28 | session.id, 29 | ctype.int32(0), # TODO: clan id 30 | ctype.int32(0), # unknown 31 | ctype.int32(self.character.appearance.sex), 32 | self.character.race, 33 | self.character.active_class, 34 | self.character.active, 35 | self.character.position.point3d.x, 36 | self.character.position.point3d.y, 37 | self.character.position.point3d.z, 38 | ctype.double(self.character.status.hp.value), 39 | ctype.double(self.character.status.mp.value), 40 | ctype.double(self.character.status.cp.value), 41 | self.character.stats.exp, 42 | self.character.stats.level, 43 | self.character.stats.karma, 44 | self.character.stats.base.INT, 45 | self.character.stats.base.STR, 46 | self.character.stats.base.CON, 47 | self.character.stats.base.MEN, 48 | self.character.stats.base.DEX, 49 | self.character.stats.base.WIT, 50 | *[ctype.int32(0) for _ in range(32)], 51 | game.models.world.WORLD.clock.get_time(), 52 | *[ctype.int32(0) for _ in range(18)], 53 | ], 54 | ) 55 | 56 | return encoded 57 | -------------------------------------------------------------------------------- /game/game/models/structures/character/stats.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from common.ctype import ctype 4 | from common.model import BaseModel 5 | from game.models.structures.character.resists import Resists 6 | 7 | 8 | class BaseStats(BaseModel): 9 | STR: ctype.int32 = 0 10 | CON: ctype.int32 = 0 11 | DEX: ctype.int32 = 0 12 | INT: ctype.int32 = 0 13 | WIT: ctype.int32 = 0 14 | MEN: ctype.int32 = 0 15 | 16 | 17 | class Stats(BaseModel): 18 | max_hp: ctype.int32 = 0 19 | max_mp: ctype.int32 = 0 20 | max_cp: ctype.int32 = 0 21 | regen_hp: ctype.float = 0.0 22 | regen_mp: ctype.float = 0.0 23 | regen_cp: ctype.float = 0.0 24 | gain_mp: ctype.float = 0.0 25 | gain_hp: ctype.float = 0.0 26 | physical_defense: ctype.int32 = 0 27 | magic_defense: ctype.int32 = 0 28 | physical_attack: ctype.int32 = 0 29 | magic_attack: ctype.int32 = 0 30 | physical_attack_speed: ctype.int32 = 0 31 | magic_attack_speed: ctype.int32 = 0 32 | magic_reuse_rate: ctype.int32 = 0 33 | shield_defense: ctype.int32 = 0 34 | critical_damage: ctype.int32 = 0 35 | pvp_physical_damage: ctype.int32 = 0 36 | pvp_magic_damage: ctype.int32 = 0 37 | pvp_physical_skill_damage: ctype.int32 = 0 38 | accuracy: ctype.int32 = 0 39 | physical_attack_range: ctype.int32 = 0 40 | magic_attack_range: ctype.int32 = 0 41 | physical_attack_angle: ctype.int32 = 0 42 | attack_count_max: ctype.int32 = 0 43 | run_speed: ctype.int32 = 70 44 | walk_speed: ctype.int32 = 150 45 | swim_run_speed: ctype.int32 = 0 46 | swim_walk_speed: ctype.int32 = 0 47 | ride_run_speed: ctype.int32 = 0 48 | ride_walk_speed: ctype.int32 = 0 49 | fly_run_speed: ctype.int32 = 0 50 | fly_walk_speed: ctype.int32 = 0 51 | base: BaseStats = Field(default_factory=BaseStats) 52 | resists: Resists = Field(default_factory=Resists) 53 | exp: ctype.int64 = 0 54 | sp: ctype.int32 = 0 55 | level: ctype.int32 = 0 56 | evasion: ctype.int32 = 0 57 | recommends_received: ctype.int16 = 0 58 | recommends_left: ctype.int16 = 0 59 | move_multiplier: ctype.double = 1.0 60 | attack_speed_multiplier: ctype.double = 1.0 61 | karma: ctype.int32 = 0 62 | -------------------------------------------------------------------------------- /common/common/client/client.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from common import exceptions 4 | 5 | 6 | class ApiClient: 7 | def __init__(self, server_ip, server_port): 8 | self.server_ip = server_ip 9 | self.server_port = server_port 10 | 11 | def _delete_none(self, request: dict): 12 | """Removes None values from request.""" 13 | 14 | return {key: value for key, value in request.items() if value is not None} 15 | 16 | def _format_path(self, endpoint): 17 | return f"http://{self.server_ip}:{self.server_port}/{endpoint}" 18 | 19 | async def _make_request(self, endpoint, method, json_data=None): 20 | url = self._format_path(endpoint) 21 | async with aiohttp.ClientSession() as session: 22 | _method = getattr(session, method) 23 | async with _method(url, json=self._delete_none(json_data or {})) as response: 24 | result = await response.json() 25 | if result["error"]: 26 | if isinstance(result["reason"], dict): 27 | if hasattr(exceptions, result["reason"]["code"]): 28 | exc = getattr(exceptions, result["reason"]["code"]) 29 | else: 30 | exc = exceptions.ApiException 31 | raise exc(result["reason"]["text"]) 32 | else: 33 | raise exceptions.ApiException(result["reason"]) 34 | else: 35 | return result["result"] 36 | 37 | async def get(self, endpoint, json_data=None): 38 | return await self._make_request(endpoint, "get", json_data=json_data) 39 | 40 | async def post(self, endpoint, json_data=None): 41 | return await self._make_request(endpoint, "post", json_data=json_data) 42 | 43 | async def delete(self, endpoint, json_data=None): 44 | return await self._make_request(endpoint, "delete", json_data=json_data) 45 | 46 | async def put(self, endpoint, json_data=None): 47 | return await self._make_request(endpoint, "put", json_data=json_data) 48 | 49 | async def patch(self, endpoint, json_data=None): 50 | return await self._make_request(endpoint, "patch", json_data=json_data) 51 | -------------------------------------------------------------------------------- /game/game/api/move.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.packets 3 | import game.states 4 | from common.api_handlers import l2_request_handler 5 | from common.ctype import ctype 6 | from common.template import Parameter, Template 7 | from game.models.character import Character 8 | from game.models.structures.object.point3d import Point3D 9 | from game.models.structures.object.position import Position 10 | from game.request import GameRequest 11 | 12 | 13 | @l2_request_handler( 14 | game.constants.GAME_REQUEST_MOVE_BACK_TO_LOCATION, 15 | Template( 16 | [ 17 | Parameter(id="to_x", start=0, length=4, type=ctype.int32), 18 | Parameter(id="to_y", start="$to_x.stop", length=4, type=ctype.int32), 19 | Parameter(id="to_z", start="$to_y.stop", length=4, type=ctype.int32), 20 | Parameter(id="from_x", start="$to_z.stop", length=4, type=ctype.int32), 21 | Parameter(id="from_y", start="$from_x.stop", length=4, type=ctype.int32), 22 | Parameter(id="from_z", start="$from_y.stop", length=4, type=ctype.int32), 23 | Parameter(id="by_mouse", start="$from_z.stop", length=4, type=ctype.int32), 24 | ] 25 | ), 26 | states="*", # TODO 27 | ) 28 | async def move_back_to_location(request: GameRequest): 29 | character: Character = request.session.character 30 | 31 | if not request.validated_data["by_mouse"]: 32 | return game.packets.ActionFailed() 33 | # TODO check for attack 34 | 35 | to_x = request.validated_data["to_x"] 36 | to_y = request.validated_data["to_y"] 37 | to_z = request.validated_data["to_z"] 38 | 39 | diff_x: ctype.double = to_x - character.position.point3d.x 40 | diff_y: ctype.double = to_y - character.position.point3d.y 41 | 42 | if (diff_x * diff_x + diff_y * diff_y) > 98010000: 43 | return game.packets.ActionFailed() 44 | 45 | new_position = Position( 46 | heading_angle=0, 47 | point3d=Point3D( 48 | x=to_x, 49 | y=to_y, 50 | z=to_z, 51 | ), 52 | ) 53 | 54 | await character.move(new_position) 55 | await character.commit_changes(fields=["position"]) 56 | 57 | 58 | @l2_request_handler( 59 | game.constants.GAME_REQUEST_VALIDATE_POSITION, 60 | Template( 61 | [ 62 | Parameter(id="x", start=0, length=4, type=ctype.int32), 63 | Parameter(id="y", start="$x.stop", length=4, type=ctype.int32), 64 | Parameter(id="z", start="$y.stop", length=4, type=ctype.int32), 65 | Parameter(id="heading", start="$z.stop", length=4, type=ctype.int32), 66 | Parameter(id="data", start="$heading.stop", length=4, type=ctype.int32), 67 | ] 68 | ), 69 | states="*", # TODO 70 | ) 71 | async def validate_position(request): 72 | pass 73 | -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import json 3 | import re 4 | import string 5 | import typing 6 | 7 | camel_case_pattern = re.compile(r"(? 0: 41 | value = [int(val) for val in value.split(" ") if len(val) > 0] 42 | elif all(c in string.digits + " -." for c in value) and len(value) > 0: 43 | value = [float(val) for val in value.split(" ") if len(val) > 0] 44 | elif value == "none": 45 | value = None 46 | elif value == "false": 47 | value = False 48 | elif value == "true": 49 | value = True 50 | if isinstance(value, list) and len(value) == 1: 51 | value = value[0] 52 | elif isinstance(value, decimal.Decimal): 53 | if value % 1 == 0: 54 | value = int(value) 55 | else: 56 | value = float(value) 57 | 58 | data[snake_case_key.replace("for", "action")] = value 59 | 60 | if key != snake_case_key.replace("for", "action"): 61 | del data[key] 62 | 63 | def encode(self, o: typing.Any) -> str: 64 | if isinstance(o, typing.Mapping): 65 | self._inner_dict(o) 66 | if isinstance(o, list): 67 | self._inner_list(o) 68 | return super().encode(o) 69 | -------------------------------------------------------------------------------- /game/game/api/game.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import game.constants 4 | import game.packets 5 | from common.api_handlers import l2_request_handler 6 | from common.ctype import ctype 7 | from common.misc import decode_str 8 | from common.models import Account 9 | from common.template import Parameter, Template 10 | from game.models.character import Character 11 | from game.states import Connected, WaitingAuthentication 12 | 13 | LOG = logging.getLogger(f"l2py.{__name__}") 14 | 15 | 16 | @l2_request_handler( 17 | game.constants.GAME_REQUEST_PROTOCOL_VERSION, 18 | Template([Parameter(id="protocol_version", start=0, length=4, type=ctype.int)]), 19 | states=[Connected], 20 | ) 21 | async def protocol_version(request): 22 | 23 | if ( 24 | request.validated_data["protocol_version"] != 746 25 | and request.validated_data["protocol_version"] != 251 26 | ): 27 | return game.packets.CryptInit(is_valid=False, xor_key=request.session.xor_key.outgoing_key) 28 | 29 | elif request.validated_data["protocol_version"] == -1: 30 | return game.packets.CryptInit(is_valid=False, xor_key=request.session.xor_key.outgoing_key) 31 | 32 | else: 33 | request.session.set_state(WaitingAuthentication) 34 | request.session.send_packet( 35 | game.packets.CryptInit(is_valid=True, xor_key=request.session.xor_key.outgoing_key) 36 | ) 37 | request.session.encryption_enabled = True 38 | 39 | 40 | @l2_request_handler( 41 | game.constants.GAME_REQUEST_AUTH_LOGIN, 42 | Template( 43 | [ 44 | Parameter(id="login", start=0, type=str, func=decode_str()), 45 | Parameter(id="play_ok2", start="$login.stop", length=4, type=ctype.int32), 46 | Parameter(id="play_ok1", start="$play_ok2.stop", length=4, type=ctype.int32), 47 | Parameter(id="login_ok1", start="$play_ok1.stop", length=4, type=ctype.int32), 48 | Parameter(id="login_ok2", start="$login_ok1.stop", length=4, type=ctype.int32), 49 | ] 50 | ), 51 | states=[WaitingAuthentication], 52 | ) 53 | async def auth_login(request): 54 | 55 | account = await Account.one(username=request.validated_data["login"], required=False) 56 | if account is not None: 57 | if ( 58 | account.game_auth.login_ok1 == request.validated_data["login_ok1"] 59 | and account.game_auth.login_ok2 == request.validated_data["login_ok2"] 60 | and account.game_auth.play_ok1 == request.validated_data["play_ok1"] 61 | and account.game_auth.play_ok2 == request.validated_data["play_ok2"] 62 | ): 63 | request.session.account = account 64 | request.session.set_state(game.states.WaitingCharacterSelect) 65 | return game.packets.CharList( 66 | characters=await Character.all(account_username=account.username) 67 | ) 68 | 69 | return game.packets.AuthLoginFail( 70 | reason_id=game.constants.GAME_AUTH_LOGIN_FAIL_PASSWORD_DOESNT_MATCH 71 | ) 72 | return game.packets.AuthLoginFail(reason_id=game.constants.GAME_AUTH_LOGIN_FAIL_DEFAULT) 73 | -------------------------------------------------------------------------------- /login/login/session.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from common.ctype import ctype 4 | from common.session import Session 5 | from login.config import DEBUG 6 | from login.keys.blowfish import BlowfishKey 7 | from login.keys.rsa import L2RsaKey 8 | from login.keys.session import SessionKey 9 | from login.keys.xor import LoginXorKey 10 | from login.protocol import Lineage2LoginProtocol 11 | from login.state import State 12 | 13 | 14 | class LoginSession(Session): 15 | def __init__(self, protocol): 16 | super().__init__() 17 | 18 | self.protocol: Lineage2LoginProtocol = protocol 19 | self.state: typing.Type[State, None] = None 20 | 21 | if DEBUG: 22 | self.id = ctype.int32(1) 23 | self.state: typing.Type[State, None] = None 24 | self.rsa_key: L2RsaKey = L2RsaKey( 25 | n=114864864492067965740896094499845788661704547603461946041788430244130842942327562108037881765257637001470002088493469466590330012894850351298499234054400902909094648545037681203002379437662692263023086237588859126444600832405209700229179654815477929362986913374184282884226518004040399737373993652540837590413, 26 | e=65537, 27 | d=24101430761959650485901054663640453504650268706716909310880009023827826327275918770132229452439066188816460415023510719173143042705957106570234945027855716930584593038616254904391656876797375966251389087225009936033550634664544981501673315139777365280685902473060207377829297089665705686626339175100783535953, 28 | p=9916011190118260834420732077691009653459567295520159291150209477378355320775407287143204582642394937713625198095479788156570194891697637999460965480152817, 29 | q=11583777215432736777867145057369984035716447897863364266554340327857721534451397646420941640550969480924837472920543549449999218012096863136050055128324189, 30 | u=8749384252980206798590996271998110590139167440335051391024304811619350675706554671938061284513578306506720862764773635685258662045283301164468169301716502, 31 | ) 32 | self.blowfish_key: BlowfishKey = BlowfishKey( 33 | b']\xc7\x9c\xf33\x82PN\xe9]\x1f\x05"y\xdf\xad' 34 | ) 35 | self.session_key: SessionKey = SessionKey( 36 | **{ 37 | "login_ok1": 1777271179, 38 | "login_ok2": 250194844, 39 | "play_ok1": 1632717010, 40 | "play_ok2": 930316699, 41 | } 42 | ) 43 | self.xor_key: LoginXorKey = LoginXorKey(1882434664) 44 | else: 45 | self.id: ctype.int32 = ctype.int32.random() 46 | self.rsa_key: L2RsaKey = L2RsaKey.generate() 47 | self.blowfish_key: BlowfishKey = BlowfishKey() 48 | self.session_key: SessionKey = SessionKey() 49 | self.xor_key: LoginXorKey = LoginXorKey() 50 | 51 | self.protocol_version: ctype.int32 = 50721 52 | self.blowfish_enabled: ctype.bool = False 53 | self.account = None 54 | 55 | @classmethod 56 | def by_username(cls, username): 57 | for session_id, session in cls.data().items(): 58 | if session["account"].username == username: 59 | return {session_id: session} 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | L2py 2 | ==== 3 | 4 | Code style: black 5 | [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 6 | chat on Discord 7 | 8 | Lineage2 Interlude+ server emulator written in python3 9 | 10 | Stage: Alpha 11 | 12 | 13 | What currently works 14 | -------------------- 15 | - [x] Login Server 16 | - [x] Game Server 17 | 18 | 19 | Contribute 20 | ---------- 21 | 22 | Feel free to join developing our server: 23 | * If you have some suggestions - please [open an Issue](https://github.com/Yurzs/L2py/issues/new/choose). 24 | * If you want to implement some features - check [Project page](https://github.com/Yurzs/L2py/projects/1). 25 | * Or join our [Discord server](https://discord.gg/hgdFQYxtvm) 26 | 27 | How to start developing 28 | ======================= 29 | 30 | * Copy `.env.example` to `.env`. 31 | * Set environment variables as you need in `.env` 32 | 33 | Using docker-compose 34 | ------------ 35 | 36 | * Make sure you have `make, docker, docker-compose` installed. 37 | * Build docker images `make docker-build` 38 | * Start containers `docker-compose up -d` 39 | * Register game server in database `make compose-exec-login register_game_server `. 40 | _NOTE: can't be `0.0.0.0`_ 41 | 42 | Without docker-compose 43 | -------------- 44 | * Make sure you have `make, docker, docker-compose` installed. 45 | * Install poetry using `make install` 46 | * Run `poetry install` 47 | * Start mongodb in container or using other methods. 48 | * Activate virtual environment `. .venv/bin/activate` 49 | * Register game server in database `login/bin/register_game_server ` 50 | _NOTE: can't be `0.0.0.0`_ 51 | 52 | _NOTE: Apply environment variables with `source .env`_ 53 | * Start login server `make python -m login/login` 54 | * Start game server `make python -m game/game` 55 | 56 | Emulator server architecture 57 | ---------------- 58 | 59 | Project is split to 2 components: 60 | 61 | - `Login Server` - L2 login service + basic HTTP API 62 | - `Game Server` - L2 game service + basic HTTP API 63 | 64 | All those services have own instances of `common.application.Application` 65 | with specific modules (for example game server have `TCPServerModule`, `HTTPServerModule`, `ScheduleModule`). 66 | 67 | ApplicationModules 68 | ------------------ 69 | 70 | Each `ApplicationModule` adds functionality to main application process. 71 | All modules are running in one asyncio loop. 72 | 73 | - `TCPServerModule`: L2 protocol requests handler 74 | - `HTTPServerModule`: HTTP JSON requests handler 75 | - `ScheduleModule`: CRON tasks runner 76 | 77 | TCPServerModule Middlewares 78 | --------------------------- 79 | 80 | Middlewares are used in L2 protocol handler for convenient way for not caring 81 | about all those complicated protocol specific encryption. 82 | 83 | Data types 84 | ---------- 85 | 86 | Most of the custom data types derive from ctypes (At least numeric ones.) 87 | -------------------------------------------------------------------------------- /common/common/template.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | import typing 5 | 6 | from common.model import BaseModel 7 | 8 | 9 | class Parameter(BaseModel): 10 | id: str 11 | start: typing.Union[int, str] 12 | type: typing.Union[typing.Type, Template] 13 | length: int = None 14 | func: typing.Optional[typing.Callable] = None 15 | stop: typing.Optional[int] = None 16 | repeat: typing.Union[str, int] = None 17 | 18 | def parse(self, data, namespace): 19 | if self.repeat is None: 20 | repeat_count = 1 21 | else: 22 | repeat_count = self.repeat if isinstance(self.repeat, int) else namespace[self.repeat] 23 | 24 | result = [] 25 | total_len = 0 26 | 27 | for _ in range(int(repeat_count)): 28 | param_len = 0 29 | if isinstance(self.type, Template): 30 | template = copy.deepcopy(self.type) 31 | value = template.parse_request(data[total_len:]) 32 | param_len = template.parameters[-1].stop 33 | 34 | elif self.func is not None: 35 | value, param_len = self.func(data[total_len:]) 36 | 37 | elif self.length is not None: 38 | if hasattr(self.type, "decode"): 39 | value, param_len = self.type.decode(data[total_len:]), self.length 40 | else: 41 | value, param_len = self.type(data[total_len:]), self.length 42 | total_len += param_len 43 | result.append(value) 44 | return result[0] if self.repeat is None else result, total_len 45 | 46 | 47 | class Template: 48 | def __init__(self, parameters: typing.List[Parameter]): 49 | self.template = {f"${parameter.id}": parameter for parameter in parameters.copy()} 50 | self.parameters = parameters 51 | 52 | def get_start(self, parameter_id): 53 | start = self.template[f"${parameter_id}"].start 54 | if isinstance(start, str): 55 | param_id, attr = start.split(".") 56 | return getattr(self.template[param_id], attr) 57 | return start 58 | 59 | def get_stop(self, parameter_id): 60 | stop = self.template[f"${parameter_id}"].stop 61 | if isinstance(stop, str): 62 | param_id, attr = stop.split(".") 63 | return getattr(self.template[param_id], attr) 64 | return stop 65 | 66 | def set_start(self, parameter_id, value): 67 | self.template[f"${parameter_id}"].start = value 68 | 69 | def set_stop(self, parameter_id, value): 70 | self.template[f"${parameter_id}"].stop = value 71 | 72 | def parse_request(self, data: bytearray): 73 | result = {} 74 | 75 | for parameter in self.parameters: 76 | start = self.get_start(parameter.id) 77 | if parameter.length is not None: 78 | chunk = bytearray(data[start : start + parameter.length]) 79 | elif parameter.stop is not None: 80 | chunk = bytearray(data[start : parameter.stop]) 81 | else: 82 | chunk = bytearray(data[start:]) 83 | parsed_value, stop = parameter.parse( 84 | chunk, namespace={f"${key}": value for key, value in result.items()} 85 | ) 86 | self.set_stop(parameter.id, start + stop) 87 | result[parameter.id] = parsed_value 88 | return result 89 | 90 | 91 | Parameter.update_forward_refs() 92 | -------------------------------------------------------------------------------- /game/game/static/character_template.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar 2 | 3 | from common.ctype import ctype 4 | from common.misc import extend_bytearray 5 | from game.models.structures.character.stats import Stats 6 | from game.models.structures.character.template import LevelUpGain 7 | from game.models.structures.object.point3d import Point3D 8 | from game.static.static import StaticData 9 | 10 | 11 | class StaticCharacterTemplate(StaticData): 12 | spawn: Point3D 13 | items: list[ctype.int32] 14 | class_id: ctype.int32 15 | class_name: str 16 | race_id: ctype.int32 17 | stats: Stats 18 | load: ctype.int32 19 | can_craft: ctype.int8 20 | male_unk1: ctype.float 21 | male_unk2: ctype.float 22 | male_collision_radius: ctype.double 23 | male_collision_height: ctype.double 24 | female_unk1: ctype.float 25 | female_unk2: ctype.float 26 | female_collision_radius: ctype.double 27 | female_collision_height: ctype.double 28 | level_up_gain: LevelUpGain 29 | base_level: ctype.int32 30 | 31 | filepath: ClassVar[str] = "game/data/char_templates.json" 32 | 33 | @classmethod 34 | def by_id(cls, class_id) -> "CharacterBaseTemplate": 35 | for template in cls.read_file(): 36 | if template.class_id == class_id: 37 | return template 38 | 39 | def encode(self, strings_format="utf-16-le") -> bytearray: 40 | result = bytearray() 41 | 42 | extend_bytearray( 43 | result, 44 | [ 45 | self.race_id, 46 | self.class_id, 47 | ctype.int32(0x46), 48 | self.stats.base.STR, 49 | ctype.int32(0x0A), 50 | ctype.int32(0x46), 51 | self.stats.base.DEX, 52 | ctype.int32(0x0A), 53 | ctype.int32(0x46), 54 | self.stats.base.CON, 55 | ctype.int32(0x0A), 56 | ctype.int32(0x46), 57 | self.stats.base.INT, 58 | ctype.int32(0x0A), 59 | ctype.int32(0x46), 60 | self.stats.base.WIT, 61 | ctype.int32(0x0A), 62 | ctype.int32(0x46), 63 | self.stats.base.MEN, 64 | ctype.int32(0x0A), 65 | # ctype.int32(0x46), 66 | # ctype.int32(0x0A), 67 | ], 68 | ) 69 | 70 | return result 71 | 72 | # @StaticData.encode_fields 73 | # def encode(self): 74 | # return [ 75 | # (self.race_id, ctype.int32), 76 | # (self.class_id, ctype.int32), 77 | # (0x46, ctype.int32), 78 | # (self.stats.base.str, ctype.int32), 79 | # (0x0A, ctype.int32), 80 | # (0x46, ctype.int32), 81 | # (self.stats.base.dex, ctype.int32), 82 | # (0x0A, ctype.int32), 83 | # (0x46, ctype.int32), 84 | # (self.stats.base.con, ctype.int32), 85 | # (0x0A, ctype.int32), 86 | # (0x46, ctype.int32), 87 | # (self.stats.base.int, ctype.int32), 88 | # (0x0A, ctype.int32), 89 | # (0x46, ctype.int32), 90 | # (self.stats.base.wit, ctype.int32), 91 | # (0x0A, ctype.int32), 92 | # (0x46, ctype.int32), 93 | # (self.stats.base.men, ctype.int32), 94 | # (0x0A, ctype.int32), 95 | # (0x46, ctype.int32), 96 | # (0x0A, ctype.int32), 97 | # ] 98 | -------------------------------------------------------------------------------- /game/game/static/item.py: -------------------------------------------------------------------------------- 1 | from typing import ClassVar, Optional 2 | 3 | from pydantic import Field, validator 4 | 5 | from common.ctype import ctype 6 | from common.model import BaseModel 7 | from game.models.structures.skill.skill import Skill 8 | 9 | 10 | class Materials(BaseModel): 11 | steel: ClassVar[ctype.int32] = 0 12 | fine_steel: ClassVar[ctype.int32] = 1 13 | blood_steel: ClassVar[ctype.int32] = 2 14 | bronze: ClassVar[ctype.int32] = 3 15 | silver: ClassVar[ctype.int32] = 4 16 | gold: ClassVar[ctype.int32] = 5 17 | mithril: ClassVar[ctype.int32] = 6 18 | oriharukon: ClassVar[ctype.int32] = 7 19 | paper: ClassVar[ctype.int32] = 8 20 | wood: ClassVar[ctype.int32] = 9 21 | cloth: ClassVar[ctype.int32] = 10 22 | leather: ClassVar[ctype.int32] = 11 23 | bone: ClassVar[ctype.int32] = 12 24 | damascus: ClassVar[ctype.int32] = 13 25 | adamantaite: ClassVar[ctype.int32] = 14 26 | chrysolite: ClassVar[ctype.int32] = 15 27 | crystal: ClassVar[ctype.int32] = 16 28 | liquid: ClassVar[ctype.int32] = 17 29 | scale_of_dragon: ClassVar[ctype.int32] = 18 30 | dyestuff: ClassVar[ctype.int32] = 19 31 | coweb: ClassVar[ctype.int32] = 20 32 | seed: ClassVar[ctype.int32] = 21 33 | 34 | 35 | class CrystalType(BaseModel): 36 | D: ClassVar[ctype.int32] = 1 37 | C: ClassVar[ctype.int32] = 2 38 | B: ClassVar[ctype.int32] = 3 39 | A: ClassVar[ctype.int32] = 4 40 | S: ClassVar[ctype.int32] = 5 41 | 42 | 43 | class CrystalItem: 44 | D: ClassVar[ctype.int32] = 1458 45 | C: ClassVar[ctype.int32] = 1459 46 | B: ClassVar[ctype.int32] = 1460 47 | A: ClassVar[ctype.int32] = 1461 48 | S: ClassVar[ctype.int32] = 1462 49 | 50 | 51 | class CrystalEnchantBonusArmor: 52 | D: ClassVar[ctype.int32] = 11 53 | C: ClassVar[ctype.int32] = 6 54 | B: ClassVar[ctype.int32] = 11 55 | A: ClassVar[ctype.int32] = 19 56 | S: ClassVar[ctype.int32] = 25 57 | 58 | 59 | class CrystalEnchantBonusWeapon: 60 | D: ClassVar[ctype.int32] = 90 61 | C: ClassVar[ctype.int32] = 45 62 | B: ClassVar[ctype.int32] = 67 63 | A: ClassVar[ctype.int32] = 144 64 | S: ClassVar[ctype.int32] = 250 65 | 66 | 67 | class Crystal: 68 | TYPE = CrystalType 69 | ITEM = CrystalItem 70 | BONUS_WEAPON = CrystalEnchantBonusWeapon 71 | BONUS_ARMOR = CrystalEnchantBonusArmor 72 | 73 | 74 | class ItemProperties(BaseModel): 75 | crystallizable: ctype.bool = False 76 | stackable: ctype.bool = False 77 | sellable: ctype.bool = False 78 | droppable: ctype.bool = False 79 | destroyable: ctype.bool = False 80 | tradable: ctype.bool = False 81 | degradable: ctype.bool = False 82 | 83 | 84 | class Crystallization(BaseModel): 85 | type: ctype.int32 | None = 0 86 | count: ctype.int32 = 0 87 | 88 | 89 | class Item(BaseModel): 90 | id: ctype.int32 91 | name: str 92 | type: str | None 93 | inventory_type: Optional[str] 94 | special_type: Optional[str] 95 | weight: ctype.int32 96 | material: str 97 | crystallization: Crystallization 98 | duration: ctype.int32 99 | body_part: Optional[str] 100 | price: ctype.int32 101 | properties: ItemProperties 102 | 103 | skills: list[Skill] = Field(default_factory=list) 104 | 105 | @classmethod 106 | @validator("material") 107 | def validate_material(cls, v): 108 | if v not in dir(Materials): 109 | raise Exception("Unknown material") 110 | return v 111 | -------------------------------------------------------------------------------- /game/game/models/structures/character/character.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | import game.packets 6 | from common.ctype import ctype 7 | from game.broadcaster import Broadcaster 8 | from game.models.structures.character.stats import Stats 9 | from game.models.structures.character.status import Status 10 | from game.models.structures.character.template import CharacterTemplate 11 | from game.models.structures.character.updates import UpdateChecks 12 | from game.models.structures.object.playable import Playable 13 | from game.models.structures.skill.skill import Skill 14 | from game.models.structures.world_region import WorldRegion 15 | 16 | 17 | class Character(Playable): 18 | stats: Stats 19 | status: Status 20 | template: CharacterTemplate 21 | attacked_by: list = Field(default_factory=list) 22 | 23 | last_skill: Optional[Skill] = None 24 | last_heal_amount: ctype.int32 = 0 25 | title: str = "" 26 | ai_class: str = "" 27 | hp_updates: UpdateChecks = Field(default_factory=UpdateChecks) 28 | skills: list[Skill] = Field(default_factory=list) 29 | current_zone: WorldRegion = Field(default_factory=WorldRegion) 30 | name_color: ctype.int32 = 2147483647 31 | title_color: ctype.int32 = 2147483647 32 | 33 | # TODO: Find a better place for those properties 34 | @property 35 | def weight_penalty(self) -> ctype.int32: 36 | return ctype.int32(0) 37 | 38 | @property 39 | def exp_penalty(self) -> ctype.int32: 40 | return ctype.int32(0) 41 | 42 | @property 43 | def exp_protected(self) -> ctype.int32: 44 | return ctype.int32(0) 45 | 46 | @property 47 | def death_penalty(self) -> ctype.int32: 48 | return ctype.int32(0) 49 | 50 | @property 51 | def inventory_max(self) -> ctype.int16: 52 | return ctype.int16(80) 53 | 54 | @property 55 | def warehouse_max(self) -> ctype.int32: 56 | return ctype.int32(80) 57 | 58 | @property 59 | def private_sell_max(self) -> ctype.int32: 60 | return ctype.int32(80) 61 | 62 | @property 63 | def private_buy_max(self) -> ctype.int32: 64 | return ctype.int32(80) 65 | 66 | @property 67 | def freight_max(self) -> ctype.int32: 68 | return ctype.int32(80) 69 | 70 | @property 71 | def dwarf_receipt_max(self) -> ctype.int32: 72 | return ctype.int32(80) 73 | 74 | @property 75 | def common_receipt_max(self) -> ctype.int32: 76 | return ctype.int32(80) 77 | 78 | @Broadcaster.broadcast( 79 | lambda self, *args, **kwargs: game.packets.CharMoveToLocation( 80 | character=self, new_position=self.position 81 | ), 82 | to_me=True, 83 | ) 84 | async def move(self, new_position): 85 | self.position = new_position 86 | 87 | @Broadcaster.broadcast( 88 | lambda self, action_id: game.packets.SocialAction( 89 | character_id=self.id, action_id=action_id 90 | ), 91 | to_me=True, 92 | pass_args_kwargs=True, 93 | ) 94 | async def use_social_action(self, action_id): 95 | pass 96 | 97 | @Broadcaster.broadcast(packet_constructor=lambda self: game.packets.CharInfo(character=self)) 98 | @Broadcaster.broadcast( 99 | packet_constructor=lambda self: game.packets.CharMoveToLocation( 100 | character=self, new_position=self.position 101 | ) 102 | ) 103 | async def spawn(self): 104 | pass 105 | -------------------------------------------------------------------------------- /common/common/document.py: -------------------------------------------------------------------------------- 1 | import json 2 | import typing 3 | from typing import ClassVar 4 | 5 | import bson 6 | import pymongo 7 | from motor.motor_asyncio import AsyncIOMotorClient 8 | from pydantic import Field 9 | 10 | from common import exceptions 11 | from common.config import Config 12 | from common.json import JsonEncoder 13 | from common.model import BaseModel 14 | 15 | 16 | class Document(BaseModel): 17 | __primary_key__: ClassVar[str] = "object_id" 18 | 19 | __database__: ClassVar[str] 20 | __collection__: ClassVar[str] 21 | 22 | NotFoundError: ClassVar = exceptions.DocumentNotFound 23 | 24 | object_id: str = Field(default_factory=lambda: str(bson.ObjectId()), alias="_id") 25 | 26 | @property 27 | def primary_key(self): 28 | return getattr(self, self.__primary_key__) 29 | 30 | @property 31 | def primary_key_field_name(self): 32 | return self.__fields__[self.__primary_key__].alias 33 | 34 | @classmethod 35 | def client(cls) -> AsyncIOMotorClient: 36 | return AsyncIOMotorClient(Config().MONGO_URI) 37 | 38 | @classmethod 39 | def sync_client(cls) -> pymongo.MongoClient: 40 | return pymongo.MongoClient(Config().MONGO_URI) 41 | 42 | @classmethod 43 | def database(cls): 44 | return cls.client()[cls.__database__] 45 | 46 | @classmethod 47 | def sync_database(cls): 48 | return cls.sync_client()[cls.__database__] 49 | 50 | @classmethod 51 | def collection(cls): 52 | return cls.database()[cls.__collection__] 53 | 54 | @classmethod 55 | def sync_collection(cls): 56 | return cls.sync_database()[cls.__collection__] 57 | 58 | @classmethod 59 | async def one(cls, document_id=None, add_query=None, required=True, **kwargs): 60 | """Finds one document by ID.""" 61 | 62 | query = {} 63 | if document_id is not None: 64 | query["_id"] = document_id 65 | if add_query is not None: 66 | query.update(add_query) 67 | 68 | encoder = JsonEncoder() 69 | query = encoder.encode_dict(query) 70 | 71 | result = await cls.collection().find_one(query, **kwargs) 72 | if result is not None: 73 | return cls(**result) 74 | elif required: 75 | raise cls.NotFoundError() 76 | 77 | @classmethod 78 | async def all(cls, document_ids=None, add_query=None, **kwargs): 79 | """Finds all documents based in IDs.""" 80 | 81 | query = {} 82 | if document_ids is not None: 83 | query["_id"] = {"$in": document_ids} 84 | if add_query is not None: 85 | query.update(add_query) 86 | 87 | documents = [] 88 | async_for = True 89 | 90 | encoder = JsonEncoder() 91 | query = encoder.encode_dict(query) 92 | 93 | cursor = cls.collection().find(query, **kwargs) 94 | if isinstance(cursor, typing.Coroutine): 95 | cursor = await cursor 96 | async_for = False 97 | 98 | if async_for: 99 | async for document in cursor: 100 | documents.append(cls(**document)) 101 | else: 102 | for document in cursor: 103 | documents.append(cls(**document)) 104 | return documents 105 | 106 | def delete(self): 107 | """Deletes document from collection.""" 108 | 109 | return self.collection().delete_one({self.primary_key_field_name: self.primary_key}) 110 | 111 | async def commit_changes(self, fields=None): 112 | """Saves changed document to collection.""" 113 | 114 | search_query = {self.primary_key_field_name: self.primary_key} 115 | update_query = {"$set": {}} 116 | fields = fields if fields is not None else [field for field in self.dict()] 117 | 118 | data = self.dict() 119 | 120 | for field in fields: 121 | update_query["$set"].update({field: data[field]}) 122 | return await self.collection().update_one(search_query, update_query) 123 | 124 | async def insert(self): 125 | """Inserts document into collection.""" 126 | 127 | return await self.collection().insert_one(self.dict()) 128 | -------------------------------------------------------------------------------- /game/game/constants.py: -------------------------------------------------------------------------------- 1 | GAME_REQUEST_AUTH_LOGIN = 8 2 | GAME_REQUEST_CHARACTER_CREATE = 11 3 | GAME_REQUEST_CHARACTER_DELETE = 12 4 | GAME_REQUEST_NEW_CHARACTER = 14 5 | GAME_REQUEST_PROTOCOL_VERSION = 0 6 | GAME_REQUEST_CHARACTER_RESTORE = 98 7 | GAME_REQUEST_CHARACTER_SELECTED = 13 8 | GAME_REQUEST_ENTER_WORLD = 3 9 | GAME_REQUEST_MOVE_BACK_TO_LOCATION = 1 10 | GAME_REQUEST_RESTART = 70 11 | GAME_REQUEST_SAY2 = 56 12 | GAME_REQUEST_ACTION = 4 13 | GAME_REQUEST_TARGET_CANCEL = 55 14 | GAME_REQUEST_ATTACK = 10 15 | GAME_REQUEST_OPEN_MINIMAP = 205 16 | GAME_REQUEST_ACTION_USE = 69 17 | GAME_REQUEST_SOCIAL_ACTION = 27 18 | GAME_REQUEST_VALIDATE_POSITION = 72 19 | GAME_REQUEST_JOIN_PARTY = 41 20 | GAME_REQUEST_WITHDRAW_PARTY = 43 21 | GAME_REQUEST_KICK_PARTY_MEMBER = 44 22 | GAME_REQUEST_PARTY_MATCH_CONFIG = 111 23 | GAME_REQUEST_HAND_OVER_PARTY_LEADER = 208 24 | GAME_REQUEST_ASK_JOIN_MPCC = 208 25 | GAME_REQUEST_GROUP_DUEL = 208 26 | GAME_REQUEST_MAKE_MACRO = 193 27 | GAME_REQUEST_DELETE_MACRO = 194 28 | GAME_REQUEST_FRIEND_INVITE = 94 29 | GAME_REQUEST_FRIEND_INVITE_ANSWER = 95 30 | GAME_REQUEST_FRIEND_MESSAGE = 204 31 | GAME_REQUEST_FRIEND_DELETE = 97 32 | 33 | GAME_REQUEST_SHORTCUT_REG = 51 # 0x33 34 | 35 | GAME_AUTH_LOGIN_FAIL_DEFAULT = 0 36 | GAME_AUTH_LOGIN_FAIL_SYSTEM_ERROR = 1 37 | GAME_AUTH_LOGIN_FAIL_PASSWORD_DOESNT_MATCH = 2 38 | GAME_AUTH_LOGIN_FAIL_TRY_LATER = 4 39 | GAME_AUTH_LOGIN_FAIL_CONTACT_SUPPORT = 5 40 | GAME_AUTH_LOGIN_FAIL_ALREADY_IN_USE = 7 41 | 42 | GAME_CHAR_CREATE_FAIL_DEFAULT = 0 43 | GAME_CHAR_CREATE_FAIL_TOO_MANY = 1 44 | GAME_CHAR_CREATE_FAIL_ALREADY_EXIST = 2 45 | GAME_CHAR_CREATE_FAIL_ENCODING_ERROR = 3 46 | 47 | BODY_PART_HEAD = "head" 48 | BODY_PART_HEAD_ALL = "dhead" 49 | BODY_PART_FACE = "face" 50 | BODY_PART_HAIR = "hair" 51 | BODY_PART_DHAIR = "dhair" 52 | BODY_PART_LEFT_EAR = "lear" 53 | BODY_PART_RIGHT_EAR = "rear" 54 | BODY_PART_NECK = "neck" 55 | BODY_PART_LEFT_FINGER = "lfinger" 56 | BODY_PART_RIGHT_FINGER = "rfinger" 57 | BODY_PART_GLOVES = "gloves" 58 | BODY_PART_CHEST = "chest" 59 | BODY_PART_LEGS = "legs" 60 | BODY_PART_FEET = "feet" 61 | BODY_PART_FULL_ARMOR = "fullarmor" 62 | BODY_PART_UNDERWEAR = "underwear" 63 | BODY_PART_WOLF = "wolf" 64 | BODY_PART_HATCHLING = "hatchling" 65 | BODY_PART_STRIDER = "strider" 66 | BODY_PART_BABY_PET = "babypet" 67 | 68 | ALL_BODY_PARTS = [ 69 | BODY_PART_HEAD, 70 | BODY_PART_HEAD_ALL, 71 | BODY_PART_FACE, 72 | BODY_PART_HAIR, 73 | BODY_PART_DHAIR, 74 | BODY_PART_LEFT_EAR, 75 | BODY_PART_RIGHT_EAR, 76 | BODY_PART_NECK, 77 | BODY_PART_LEFT_FINGER, 78 | BODY_PART_RIGHT_FINGER, 79 | BODY_PART_GLOVES, 80 | BODY_PART_CHEST, 81 | BODY_PART_LEGS, 82 | BODY_PART_FEET, 83 | BODY_PART_FULL_ARMOR, 84 | BODY_PART_UNDERWEAR, 85 | BODY_PART_WOLF, 86 | BODY_PART_HATCHLING, 87 | BODY_PART_STRIDER, 88 | BODY_PART_BABY_PET, 89 | ] 90 | 91 | ARMOR_TYPE_LIGHT = "light" 92 | ARMOR_TYPE_HEAVY = "heavy" 93 | 94 | ACTION_TARGET = 0 95 | ACTION_TARGET_SHIFT = 1 96 | ACTION_SIT = 0 97 | ACTION_RUN = 1 98 | ACTION_FAKE_DEATH_START = 2 99 | ACTION_FAKE_DEATH_STOP = 3 100 | ACTION_COMMON_CRAFT = 51 101 | 102 | SOCIAL_ACTION_HELLO = 2 103 | SOCIAL_ACTION_VICTORY = 3 104 | SOCIAL_ACTION_ADVANCE = 4 105 | SOCIAL_ACTION_NO = 5 106 | SOCIAL_ACTION_YES = 6 107 | SOCIAL_ACTION_BOW = 7 108 | SOCIAL_ACTION_UNAWARE = 8 109 | SOCIAL_ACTION_WAITING = 9 110 | SOCIAL_ACTION_LAUGH = 10 111 | SOCIAL_ACTION_APPLAUD = 11 112 | SOCIAL_ACTION_DANCE = 12 113 | SOCIAL_ACTION_SORROW = 13 114 | PUBLIC_SOCIAL_ACTIONS = [ 115 | SOCIAL_ACTION_HELLO, 116 | SOCIAL_ACTION_VICTORY, 117 | SOCIAL_ACTION_ADVANCE, 118 | SOCIAL_ACTION_NO, 119 | SOCIAL_ACTION_YES, 120 | SOCIAL_ACTION_BOW, 121 | SOCIAL_ACTION_UNAWARE, 122 | SOCIAL_ACTION_WAITING, 123 | SOCIAL_ACTION_LAUGH, 124 | SOCIAL_ACTION_APPLAUD, 125 | SOCIAL_ACTION_DANCE, 126 | SOCIAL_ACTION_SORROW, 127 | ] 128 | 129 | SEVEN_SIGNS_PERIOD_COMPETITION_RECRUITING = 0 130 | SEVEN_SIGNS_PERIOD_COMPETITION = 1 131 | SEVEN_SIGNS_PERIOD_COMPETITION_RESULTS = 2 132 | SEVEN_SIGNS_PERIOD_SEAL_VALIDATION = 3 133 | 134 | WAIT_TYPE_SITTING = 0 135 | WAIT_TYPE_STANDING = 1 136 | WAIT_TYPE_START_FAKE_DEATH = 2 137 | WAIT_TYPE_STOP_FAKE_DEATH = 3 138 | 139 | """System Messages""" 140 | WELCOME_TO_LINEAGE = 34 141 | -------------------------------------------------------------------------------- /login/login/api/login.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import login.constants 4 | from common.api_handlers import l2_request_handler 5 | from common.client.exceptions import ApiException, WrongCredentials 6 | from common.ctype import ctype 7 | from common.misc import decode_str 8 | from common.models import Account, GameServer 9 | from common.template import Parameter, Template 10 | from login.api.handlers import verify_secrets 11 | from login.packets import GGAuth, LoginFail, LoginOk, PlayFail, PlayOk, ServerList 12 | from login.state import Authenticated, Connected, GGAuthenticated, WaitingGameServerSelect 13 | 14 | LOG = logging.getLogger(f"l2py.{__name__}") 15 | 16 | 17 | @l2_request_handler( 18 | login.constants.REQUEST_AUTH_LOGIN, 19 | Template([]), 20 | states=[GGAuthenticated], 21 | ) 22 | async def auth_login(request): 23 | 24 | encrypted = bytearray(request.data[0:128]) 25 | decrypted = request.session.rsa_key.private_decrypt(encrypted) 26 | try: 27 | username = decode_str("utf-8")(decrypted[94:107])[0] 28 | password = decode_str("utf-8")(decrypted[108:124])[0] 29 | except UnicodeDecodeError: 30 | return LoginFail(reason_id=login.constants.LOGIN_FAIL_WRONG_LOGIN_OR_PASSWORD) 31 | 32 | try: 33 | account = await Account.one(username=username, required=False) 34 | if account is None: 35 | account = await Account.new(username=username, password=password) 36 | if not account.authenticate(password): 37 | raise WrongCredentials("Wrong Password") 38 | except WrongCredentials: 39 | return LoginFail(reason_id=login.constants.LOGIN_FAIL_WRONG_LOGIN_OR_PASSWORD) 40 | except ApiException: 41 | return LoginFail(reason_id=login.constants.LOGIN_FAIL_DATABASE_ERROR) 42 | except Exception as e: 43 | LOG.exception(e) 44 | return LoginFail(reason_id=login.constants.LOGIN_FAIL_WRONG_PASSWORD) 45 | 46 | request.session.set_state(Authenticated) 47 | request.session.account = account 48 | 49 | return LoginOk( 50 | login_ok1=request.session.session_key.login_ok1, 51 | login_ok2=request.session.session_key.login_ok2, 52 | ) 53 | 54 | 55 | @l2_request_handler(login.constants.REQUEST_GG_AUTH, Template([]), states=[Connected]) 56 | async def gg_authenticated(request): 57 | request.session.set_state(GGAuthenticated) 58 | return GGAuth() 59 | 60 | 61 | @l2_request_handler( 62 | login.constants.REQUEST_SERVER_LIST, 63 | Template( 64 | [ 65 | Parameter(id="login_ok1", start=0, length=4, type=ctype.int32), 66 | Parameter(id="login_ok2", start=4, length=4, type=ctype.int32), 67 | ] 68 | ), 69 | states=[Authenticated], 70 | ) 71 | @verify_secrets 72 | async def server_list(request): 73 | game_servers = await GameServer.all() 74 | request.session.set_state(WaitingGameServerSelect) 75 | return ServerList(servers=game_servers) 76 | 77 | 78 | @l2_request_handler( 79 | login.constants.REQUEST_SERVER_LOGIN, 80 | Template( 81 | [ 82 | Parameter(id="login_ok1", start=0, length=4, type=ctype.int32), 83 | Parameter(id="login_ok2", start=4, length=4, type=ctype.int32), 84 | Parameter(id="server_id", start=8, length=1, type=ctype.int8), 85 | ] 86 | ), 87 | states=[WaitingGameServerSelect], 88 | ) 89 | @verify_secrets 90 | async def server_login(request): 91 | game_server = await GameServer.one( 92 | server_id=request.validated_data["server_id"], required=False 93 | ) 94 | 95 | if game_server is None: 96 | return PlayFail(reason_id=login.constants.PLAY_FAIL_ACCESS_DENIED) 97 | 98 | if game_server.is_full: 99 | return PlayFail(reason_id=login.constants.PLAY_FAIL_TOO_MANY_USERS) 100 | 101 | request.session.send_packet( 102 | PlayOk( 103 | play_ok1=request.session.session_key.play_ok1, 104 | play_ok2=request.session.session_key.play_ok2, 105 | ) 106 | ) 107 | 108 | await request.session.account.login_authenticated( 109 | game_server.server_id, 110 | request.session.session_key.play_ok1, 111 | request.session.session_key.play_ok2, 112 | request.session.session_key.login_ok1, 113 | request.session.session_key.login_ok2, 114 | ) 115 | -------------------------------------------------------------------------------- /game/game/api/macros.py: -------------------------------------------------------------------------------- 1 | import game.constants 2 | import game.states 3 | from common.api_handlers import l2_request_handler 4 | from common.ctype import ctype 5 | from common.misc import decode_str 6 | from common.template import Parameter, Template 7 | from game.models.structures.macro import Macro, MacroEntry 8 | from game.request import GameRequest 9 | 10 | 11 | @l2_request_handler( 12 | game.constants.GAME_REQUEST_MAKE_MACRO, 13 | Template( 14 | [ 15 | Parameter( 16 | id="macro_id", 17 | start=0, 18 | length=4, 19 | type=ctype.int32, 20 | ), 21 | Parameter( 22 | id="name", 23 | start="$macro_id.stop", 24 | type=str, 25 | func=decode_str(), 26 | ), 27 | Parameter( 28 | id="description", 29 | start="$name.stop", 30 | type=str, 31 | func=decode_str(), 32 | ), 33 | Parameter( 34 | id="acronym", 35 | start="$description.stop", 36 | type=str, 37 | func=decode_str(), 38 | ), 39 | Parameter( 40 | id="icon", 41 | start="$acronym.stop", 42 | length=1, 43 | type=ctype.int8, 44 | ), 45 | Parameter( 46 | id="macro_count", 47 | start="$icon.stop", 48 | length=1, 49 | type=ctype.int8, 50 | ), 51 | Parameter( 52 | id="macros", 53 | start="$macro_count.stop", 54 | type=Template( 55 | [ 56 | Parameter(id="entry_id", start=0, length=1, type=ctype.int8), 57 | Parameter(id="type", start=1, length=1, type=ctype.int8), 58 | Parameter(id="skill_id", start=2, length=4, type=ctype.int32), 59 | Parameter(id="shortcut_id", start=6, length=1, type=ctype.int8), 60 | Parameter(id="command", start=7, type=str, func=decode_str()), 61 | ], 62 | ), 63 | repeat="$macro_count", 64 | ), 65 | ] 66 | ), 67 | ) 68 | async def create_macro(request: GameRequest): 69 | 70 | character = request.session.character 71 | 72 | macro_id = 0 73 | 74 | if request.validated_data["macro_id"] != 0: # modification of existing 75 | macros = {macro.id: macro for macro in character.macros} 76 | macro_to_delete = macros[request.validated_data["macro_id"]] 77 | character.macros.remove(macro_to_delete) 78 | macro_id = macro_to_delete.id 79 | 80 | if macro_id == 0: 81 | macro_id = 1 82 | existing_ids = [macro.id for macro in character.macros] 83 | for _ in range(9999): 84 | if macro_id in existing_ids: 85 | macro_id += 1 86 | else: 87 | break 88 | 89 | macro = Macro( 90 | id=macro_id, 91 | name=request.validated_data["name"], 92 | acronym=request.validated_data["acronym"], 93 | description=request.validated_data["description"], 94 | entries=[MacroEntry(**macro) for macro in request.validated_data["macros"]], 95 | icon=request.validated_data["icon"], 96 | ) 97 | character.macros.append(macro) 98 | 99 | character.macros_revision += 1 100 | await character.commit_changes(fields=["macros"]) 101 | 102 | character.notify_macros(request.session) 103 | 104 | 105 | @l2_request_handler( 106 | game.constants.GAME_REQUEST_DELETE_MACRO, 107 | Template( 108 | [ 109 | Parameter( 110 | id="macro_id", 111 | start=0, 112 | length=4, 113 | type=ctype.int32, 114 | ), 115 | ] 116 | ), 117 | ) 118 | async def delete_macro(request): 119 | 120 | character = request.session.character 121 | 122 | for macro in character.macros.copy(): 123 | if macro.id == request.validated_data["macro_id"]: 124 | character.macros.remove(macro) 125 | 126 | await character.commit_changes(fields=["macros"]) 127 | 128 | character.macros_revision += 1 129 | character.notify_macros(request.session) 130 | -------------------------------------------------------------------------------- /game/game/api/action.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import game.constants 4 | import game.packets 5 | import game.states 6 | from common.api_handlers import l2_request_handler 7 | from common.ctype import ctype 8 | from common.response import Response 9 | from common.template import Parameter, Template 10 | from game.models.world import WORLD 11 | 12 | LOG = logging.getLogger(f"l2py.{__name__}") 13 | 14 | 15 | @l2_request_handler( 16 | game.constants.GAME_REQUEST_ACTION, 17 | Template( 18 | [ 19 | Parameter(id="object_id", start=0, length=4, type=ctype.int32), 20 | Parameter(id="orig_x", start=4, length=4, type=ctype.int32), 21 | Parameter(id="orig_y", start=8, length=4, type=ctype.int32), 22 | Parameter(id="orig_z", start=12, length=4, type=ctype.int32), 23 | Parameter(id="shift_flag", start=16, length=1, type=ctype.int8), 24 | ] 25 | ), 26 | ) 27 | async def action(request): 28 | character = request.session.character 29 | object_id = request.validated_data["object_id"] 30 | 31 | obj = WORLD.find_object_by_id(object_id) 32 | if obj is None: 33 | return game.packets.ActionFailed() 34 | 35 | await character.set_target(obj) 36 | request.session.send_packet(game.packets.MyTargetSelected(object_id=object_id, color=0)) 37 | 38 | return 39 | 40 | match request.validated_data["action_id"]: 41 | case game.constants.ACTION_TARGET: 42 | if character.target != object_id: 43 | character.set_target(obj) 44 | request.session.send_packet(game.packets.MyTargetSelected(object_id, 0)) 45 | WORLD.broadcast_target_select(character, obj) 46 | case game.constants.ACTION_TARGET_SHIFT: 47 | pass 48 | case _: 49 | LOG.info("%s is probably cheating", character.name) 50 | return game.packets.ActionFailed() 51 | 52 | 53 | @l2_request_handler( 54 | game.constants.GAME_REQUEST_TARGET_CANCEL, 55 | Template([Parameter(id="unselect", start=0, length=2, type=ctype.int16)]), 56 | ) 57 | async def target_cancel(request): 58 | await request.session.character.unset_target() 59 | request.session.send_packet( 60 | game.packets.TargetUnselected( 61 | target_id=request.session.character.id, 62 | position=request.session.character.position, 63 | ) 64 | ) 65 | 66 | # WORLD.broadcast_target_unselect(request.session.character) 67 | 68 | 69 | @l2_request_handler( 70 | game.constants.GAME_REQUEST_ACTION_USE, 71 | Template( 72 | [ 73 | Parameter(id="action_id", start=0, length=4, type=ctype.int32), 74 | Parameter(id="with_ctrl", start=4, length=4, type=ctype.int32), 75 | Parameter(id="with_shift", start=8, length=1, type=ctype.int8), 76 | ] 77 | ), 78 | ) 79 | async def action_use(request): 80 | action = request.validated_data["action_id"] 81 | character = request.session.character 82 | 83 | match action: 84 | case game.constants.ACTION_RUN: 85 | character.status.is_running = not character.status.is_running 86 | WORLD._broadcast( 87 | character, 88 | game.packets.ChangeMoveType( 89 | character_id=character.id, 90 | move_type=ctype.int32(character.status.is_running), 91 | ), 92 | ) 93 | case game.constants.ACTION_SIT: 94 | character.status.is_sitting = not character.status.is_sitting 95 | packet = game.packets.ChangeWaitType( 96 | character_id=character.id, 97 | move_type=not character.status.is_sitting, 98 | position=character.position, 99 | ) 100 | WORLD._broadcast(character, packet) 101 | case game.constants.ACTION_FAKE_DEATH_START: 102 | pass 103 | case game.constants.ACTION_FAKE_DEATH_STOP: 104 | pass 105 | case game.constants.ACTION_COMMON_CRAFT: 106 | pass 107 | 108 | 109 | @l2_request_handler( 110 | game.constants.GAME_REQUEST_SOCIAL_ACTION, 111 | Template( 112 | [ 113 | Parameter(id="action_id", start=0, length=4, type=ctype.int32), 114 | ] 115 | ), 116 | ) 117 | async def social_action(request): 118 | await request.session.character.use_social_action(request.validated_data["action_id"]) 119 | --------------------------------------------------------------------------------