diff --git a/share/doc/protocol/login.md b/share/doc/protocol/login.md
new file mode 100644
index 00000000..29740718
--- /dev/null
+++ b/share/doc/protocol/login.md
@@ -0,0 +1,370 @@
+# Login protocol
+
+## Upstream
+
+| Opcode | Length | Jagex name | Description |
+|-------:|---------------:|-----------------------------|------------------------------------|
+| 14 | 1 | `INIT_GAME_CONNECTION` | Set username hash |
+| 15 | 4 | `INIT_JS5REMOTE_CONNECTION` | Switch to JS5 mode |
+| 16 | Variable short | `GAMELOGIN` | Login (new session) |
+| 17 | 0 | Unknown | Switch to JAGGRAB mode |
+| 18 | Variable short | Unknown | Reconnect (existing session) |
+| 20 | 6 | Unknown | Check date of birth and country |
+| 21 | 8 | `CREATE_CHECK_NAME` | Check username availability |
+| 22 | Variable byte | `CREATE_ACCOUNT` | Create account |
+| 23 | 4 | `REQUEST_WORLDLIST` | Request world list |
+| 24 | Variable byte | `CHECK_WORLD_SUITABILITY` | Request most suitable world number |
+
+### 14 (`INIT_GAME_CONNECTION`)
+
+| Data type | Description |
+|--------------|---------------|
+| UnsignedByte | Username hash |
+
+The following algorithm computes the username hash:
+
+ usernameHash = (encodedUsername >> 16) & 0x1F
+
+where `encodedUsername` is the player's Base37-encoded username.
+
+The consensus in the community is that Jagex's implementation uses the username
+hash to load balance between login servers, but this has not been confirmed.
+
+### 15 (`INIT_JS5REMOTE_CONNECTION`)
+
+| Data type | Description |
+|-----------|---------------------|
+| Int | Client build number |
+
+### 16 (`GAMELOGIN`)
+
+| Data type | Description |
+|---------------|----------------------------------------------|
+| Int | Client build number |
+| Byte | Unknown (hard-coded to `0` in client script) |
+| Boolean | Advert suppressed |
+| Boolean | Client signed |
+| UnsignedByte | Display mode |
+| UnsignedShort | Canvas width |
+| UnsignedShort | Canvas height |
+| UnsignedByte | Anti-aliasing mode |
+| Byte\[24\] | UID |
+| String | Settings cookie |
+| Int | Affiliate ID |
+| Int | Preferences |
+| Short | TODO |
+| Int\[29\] | JS5 archive checksums |
+| UnsignedByte | RSA-encrypted payload length (n) |
+| Byte\[n\] | RSA-encrypted payload |
+
+The unknown byte hard-coded to `0` in a client script might represent the
+language. It is consistent with the ID for English. We can infer that there
+were language-specific versions of the cache, as the surviving copy does not
+contain translations.
+
+The structure of the plaintext payload is described below:
+
+| Data type | Description |
+|--------------|--------------------------------|
+| UnsignedByte | Must be `10` |
+| Int | ISAAC cipher key (bits 0-31) |
+| Int | ISAAC cipher key (bits 32-63) |
+| Int | ISAAC cipher key (bits 64-95) |
+| Int | ISAAC cipher key (bits 96-127) |
+| Long | Base37-encoded username |
+| String | Password |
+
+### 17 (Switch to JAGGRAB mode)
+
+### 18 (Reconnect)
+
+The packet is identical to `GAMELOGIN` in all but one way: the opcode of this
+packet indicates the client is reconnecting due to connection loss, rather than
+logging in from the login screen.
+
+### 20 (Check date of birth and country)
+
+| Data type | Description |
+|---------------|------------------------------|
+| UnsignedByte | Day |
+| UnsignedByte | Month |
+| UnsignedShort | Year |
+| UnsignedShort | Country ID |
+
+### 21 (`CREATE_CHECK_NAME`)
+
+| Data type | Description |
+|-----------|------------------------------|
+| Long | Base37-encoded username |
+
+### 22 (`CREATE_ACCOUNT`)
+
+| Data type | Description |
+|-----------------|-------------------------------------------------------|
+| UnsignedShort | Client build number |
+| UnsignedByte | RSA-encrypted payload length (n) |
+| Byte\[n\] | RSA-encrypted payload |
+| Byte\[len-n-3\] | XTEA-encrypted payload |
+
+The structure of the RSA-decrypted payload is described below:
+
+| Data type | Description |
+|---------------|--------------------------------|
+| UnsignedByte | Must be `10` |
+| UnsignedShort | Flags (see below) |
+| Long | Base37-encoded username |
+| Int | XTEA key (bits 0-31) |
+| String | Password |
+| Int | XTEA key (bits 32-63) |
+| UnsignedShort | Affiliate ID |
+| UnsignedByte | Day |
+| UnsignedByte | Month |
+| Int | XTEA key (bits 64-95) |
+| UnsignedShort | Year |
+| UnsignedShort | Country ID |
+| Int | XTEA key (bits 96-127) |
+
+| Flag | Description |
+|------:|--------------------------------------|
+| `0x1` | Receive RuneScape newsletters |
+| `0x2` | Receive Other newsletters |
+| `0x4` | Share details with business partners |
+
+The structure of the XTEA-decrypted payload is described below:
+
+| Data type | Description |
+|-------------|---------------|
+| String | Email address |
+| Byte\[0-7\] | Padding |
+
+### 23 (`REQUEST_WORLDLIST`)
+
+| Data type | Description |
+|-----------|------------------------------|
+| Int | Previous world list checksum |
+
+The previous world list checksum is set to 0 if the client has not fetched the
+world list before. It is used to save bandwidth if the world list has not
+changed when the "Refresh" button is clicked: if checksum has not changed, the
+server only sends the player counts and not the full world list.
+
+Given the use of CRC-32 elsewhere in the client, it is probably the CRC-32
+checksum of the encoded world list (excluding player counts), but this has not
+been confirmed.
+
+### 24 (`CHECK_WORLD_SUITABILITY`)
+
+| Data type | Description |
+|---------------|-------------------------------------------|
+| UnsignedShort | Client build number |
+| UnsignedByte | RSA-encrypted payload length (n) |
+| Byte\[n\] | RSA-encrypted payload |
+
+The structure of the plaintext payload is described below:
+
+| Data type | Description |
+|--------------|-------------------------|
+| UnsignedByte | Must be `10` |
+| Int | Random integer |
+| Long | Base37-encoded username |
+| Int | Random integer |
+| String | Password |
+| Int | Random integer |
+
+## Downstream
+
+| Opcode | Length | Jagex name | Description |
+|-------:|-------:|----------------------------------|----------------------------------------|
+| 0 | 8 | Unknown | Exchange session key |
+| 1 | 0 | Unknown | Display video advertisement |
+| 2 | 14 | `OK` | Login successful |
+| 3 | 0 | `INVALID_USERNAME_OR_PASSWORD` | Invalid username or password |
+| 4 | 0 | `BANNED` | Account banned |
+| 5 | 0 | `DUPLICATE` | Already logged in |
+| 6 | 0 | `CLIENT_OUT_OF_DATE` | Client out of date |
+| 7 | 0 | `SERVER_FULL` | Server full |
+| 8 | 0 | `LOGINSERVER_OFFLINE` | Login server offline |
+| 9 | 0 | `IP_LIMIT` | Too many connections from IP address |
+| 10 | 0 | Unknown | Bad session ID |
+| 11 | 0 | `FORCE_PASSWORD_CHANGE` | Password is weak |
+| 12 | 0 | `NEED_MEMBERS_ACCOUNT` | World is members-only |
+| 13 | 0 | `INVALID_SAVE` | Could not complete login |
+| 14 | 0 | `UPDATE_IN_PROGRESS` | Update in progress |
+| 15 | 0 | `RECONNECT_OK` | Reconnect successful |
+| 16 | 0 | `TOO_MANY_ATTEMPTS` | Too many login attemts from IP address |
+| 17 | 0 | Unknown | Account in members-only area |
+| 18 | 0 | `LOCKED` | Account locked |
+| 19 | 0 | Unknown | Fullscreen is members-only |
+| 20 | 0 | Unknown | Invalid login server requested |
+| 21 | 1 | `HOP_BLOCKED` | Wait for profile transfer |
+| 22 | 0 | `INVALID_LOGIN_PACKET` | Malformed login packet |
+| 23 | 0 | Unknown | No reply from login server |
+| 24 | 0 | `LOGINSERVER_LOAD_ERROR` | Error loading profile |
+| 25 | 0 | `UNKNOWN_REPLY_FROM_LOGINSERVER` | Unknown reply from login server |
+| 26 | 0 | `IP_BLOCKED` | IP address banned |
+| 27 | 0 | Unknown | Service unavailable |
+| 29 | 1 | `DISALLOWED_BY_SCRIPT` | Disallowed by script |
+| 30 | 0 | Unknown | Client is members-only |
+| 101 | 2 | Unknown | Switch world |
+
+### 0 (Exchange session key)
+
+| Data type | Description |
+|-----------|--------------------------------|
+| Long | ISAAC cipher key (bits 64-127) |
+
+### 1 (Display video advertisement)
+
+After the client has finished displaying the advertisement, it sends an empty
+packet with opcode 17. The opcode is encrypted with ISAAC.
+
+### 2 (`OK`)
+
+| Data type | Description |
+|---------------|--------------------------------|
+| UnsignedByte | Staff moderator level |
+| UnsignedByte | Player moderator level |
+| Boolean | Player is underage |
+| Boolean | Parental chat consent |
+| Boolean | Parental advertisement consent |
+| Boolean | Quick chat world |
+| Boolean | Record mouse movement |
+| UnsignedShort | Player index |
+| Boolean | Player is a member |
+| Boolean | Members-only world |
+
+### 3 (`INVALID_USERNAME_OR_PASSWORD`)
+
+**Message:** Invalid username or password. If you have forgotten your password,
+click here.
+
+### 4 (`BANNED`)
+
+**Message:** Your account has been disabled. Please click here to check
+your Message Centre for details.
+
+### 5 (`DUPLICATE`)
+
+**Message:** Your account has not logged out from its last session. Try again
+in a few minutes.
+
+### 6 (`CLIENT_OUT_OF_DATE`)
+
+**Message:** RuneScape has been updated! Please reload this page.
+
+### 7 (`SERVER_FULL`)
+
+**Message:** This world is full. Please use a different world.
+
+### 8 (`LOGINSERVER_OFFLINE`)
+
+**Message:** Unable to connect: login server offline.
+
+### 9 (`IP_LIMIT`)
+
+**Message:** Login limit exceeded: too many connections from your address.
+
+### 10 (Bad session ID)
+
+**Message:** Unable to connect: bad session ID.
+
+### 11 (`FORCE_PASSWORD_CHANGE`)
+
+**Message:** Your password is an extremely common choice, and is very weak. You
+must change it before you can login. Click here
+
+### 12 (`NEED_MEMBERS_ACCOUNT`)
+
+**Message:** You need a members' account to log in to this world. Please
+subscribe or use a different world.
+
+### 13 (`INVALID_SAVE`)
+
+**Message:** Could not complete login. Please try using a different world.
+
+### 14 (`UPDATE_IN_PROGRESS`)
+
+**Message:** The server is being updated. Please wait a few minutes and try again.
+
+### 15 (`RECONNECT_OK`)
+
+### 16 (`TOO_MANY_ATTEMPTS`)
+
+**Message:** Too many incorrect logins from your address. Please wait 5 minutes before
+trying again.
+
+### 17 (Account in members-only area)
+
+**Message:** You are standing in a members-only area. To play on this world, move to a free
+area first.
+
+### 18 (`LOCKED`)
+
+**Message:** Your account has been locked as we suspect it has been stolen. Click here to
+recover your account.
+
+### 19 (Fullscreen is members-only)
+
+**Message:** Fullscreen is currently a members-only feature. To log in, either return to the
+main menu and exit fullscreen or use a members' account.
+
+### 20 (Invalid login server requested)
+
+**Message:** Invalid loginserver requested. Please try using a different world.
+
+### 21 (`HOP_BLOCKED`)
+
+| Data type | Description |
+|--------------|-------------------------|
+| UnsignedByte | Hop time |
+
+**Message:** You have only just left another world. Your profile will be transferred in
+seconds.
+
+### 22 (`INVALID_LOGIN_PACKET`)
+
+**Message:** Malformed login packet. Please try again.
+
+### 23 (No reply from login server)
+
+**Message:** No reply from login server. Please wait a minute and try again.
+
+### 24 (`LOGINSERVER_LOAD_ERROR`)
+
+**Message:** Error loading your profile. Please contact Customer Support.
+
+### 25 (`UNKNOWN_REPLY_FROM_LOGINSERVER`)
+
+**Message:** Unexpected loginserver response. Please try using a different world.
+
+### 26 (`IP_BLOCKED`)
+
+**Message:** This comptuer's address has been blocked as it was used to break our rules.
+
+### 27 (Service unavailable)
+
+Service unavailable.
+
+### 29 (`DISALLOWED_BY_SCRIPT`)
+
+| Data type | Description |
+|--------------|-------------------------|
+| UnsignedByte | Reason |
+
+| Reason | Message |
+|-------:|---------------------------------------------------------------------------------------|
+| 0 | You must have a Combat Level of at least 20 (without Summoning) to enter a PvP world. |
+| 1 | You are currently carrying lent items and cannot enter a PvP world. |
+| 2 | You must be standing in the Wilderness or Edgeville to enter this Bounty world. |
+| Other | Unexpected server response. Please try using a different world. |
+
+### 30 (Client is members-only)
+
+This is not a member's account; please choose the 'Free Users' option from the
+website to play on this account.
+
+### 101 (Switch world)
+
+| Data type | Description |
+|---------------|-------------------------|
+| UnsignedShort | World number |