Friends, parties and guilds in 1.4.4
This page describes how the 1.4.4 client handles the Social panel: friends, blocked players, parties and party invites, guilds and Slayer Links. It records what the client sends, what it needs back, and why the first test with two players on 22 September 2026 showed nothing. It also lists the fixes we built in the metagame.
Status (22 September 2026): built, tested without the game and running on our rented server (60955e1), not yet tried by two players. Every fix below passes HTTP tests that replay the client’s own requests and check each reply against a model of the client’s parsing. The next two-player test on the rented server will confirm or correct them; How to verify lists its steps and the log lines to expect. Text chat is built too, and has a page of its own: Text chat.
Update (23 September 2026), from the port of Harmonic’s 1.4.4 fork (The Harmonic port), built and tested without the game, not deployed yet: Slayer Links (on by default), friends’ online status inside the chat server (off by default, Text chat), a party accept that arrives twice now succeeds, and the client’s session check names the player’s own account.
Contents
Evidence and confidence
Each fact is marked with where it comes from and how sure we are.
| Label | Source |
|---|---|
| B | The 1.4.4 executable (Dauntless-Win64-Shipping.exe, 103 MB): its strings (ASCII and UTF-16), and disassembly of the functions that build requests and parse replies. Addresses are virtual addresses with image base 0x140000000. |
| L | The live server: a census of every route the real clients and game servers called on 22 September 2026, with counts and unanswered routes, and what the two players reported. |
| R | Requests the 2.1.1 client sent to our research server (the same Phoenix services, recorded with their bodies). |
| K | Reflected class and struct names (the SDK dump of the game’s classes), and the endpoint table in UndauntedInternalServer/dllmain.cpp. |
| C | Our own code and tests. |
| G | A design decision of ours, where nothing in the client decides it. |
Confidence: H high (read in the code, or seen live), M medium (one link not traced), L low.
The method for reading JSON field names out of the executable is on Reading the JSON contract from the binary.
What the players saw, and why
| What the players saw | Cause | Fix | Conf |
|---|---|---|---|
| A party invite reached the other player’s client (the invite poll answered it twice) but never appeared under PARTY INVITES. | Before the panel shows an invite, the client asks POST /accountinfo/public about the sender. Our reply, inherited from upstream, described the caller instead: the caller’s id in accountId and in linkedAccounts. The client files user info under the reply’s accountId and keeps the first reply per id, so the sender never got any user info, was invalidated, and the invite was dropped (B 0x140b74870, 0x1409ede50). | /accountinfo/public answers for the asked account. | H in the code, M that nothing else stands in the way |
| Add Friends “found nothing”. | Add Friends is not a search. It is a name box and an Add button. The name was found (GET /account/api/public/account/displayName/<name>), but the next step, POST /account/mapping, first got 404 and then a reply shape the client does not read. With no mapping, the client drops the friend request without a message. No friend request ever reached the server (0 calls, L). | /account/mapping answers in the shape the client parses. | H |
| Everyone, the players themselves included, showed as Offline. | Online status comes only from XMPP presence, pushed over the chat connection. There is no HTTP presence route, and our chat server sent no presence outside chat rooms. | Built on 23 September 2026, on by default since 30 September: friends’ presence in the chat server (CHAT=1 and CHAT_PRESENCE=1, Text chat), waiting for the two-player test that the party’s automatic kick stays asleep. | H for the cause, M for the fix |
| “Unable to create guild.” | POST /guild/validate (from the client) and POST /guild (from the game server) both got 404. | The eleven v2 guild routes. | H |
| Nobody used the in-game party Invite (both invites of the night came from the host’s fallback). | The players could not find each other: Hunt Members and friends need the same user-info step as an invite’s sender. | Same fix as the first row. | M |
After the fixes are deployed, both players must restart the game once. The client keeps the first user info and the first mapping per id for the whole session, so wrong entries from before stay until it restarts.
A correction. Our earlier notes, and roadmap item 1.11, blamed /account/mapping for both the friend search and the party invite. That is only half right. Add Friends did stop at /account/mapping (and would next have stopped at /accountinfo/public). The received party invite never uses the mapping: its sender id is already a Phoenix id, and it was dropped at /accountinfo/public. The mapping calls seen around the invite that night fit other callers: the two Add Friends attempts and the client’s own mapping of the local player at login. That login call rests on the executable’s log string and the 2.1.1 capture (B, R); the 1.4.4 census shows only 6 mapping calls for 8 logins and 2 Add Friends attempts (L), so it does not happen at every login. The live test counts them (see How to verify).
One id, two steps: the identity chain
Every entry in every Social list (friends, friend requests, blocked players, party members, Hunt Members, party invites, guild members, guild inviters) is a “social user” keyed by its Phoenix account id (B 0x1415af620). It only appears once two lookups have succeeded:
Epic id (friends list, block list, Add Friends, /invite <name>, the guild add-member box)
| POST /account/mapping {"srcAccountType": "epic", "ids": [<epic id>]} -> Phoenix id
v
Phoenix id (party invite sender, party members, Hunt Members, guild members and inviters)
| POST /accountinfo/public {"accountId": <phoenix id>} -> user info
| the reply's accountId must be the asked id (cache key, first reply wins)
| linkedAccounts [{accountType: "epic", accountId: X}] sets the user's Epic id to X
v
the social toolkit checks that the user's Epic id is the Epic id the action started from
v
the entry is shown, or the queued action runs (send the friend request, show the invite ...)
When the mapping yields nothing, the action is dropped quietly (B, log string “Mapping primary Id for unknown, unmapped external Id [%s] for user action”). When the user info yields nothing, the user is invalidated (B, “SocialToolkit - HandleUserInvalidated called for [%s]”).
On this server every account has exactly one id, UID-<uuid>. It is at once the Epic account id (the login answers it as account_id), the Phoenix account id (party and guild member ids) and the chat id. So every mapping is the identity. In the original service the Epic id was a separate 32-character id (R: the 2.1.1 client mapped such an id at login), and the client still walks both steps, so both must answer correctly even though the ids are equal.
POST /account/mapping
The client’s QueryAccountMappingsEndpoint (K dllmain.cpp), in Phoenix’s own user service.
- Request (B, R, L):
{"srcAccountType": "epic", "ids": ["<id>"]}, JSON with the player’s bearer token, at most 100 ids per request. The 2.1.1 client sent exactly this at login (R), and so did the live 1.4.4 clients (L), though not at every login (6 calls in 8 logins, 2 of them Add Friends). - Reply the parser reads (B
0x140b09f60..0x140b0afd4): a root keyaccountMappingsholding an object keyed by each asked id. Each value is an object with non-empty stringsaccountIdandaccountType;accountTypeis compared without case withepicandphoenix. Nothing else in an entry is read. The HTTP status is not checked; an empty body fails (“Empty response payload”) and so does invalid JSON (“Invalid response payload”). Extra root keys are ignored. - Our reply:
{"accountMappings": {"<id>": {"accountId": "<id>", "accountType": "phoenix"}}, "code": "OK", "message": "", "payload": {"accountMappings": {...}}}. The wrapped copy underpayloadcosts nothing and covers a reader that expects the Phoenix envelope. ForsrcAccountTypephoenixthe entries sayepic. Unknown ids are left out; without a valid token the map is empty. - Why the two earlier replies failed: the first was an object keyed by id at the root, with no
accountMappingskey, so the parser found nothing. The second sentaccountMappingsas an array, which the parser reads as an empty object. Either way no mapping was cached. Our client model reproduces both failures (see Testing without the game). - Confidence H for the shape (the parser was traced end to end); not yet seen working in game.
POST /accountinfo/public
The client’s PublicAccountInfoEndpoint, one call per player to show (73 calls live, all answered, L). This is the step every other player in the Social panel depends on.
- Request (B, R):
{"accountId": "<id>"}, or from a second request builder{"displayname": "<name>"}. -
Reply (B
0x140b69ac0, a plain object, no envelope, like the other account services on Backend contract):{ "accountId": "UID-B", "username": "Bravo", "linkedAccounts": [ { "accountId": "UID-B", "accountType": "epic" } ], "isSubscribed": true, "language": null } accountIdmust be the looked-up account: it is the key the client files the user info under, and the first reply per key wins (B0x140b74870, the early return at0x140b748f9).- The
epicentry oflinkedAccountsbecomes the user’s Epic id (B0x140b7499f), which the toolkit compares with the Epic id the action started from. - An unknown id or name answers 404
{}: the client counts a failed lookup and caches nothing (B, “Batch Succeeded, Total = %d, FailureCount = %d”). With the old reply it cached nothing useful either. isSubscribedandlanguageare not read by the client; they stay because they were harmless live.- Why upstream’s reply failed: it put the caller’s own id in
accountIdandlinkedAccounts. At login the client looks up its own id first, so every later lookup of another player landed on the caller’s existing entry and was ignored. The other player never got user info. - Confidence H for the shape and the cache keying; M that this alone makes invite senders, Hunt Members and friends appear.
Lookups by name (unchanged)
| Route | Used by | Conf |
|---|---|---|
GET /account/api/public/account/displayName/:name → {id, displayName, externalAuths: {}} or 404 | Add Friends, the chat’s /invite <name>, the guild add-member box (B, L: 2 calls) | M |
GET /account/api/public/account?accountId=A&accountId=B → an array of the same | The Epic-side name of a social user (B) | M |
A typed name that contains @ goes to an email lookup, which stays unanswered: our usernames cannot contain @, and the 404 gives the normal “not found” message.
Friends
The Epic friends service ([OnlineSubsystemMcp.OnlineFriendsMcp], B), with our player token as the bearer. Friendships and blocks are stored in SQLite (built for roadmap 1.9). The routes are on HTTP API.
| Call | When | Request and reply | Evidence | Conf |
|---|---|---|---|---|
GET /friends/api/public/friends/:id?includePending=true | once per login | A bare array of {accountId, status: ACCEPTED or PENDING, direction: INBOUND or OUTBOUND, created} (the client wraps it as {"friends": ...} itself) | B, L: 8 logins, 8 calls | H |
GET /friends/api/public/blocklist/:id | once per login | {"blockedUsers": [...]} | B, L | H |
POST /friends/api/public/friends/:me/:them | Add Friends, the Add Friend menu item, Accept | no body (Accept sends an empty JSON body); any 2xx | B | H |
DELETE /friends/api/public/friends/:me/:them | Decline, Remove Friend | any 2xx | B | H |
POST (and now also PUT) /friends/api/public/blocklist/:me/:them | Block, after its dialog | any 2xx | B, the verb inferred | M |
DELETE /friends/api/public/blocklist/:me/:them | Unblock | any 2xx | B | H |
The client reads both lists only at login (L). A friend request or an accept therefore shows for the other player at their next login. With friends’ online status on (CHAT=1 and CHAT_PRESENCE=1), an accept is also pushed at once over the chat connection (Text chat); a new request is not.
What the player should see (M unless marked):
| Step | Calls | Result |
|---|---|---|
| Log in | friends list, block list; per listed id: mapping, user info | Accepted friends under OFFLINE (never under EPIC FRIENDS without presence); a pending request someone sent, with Accept and Decline; blocked players under BLOCKED; requests you sent are not listed |
| Add Friends: type a name, press Add | name lookup (404: the “unknown player” message; your own name: the “you cannot invite yourself” message), mapping, user info, POST .../friends/<me>/<them> | a “friend invite sent” message; nothing new in the lists |
| The other player logs in again | the login calls | the request, with Accept and Decline (where exactly it is listed is still to be seen) |
| Accept | POST .../friends/<me>/<them> | the friend shows under OFFLINE after the next login |
| Decline, Remove Friend | DELETE .../friends/<me>/<them> | the entry goes (H) |
| Block, Unblock | POST/DELETE .../blocklist/<me>/<them> | the entry moves to or from BLOCKED |
Limits we added (G): at most 50 unanswered requests sent per account, and at most 20 new requests per 10 minutes (409, which the client shows as a failure). Accepting a request is never limited. The existing limits stay: 200 friendships and 200 blocks per account. A block also removes the party and guild invites pending between the two players (G), and the invite lists leave out any invite between players who blocked each other.
Parties
The party service was already built (roadmap 1.9) and matched the client’s requests and replies. The live census shows it working: 399 party polls and 399 invite polls, and the invite delivered to the other player’s poll (L). The only server change parties needed is the /accountinfo/public fix; since 23 September 2026 a repeated accept is also tolerated.
| Call | When | Evidence | Conf |
|---|---|---|---|
POST /party {buildId, featureOverrides: []} | the poll, about every 10 seconds, and at login | B 0x140b57600, L: 399 calls | H |
GET /party/invites → {"invitations": [{recipientPlayerId, sendingPlayerId, partyId, sendingPlatform, sendingDisplayName}]} | the invite poll | B 0x140b675d0, L: 399 calls | H |
PUT /party/invite {recipientPlayerId, partyId, buildId, featureOverrides} | Invite to Party; the chat’s /invite <name> | B | H |
PUT /party/invite/accept/:partyId | Accept; the id is the party’s, not an invite id (B 0x140b35384) | B | H |
DELETE /party/invite | Decline | B | H |
DELETE /party/member, DELETE /party/member/:id, PUT /party/member/promote/:id | leave (also at every login), kick, promote | B, L: 12 leaves in 8 logins | H |
POST /candidate/join from the leader | the leader picks a hunt; the members follow through their party poll | C, tests | M |
What the player should see once the fix is deployed: an invite appears under PARTY INVITES within about 10 seconds (the next invite poll), after the client has looked up the sender. Accepting shows both names in both party panels. When the leader picks a hunt, the whole party lands on one hunt server. Parties live in memory: a metagame restart leaves everyone in a party of one, which the client treats as normal.
Two things we deliberately left alone: invites the caller sent are not listed in GET /party/invites (the client might misread them as received), and a party of one keeps the old placeholder values, which were harmless live for queueing hunts.
One risk for the in-game invite: the placeholder may read as “matchmaking”. Before it sends PUT /party/invite, the client refuses when the inviter is the invitee, is not the leader, or has a party that is not idle (B 0x1415b2280; the last check calls 0x1415a98c0 at 0x1415b27aa and logs “Player %s tried to send an invite to player %s, but party %s was matchmaking”). That state is read from the party’s candidate, and the placeholder says QUEUED_FOR_START with a candidate id. Solo players queued hunts without trouble with it (L: 27 queued joins), and one link of the state reading was not traced, so whether it greys out Invite to Party is open (M). Nobody used the in-game invite live (L: 0 PUT /party/invite), so nothing contradicts it either. If it does, PARTY_SOLO_STUB=0 answers a party of one with no candidate (candidateState: null, which reads as idle) instead; see Configuration.
Limits we added (G): a player sends at most 20 invites in 10 minutes, and after a player declines someone’s invite, that sender cannot invite them again for 2 minutes (both 409, a failure to the client). A block removes the pending invites between the two.
A repeated accept
An accept can arrive twice: the client retries a request whose answer was lost, and Harmonic’s fork tolerated that (G for how often it happens in 1.4.4). Since 23 September 2026, a PUT /party/invite/accept/<id> that matches no live invite still answers 200 with the caller’s party (the same body as the party poll) when the caller is already in a party of two or more and the id is that party’s id, or the id of another member of it (for example the invite’s sender). The log line is party: accept by <A> id=<id>: already in P=<party> size=<n>; answering that party (a repeated accept). A party of one, a party the caller has left, the caller’s own id and a stranger’s id still get 404, as before. No switch: it only answers where the reply used to be 404.
The automatic kick
The client’s automatic kick of “offline” party members never runs without presence (B 0x1415f6f60, a 10-second threshold). The chat server keeps it that way: by default it sends no presence outside chat rooms at all, and with friends’ online status switched on (CHAT_PRESENCE=1) it still never sends a player a presence from their own account, not even from their second session (Text chat).
Guilds
The contract
- Endpoints (K
dllmain.cpp, the cookedDefaultGame.ini): eleven*_v2keys. The olderv1keys are compiled in but never called, so we built only v2. - Every reply is read through the Phoenix envelope
{"code": string, "message": string, "payload": object}(B0x140b12170). Success is a 2xx status and a parseable JSON body (B0x140aae300), so a 204 is a failure.GET /guild’s 204 for “not in a guild” relies on exactly that: the client then clears its guild (B0x1415c2c90; L: 34 calls answered 204). - On an error status the client still reads
codeand maps it to its guild error (B0x140ade3f0, KEGuildRequestError). An unknown or empty code shows as “Unable to create guild.” - Payload fields are strings, except
maximum_guild_members(a whole number). Ranks areMember,OfficerandLeader, compared without case; the client puts the rank in the URL in lower case. - Creation goes through the game server. The Create button sends the RPC
ServerCreateGuildto the Ramsgate game server (B, K), which sendsPOST /guildwith its key (L: one such call, answered 404). The RPC’s validation returns true without checking any item, so creating a guild costs nothing in 1.4.4. - There is no message of the day, banner, hall, perk or guild XP in v2. Guild chat is the XMPP room
Guild-<guildId>(built, for the guild’s members only: Text chat). The[TAG]over players’ heads works by itself onceGET /guildreturns a nameplate.
We send every reply wrapped and also copy the payload’s fields to the root (G). The envelope reader ignores extra root keys, so the copies cost nothing and cover a flat reader in case our reading of the code is wrong.
Error codes
The server sends the code; the client shows its own text (from the create-guild widget).
code | Client error | Status we send | Text the client shows |
|---|---|---|---|
SlyAdorableQuillshot | InvalidPermission | 403 | |
ExcludedAdorableQuillshot | NotInAGuild | 404 | |
ObedientAdorableQuillshot | GuildNameInvalidLength | 400 | Name is invalid. Must contain 4-15 english letters and digits. |
SeizedAdorableQuillshot | GuildNameTaken | 409 | Guild name already in use. |
NastyAdorableQuillshot | GuildNameProfane | 400 | Guild name contains profanity. |
NumberedAdorableQuillshot | GuildNameTooManyNumbers | 400 | Guild name must have 6 numbers or less. |
LetteredAdorableQuillshot | GuildNameTooManyLetters | 400 | Guild name must not have more than 6 of the same letter in a row. |
DutifulAdorableQuillshot | GuildNameplateInvalidLength | 400 | Nameplate is invalid. Must contain 2-6 english letters and digits. |
CapturedAdorableQuillshot | GuildNameplateTaken | 409 | Guild nameplate already in use. |
DirtyAdorableQuillshot | GuildNameplateProfane | 400 | Guild nameplate contains profanity. |
OccupiedAdorableQuillshot | YouAlreadyInAGuild | 409 | Unable to create guild. You are already in a guild. |
ClonedAdorableQuillshot | TargetAlreadyInYourGuild | 409 | |
RedundantAdorableQuillshot | TargetAlreadyHasGuildInvite | 409 | |
StuffedAdorableQuillshot | GuildIsFull | 409 | |
UninvitedAdorableQuillshot | GuildInviteNotFound | 404 | |
ChiefAdorableQuillshot | GuildLeaderCannotLeaveGuild | 409 | |
DocileAdorableQuillshot | InvalidGuildRank | 400 | |
"" (empty) | Unknown | as fits | Unable to create guild. |
The seventeen code strings sit together in the executable (B, file offsets 0x447b950 to 0x447bca0), in the same order as the error enum in the SDK (K). The pairing comes from the mapper (B, H). The name rules come from the texts above and from the client’s own length limits (4 and 15 for names, 2 and 6 for nameplates, B). The executable also holds older texts with other limits (“4-32” and “2-7”); they belong to the unused v1 code.
Name and nameplate rules
Checked in this order; the first failure decides.
- The caller (for a create, the leader) is not already in a guild:
Occupied. - Name: 4 to 15 English letters and digits, nothing else (a space is invalid):
Obedient. - Name: at most 6 digits:
Numbered. - Name: at most 6 of the same letter in a row, regardless of case:
Lettered. - Name: no word from the deny list (G):
Nasty. - Name: not taken, regardless of case (G):
Seized. - Nameplate: empty, or 2 to 6 English letters and digits:
Dutiful. An empty nameplate is allowed (the client skips its own check when it is empty, B), and several guilds may have none. - Nameplate: no word from the deny list:
Dirty. - Nameplate: not taken, regardless of case:
Captured.
The client never calls the profanity service (the cooked config turns it off), so the server keeps a short built-in deny list; GUILD_NAME_DENYLIST adds words (see Configuration). A few short offensive words are refused as the whole name or nameplate, with the same codes. Reserved words (G) keep a guild from posing as the server’s staff or the project: admin, moderator, official, staff and a few more anywhere, and nameplates such as GM, DEV and MOD. They answer “already in use” (Seized or Captured); GUILD_RESERVED_NAMES=0 allows them. The full lists are on HTTP API.
The routes
| Route (endpoint key) | Caller | What it does | Conf |
|---|---|---|---|
GET /guild (GuildEndpoint_v2) | the client, at login, at each world load and after every guild action (L: 34 calls in 8 logins) | the caller’s guild, or 204 | H |
GET /guild/invite/player (GuildViewInvitesEndpoint_v2) | the same moments | the caller’s open invites: {id, guild_id, guild_name, inviter_account_id} | H |
POST /guild/validate (GuildCreateValidateEndpoint_v2) | the client, while typing in CREATE A GUILD (L: 2 calls) | {leader_account_id, name, nameplate}: checks the rules | H |
POST /guild (GuildEndpoint_v2) | the game server, after ServerCreateGuild (L: 1 call) | the create; answers the new guild, which the client applies straight away | H |
DELETE /guild/:guildId (GuildDisbandEndpoint_v2) | DISBAND GUILD, leader only | removes the guild | H |
PUT /guild/invite/:accountId (GuildInviteEndpoint_v2) | the add-member box, “Invite to Guild” in any player’s menu | invites; Leader or Officer | H |
POST /guild/invite/accept/:guild_invite_id | Accept Guild Invite | joins as a Member | H |
DELETE /guild/invite/:guild_invite_id | Decline Guild Invite | removes the invite | H |
DELETE /guild/player (GuildLeaveEndpoint_v2) | Leave Guild | leaves; not the leader | H |
DELETE /guild/player/:accountId (GuildKickEndpoint_v2) | Kick From Guild, leader only | removes a member | H |
PUT /guild/rank/:accountId/:rank (GuildChangeRankEndpoint_v2) | Promote To Guild Officer (officer), Demote To Guild Member (member), Promote To Guild Leader (leader) | changes a rank; leader hands the guild over | H (the route), M (the old leader becoming an Officer is our choice) |
The guild object in the replies is {id, name, nameplate, leader_account_id, members: [{phx_account_id, rank}], maximum_guild_members} (B 0x140b13de0, K FGuildData). The exact checks and codes of each route are on HTTP API.
Creating a guild through the game server
The Create button sends the RPC ServerCreateGuild(LeaderPlayerId, name, nameplate) to the Ramsgate game server (K Archon_parameters.hpp), whose validation returns true, and the game server sends POST /guild with leader_account_id set to that id. The game server sends no token of the player. CreateGuild (B 0x140ac7270) takes its token at 0x140ac78a4 from 0x140b461d0, which asks the subsystem’s identity interface for the token of the subsystem’s own local user (Subsystem+0x2c0); the request gets an Authorization header only when that token is not empty (0x140b3b561). Game servers never log in to Phoenix (L: every POST /login came from a client), so normally the create carries no token, and if one ever did, it would be the game server’s own, the same for every player on it (H). So the leader id is only what some client claimed, and the metagame ties the create to the leader’s own action instead (G):
POST /guildaccepts only the game-server key, only from this machine. A player’s token alone is refused.- A bearer token that comes along is only logged (“the game server’s token names …”, and the created line ends “a token of X came along” or “no token”); a bad one is ignored instead of failing.
- The leader must have validated this very name and nameplate with their own token (
POST /guild/validate, which the create window sends while they type) in the last 15 minutes. The last five validated pairs per player count, regardless of case, in case Create is pressed before the last check has come back. Otherwise the create is refused with an empty code, which the client shows as “Unable to create guild.” (the message in our body never reaches the screen: for an error status the client builds its message from the HTTP status, B0x140aae447), and logged as “no validate of this name and nameplate by the leader in the last 15 minutes”. A name the rules refuse anyway gets that rule’s own text. So a modified client cannot make another player the leader of a guild that player never named, and being online is not enough. GUILD_CREATE_ACTIVITY_FALLBACK=1also accepts a leader who validated another name or was heard from in the last minute, with a warning in the log. It exists only for the case where the live test shows the client never validates the final name.- At most one new guild per leader per 10 minutes, and an admin can disband any guild. A successful create uses up the leader’s validated names.
Storage, limits and permissions
Guilds, members and invites are stored in SQLite (migration 0013_guilds, three new tables; see Files and data), so a guild survives restarts and an invite waits for a player who is offline.
| Limit | Value |
|---|---|
| Members per guild | GUILD_MAX_MEMBERS, default 100 (the client takes whatever number we send) |
| Invite lifetime | GUILD_INVITE_TTL_DAYS, default 7 |
| Open invites per guild | 50 |
| Invites sent per inviter | 30 per hour |
| Open invites per player | 20; the oldest is dropped (one guild holds at most one of them) |
| Re-inviting a player who declined | the same guild waits 24 hours |
| Guilds created per leader | 1 per 10 minutes |
| Action | Who |
|---|---|
| Read your guild and your invites | you |
| Invite | Leader, Officer |
| Accept, decline | the invited player |
| Leave | Member, Officer |
| Kick, change ranks, disband | Leader |
A block in either direction refuses a guild invite and removes the open ones between the two. An Officer’s invites are removed when the Officer is demoted to Member, kicked or leaves; the list leaves out, and an accept refuses (Uninvited), any invite whose inviter is no longer a Leader or Officer of that guild. A player in another guild can be invited but must leave that guild before accepting (the client says so itself, B).
Nothing is pushed. Other members and invitees see a change at their next GET /guild (login, world load, or their own guild action). The panels do not refresh themselves (B).
Slayer Links
Slayer Links (the client’s name is Linked Slayers; the My Links tab of the Social panel, K USocialPanelTabLinkedSlayers) let two friends link up for a week. Each player has three link slots. One player invites a friend into a slot, the friend accepts into one of theirs, and the link runs for 168 hours.
Status (23 September 2026): built and tested without the game, on by default (SLAYER_LINKS), not yet tried in game. The contract comes from Harmonic’s fork, which had the first working version; we checked every route and body against the executable and corrected five points (below). No Slayer Link call appeared in the census of 22 September 2026 (L), so the client may keep the tab hidden (ULinkedSlayersFeature); if it never calls these routes, nothing changes for players.
The routes
Every reply uses the Phoenix envelope {"code": null, "message": "OK", "payload": ...}. Each route acts as the account of the bearer token; ids in a body or path only name the other player.
| Route (endpoint key) | Request | Payload of the reply | Evidence |
|---|---|---|---|
GET /slayerlink/status_good (LinkedSlayersStatusEndpoint) | none | {invites: [...], links: [...], config: {link_duration_hours: 168, invite_expiry_hours: 24}} | B: FOnlineLinkedSlayer::GetStatusUpdate 0x1415e5990 and its handler 0x1415e8b40 read an object with the keys invites, links and config (serializer 0x141600510); config reads the two hours (0x1415ffc90). That this object is the envelope’s payload: S. The client polls for news (S: that the poll is this route). |
GET /slayerlink/invites (LinkedSlayersAllInvitesEndpoint) | none | {invites: [{account_id, slot, direction, status, expires, link_id}]}: pending, unexpired invites; account_id is the other player, direction is Sent or Received, status is Pending, expires an ISO date | B 0x1415e7d00, entries 0x1415ff170; the enum spellings from the SDK (S) |
GET /slayerlink/links (LinkedSlayersAllLinkSlotsDataEndpoint) | none | {links: [{account_id, linked_account_id, slot, ends, link_id, prize_pool: []}]}: running links, by the caller’s slot | B 0x1415e8690, entries 0x141600170 read account_id, slot, ends, prize_pool; the status reply’s link entries read linked_account_id, slot, ends, link_id, prize_pool. Every entry carries both sets. |
PUT /slayerlink/invite (LinkedSlayersInviteEndpoint) | {account_id, slot, action_source} | {link_id}: the invite’s id; inviting the same player again answers the same id | B 0x1415fcba2, body 0x1415ff0e0 |
POST /slayerlink/invite (LinkedSlayersInviteAcceptDeclineEndpoint) | {account_id, action, slot, action_source}, action = accept, reject or cancel | {link_id} | B: accept 0x1415d4aa6, cancel 0x1415d96cf, reject 0x1415dadd6, body 0x1415fdc40 |
DELETE /slayerlink/invites/:account_id (LinkedSlayersDeleteAllInvitesEndpoint) | none | {} | B 0x1415db890; whose id the client puts in the path was not traced (G), so both meanings are handled |
DELETE /slayerlink/links (the same key as the links list) | {account_id, slot, delete_pair}, or the same as a query | {} | B: FOnlineLinkedSlayer::DeleteLinks 0x1415dc442, body 0x141600eb0 |
POST /slayerlink/availability (LinkedSlayersGetFriendsAvailabilityEndpoint) | {account_ids: [...]}, at most 50 | {availability: [{account_id, available}]} | B 0x1415e3608, 0x1415fdf30, 0x1415fdf90 |
Errors are {"code": "<status>", "message": ..., "payload": null}: 400 (no account id, a slot outside 1-3, an unknown action), 403 (not friends, or blocked), 404 (no such account or invite), 409 (yourself, the slot is linked or has a waiting invite, already linked, the other player already invited you, no free slot, more than 20 new invites in 10 minutes, the invite ran out or was already answered). No token gets 401; a game server’s key alone gets 403.
Not answered: the reward routes PUT /slayerlink/links/rewards and GET /slayerlink/links/rewards/:account_id/:slot stay 404 until they are traced, because a false success could lose a reward. The keys LinkedSlayersInviteCancelEndpoint, ...AllLinksProgressEndpoint, ...AddLinkProgressEndpoint, ...DeleteInviteDataEndpoint (/slayerlink/link), ...SetEndTimeEndpoint and ...SetRemainingTimeEndpoint have no reference in the executable (B): the client never sends them.
The rules
- Who. Both players must be accepted friends, and neither may have blocked the other.
- Slots. Three per player, numbered 1 to 3 as the client numbers them, and one waiting invite per slot. The client fills its slot map with the keys 1 up to its
MaxLinkSlotsCount, which is 3 (B: the loop0x1415ea22d-0x1415ea3a5, the count set at0x1415ce5f6), and looks every slot up by that key; a slot it does not hold logs “Invalid Slot Id” (B0x1415ef4a0). A slot of 0 is therefore refused (400) and never stored. An accept uses the slot in the body when it is free, otherwise the first free one. - Invite limit. A player sends at most 20 new invites in 10 minutes (409 after that, the same limit as friend requests), counted from the stored invites, so a restart does not reset it. Inviting the same player again answers the waiting invite and does not count.
- Answers. Accept and reject belong to the invited player (
account_id= the sender), cancel to the sender (account_id= the invited player). Alink_idorinvite_idin the body, if the client ever sends one, is tried first. Repeating an answer of the same kind is 200 again. - Times. An invite lasts 24 hours and a link 168 hours; an expired invite turns
EXPIREDthe next time it is looked at. - Clearing invites.
DELETE /slayerlink/invites/<own id>withdraws every invite the caller sent and declines every one they received; with another player’s id it does that only for the invites between the two. - Removing a link ends the caller’s link in that slot, or with that player, for both players (there is one row per pair;
delete_pairis only logged). Nothing to remove is still 200. - Unfriending or a block cancels the waiting invites between the two, inside the same database transaction. A running link stays until it ends: whether an unfriend should also end it is the owner’s decision.
- Declined, cancelled and expired invites are deleted once their 24 hours are over, accepted invites and ended links 30 days later (both when the next new invite is made). Invites and links are stored in SQLite (migration
0016_slayer_links, tablesslayerlinkinvitesandslayerlinks; see Files and data).
What we corrected from Harmonic’s version
| Harmonic’s fork | The client (B) |
|---|---|
The invite list names the other player linked_account_id | It reads account_id (0x1415ff170) |
A link is removed at /slayerlink/link | That key is never used; the client sends DELETE /slayerlink/links with a body (0x1415dc442) |
No route for DELETE /slayerlink/invites/<id> | The client sends it (0x1415db890) |
| The status reply was flat | It nests invites, links and config (0x141600510) |
| Slots are numbered 0 to 2 | The slot map’s keys are 1 to 3 (0x1415ea22d), and every lookup uses them |
Logs. Every action is one slayerlink: line, for example slayerlink: invite by=<A> to=<B> slot=1 -> sent id=<id> or slayerlink: accept by=<B> other=<A> id=<id> -> accepted (slots 1 and 2); a refusal ends in refused <status>: <reason>. SLAYER_LINKS=0 makes every route fall through to the 404 it got before; the stored invites and links stay. The routes are also on HTTP API.
The session check (oauth/verify)
While it runs, the client regularly checks its session with GET /account/api/oauth/verify (about every 30 seconds, G for the exact period). Upstream answered every caller with the same placeholder account, which the client’s handler cannot match to its user: the executable logs “Verify auth response ignored, can’t find UserAccount for %s” for that case (B 0x140965ab8, in the handler at 0x140964040).
Since 23 September 2026 the reply names the account of the token it came with, as the real service did:
- a valid player token:
account_idis that player’s own account; - a missing, malformed, expired or foreign token: the old placeholder reply, still with 200. It never answers 401: after the token’s 24 hours a 401 could log the player out (G). A bad or expired token is logged at most once a minute (“with a bad or expired token: answering the static reply”), which is expected for sessions older than 24 hours;
expires_atstays far in the future andactivetrue.
With the player’s own id the reply takes the client’s “found the account” path; which fields that path reads, and whether it updates the expiry, was not traced (G). VERIFY_STUB_ACCOUNT=1 puts the placeholder back for everyone. The live test is a session of more than 30 minutes, ideally past the token’s 24 hours, with no logout and no reconnect loop.
Testing without the game
The fixes are tested over HTTP against the real metagame, with the client’s own bodies and headers:
- A model of the client (
UndauntedMetagame/test/socialclient.ts) reimplements the parse rules above: the mapping parser, the user-info cache with its first-reply-wins key, the toolkit’s Epic id check, the Phoenix envelope, the guild error mapping, the friends list and the party invitations. Fed the replies our server sent on 22 September 2026, it reproduces what the players saw: the array mapping maps nothing, and the old user-info reply leaves the invite’s sender invalidated. That checks the model, and guards against the same mistakes coming back. - The social flows (
test/socialflow.test.ts): Add Friends by name through the whole chain, a pending request seen at the other player’s login, blocked players, a received party invite whose sender is set up, accept, every party member resolving to their own name, the leader queueing with every member expected exactly once, user info filed under the right id in any order, the name form of/accountinfo/public, the mapping details, both rollback switches, the new friend limits, and the party invite rules (a block removes invites both ways, the pause after a decline, the sender’s limit).test/partyhttp.test.tsalso coversPARTY_SOLO_STUB=0. - Guilds (
test/guildhttp.test.ts): every route and error code, the reserved words, the game server’s create tied to the leader’s own validate of that exact name (another name, nameplate or leader is refused; a token that comes along changes nothing; the activity fallback switch), invites, expiry, the limits, ranks and hand-over, kick, leave and disband (including the route order), what a block, a decline and a demoted, kicked or departed Officer do to open invites, a restart, permissions andGUILDS=0. Each reply is checked as exact JSON and through the model. - Slayer Links (
test/slayerlinks.test.ts, rewritten from Harmonic’s case with the client’s exact paths and bodies): every route and its keys, both directions of the invite list, accept with the chosen or the first free slot, reject and cancel byaccount_id, both meanings of the invite delete, a link removed for both players, the three slots numbered 1 to 3 (0 and 4 refused, the third slot used) and one waiting invite per slot, the invite limit and the clean-up of answered invites, expiry with a test clock, an unfriend or a block cancelling waiting invites while a running link stays, 401 and 403 on all eight routes,SLAYER_LINKS=0and the unanswered reward routes. - The smaller fixes (
test/socialported.test.ts): the repeated party accept (by the party id and by the sender; not for one’s own id, a stranger, a party of one or a party left), andoauth/verify(the caller’s own id with a valid token; the placeholder with 200 for no token, a bad, expired or foreign one;VERIFY_STUB_ACCOUNT=1).
Open questions, and the live test
To be checked in the next two-player test (How to verify has the steps):
- Does the
/accountinfo/publicfix alone make received party invites show? The log should show the invite poll, thenaccountinfo/public by <recipient> for <sender> -> found. - Where does an incoming friend request appear, and is that section hidden while empty?
- Does the Block menu reach our friends routes at all (a
POSTorPUT .../blocklist/...line)? - Does the game server’s
POST /guildcarry anyAuthorizationheader (the created line ends “no token” or “a token of X came along”), and does the client validate the final name and nameplate before Create (a “no validate of this name” refusal says it did not)? - Does
GET /guildrun at every world load (34 calls in 8 logins suggest so)? - Nobody should be kicked from a party after a minute (no
DELETE /party/member/<id>or/party/leader/<id>lines). - Does Invite to Party work for a leader on their own with the placeholder candidate (a
PUT /party/inviteline appears), or does it needPARTY_SOLO_STUB=0? - How many
account/mappingcalls does each login make (the census had 6 in 8 logins)? - Does the client ever send a party accept twice (a
(a repeated accept)line)? - Does the My Links tab call the Slayer Link routes at all, and how often does it poll
GET /slayerlink/status_good? - Does a session of more than 30 minutes stay logged in with the new
oauth/verifyreply?
Every player hits three changed replies at each login, whether or not they use the Social panel: POST /accountinfo/public (now about the asked account, 404 for an unknown one), POST /account/mapping (now maps the local player where it used to map nothing), and GET /guild/invite/player (the new envelope {"code": "OK", "message": "", "payload": {"invites": []}, "invites": []} instead of the old stub {"code": null, "message": "OK", "payload": {"invites": []}}). Our client model reads all three as intended, but none has been seen in a real game. The rollback switches, if one disturbs something: ACCOUNTINFO_PUBLIC_LEGACY=1, ACCOUNT_MAPPING=0 and GUILDS=0 (see Configuration). Since 23 September 2026 a fourth reply changes for everyone: oauth/verify names the player’s own account (VERIFY_STUB_ACCOUNT=1 is its rollback).
How to verify
A test with two players, A and B, on the rented server. Each step names what to do, what the metagame’s log (data\logs\metagame.out.log on a kit server) should show, and what to do if it does not. <A> and <B> stand for the two account ids (UID-...); the request log prints every call as METHOD /path gs=0|1 (gs=1: from a game server).
0. Before the session (host).
- Deploy the update. At the first start the metagame applies migration
0013_guildsand starts listening as usual; noguild:line appears until someone uses guilds. - Optional: set
LOG_BODIES=1in the metagame’s settings for this session only, to record the request bodies (account ids and guild names; the kit turns it off again in public mode). Delete the body log afterwards. - Both players quit the game completely and start it again from the launcher. The client keeps the old answers until it restarts.
1. Login (each player). Expect, per player X: POST /login, friends: list for <X>: 0 friend(s), 0 pending, GET /friends/api/public/blocklist/<X>, accountinfo/public by <X> for <X> -> found, GET /guild gs=0 and GET /guild/invite/player gs=0 (and no “Guild invites (stubbed)” line), POST /party gs=0 about every 10 seconds, and party: poll by=<X> P=... size=1 leader=<X> (a poll line is logged again only when the party changes). Count the account/mapping by <X>: ... -> 1 of 1 mapped lines (question 8). The player reaches Ramsgate and the Social panel opens; everyone shows as Offline, which is expected. If a login hangs or the Social panel breaks, turn on ACCOUNTINFO_PUBLIC_LEGACY=1, restart the metagame and both games, and retry; then ACCOUNT_MAPPING=0, then GUILDS=0, one at a time, to find the change at fault.
2. A invites B to a party. A: Social, find B under Hunt Members (or type /invite <B's name> in chat), Invite to Party. Expect PUT /party/invite gs=0 and party: invite P=<PA> from=<A> to=<B>, then within about 10 seconds party: invites for <B> -> 1 (P=<PA> from=<A>) and accountinfo/public by <B> for <A> -> found. B sees a toast and an entry under PARTY INVITES (question 1).
- No
PUT /party/inviteline at all, and the menu item greyed out or silent: the placeholder reads as matchmaking (question 7). SetPARTY_SOLO_STUB=0, restart the metagame (the games can stay open; parties start over), and retry. party: invite by=<A> to=<B> refused ...: the reason is in the line.- The invite poll shows 1 and
accountinfo/public ... -> foundappears, but B sees nothing: the diagnosis is wrong somewhere; note it, and use the host’sPartyInviteto go on with the other steps.
3. B accepts. Expect party: accept by <B> matched=partyId P=<PA> size=2, then party: poll by=<A> P=<PA> size=2 leader=<A> members=<A>,<B>. Both party panels show both names. A second accept of the same party, if the client sends one, logs ... (a repeated accept) and is answered with the party (question 9). (Declining instead logs party: decline by=<B> ... removed=1, and A cannot invite B again for 2 minutes.)
4. A hunt together. A (the leader) picks a hunt. Expect mm: party P=<PA> candidate <C> mode=... hunt=... members=<A>,<B> by=<A> and then mm: party P=<PA> candidate <C> ready at <host>:<port> for 2 member(s); both land on the same hunt, and come back to Ramsgate with the leader. Afterwards the polls still show size=2. For the whole session there should be no DELETE /party/member/<id> or DELETE /party/leader/<id> line (question 6).
5. Add Friends. A: Social, Add Friends, type B’s username, Add. Expect EOS Account by name by <A>: <B>, account/mapping by <A>: ... ids=[1]; ... -> 1 of 1 mapped, accountinfo/public by <A> for <B> -> found and friends: request by=<A> to=<B> -> requested; A sees “friend invite sent”. B restarts the game (the lists are read only at login): expect friends: list for <B>: 0 friend(s), 1 pending; note where the request shows (question 2). B accepts: friends: request by=<B> to=<A> -> accepted; after the next login both show each other under OFFLINE (1 friend(s)).
EOS Account by name by <A>: not found: the name was typed wrong (it must be exact, any case).- No
account/mappingline after the name lookup, or0 of 1 mapped: the mapping step failed; note it (ACCOUNT_MAPPINGmust not be0). - Optional, question 3: B blocks A from A’s menu; expect a
POSTorPUT/friends/api/public/blocklist/<B>/<A>line andfriends: block by=<B> target=<A> -> blocked; note which verb. Unblock logs-> unblocked.
6. A creates a guild. A: Guilds tab, CREATE GUILD, type a name (4-15 letters and digits) and a nameplate, wait a second, press Create. Expect one or more guild: validate by <A> name="..." tag="..." -> ok, then POST /guild gs=1 and guild: created G=<id> name=... tag=... leader=<A> (validated name, no token). Note “no token” or “a token of X came along” (question 4). A sees the guild view (“Members: 1 / 100”) and the [TAG] over their head.
guild: create for <A> ... refused 403 (no code): no validate of this name and nameplate ...: the create named something A had not validated. Retype, wait until the window has checked it, and press Create again. If it keeps happening, the client does not validate the final name: setGUILD_CREATE_ACTIVITY_FALLBACK=1, restart the metagame and retry (question 4).- A validate refused with a code (for example
409 SeizedAdorableQuillshot): the window shows the reason; pick another name. - No
POST /guild gs=1line at all: the game server never sent the create; check the Ramsgate game server’s log. guild: create for <someone else>: the leader id is not A’s own; note it.
7. Guild invite, accept, ranks. A: the add-member box in the Guilds tab, B’s username (or Invite to Guild in B’s menu). Expect EOS Account by name by <A>: <B>, a mapping line and guild: invite by=<A> to=<B> -> 200. B travels (to a hunt and back) or logs in again: expect GET /guild/invite/player gs=0 and accountinfo/public by <B> for <A> -> found; B sees the invite under GUILD INVITES. B accepts: guild: accept by=<B> G=<id> -> 200, and B sees the guild view. A sees B after A’s next GET /guild (a world load). Then, as wanted: Promote To Guild Officer (guild: rank by=<A> target=<B> rank="officer" -> 200), Leave Guild (guild: leave by=<B> -> 200), DISBAND GUILD (guild: disband G=<id> by=<A> -> 200). Count the GET /guild lines per world load (question 5).
8. Slayer Links (after steps 1-7, the two being friends; once the update of 23 September 2026 is deployed). A: Social, My Links, invite B into a slot. Expect PUT /slayerlink/invite gs=0 and slayerlink: invite by=<A> to=<B> slot=<n> -> sent id=<id>. B opens My Links: expect GET /slayerlink/invites (or /slayerlink/status_good) and sees the invite with A’s name. B accepts: slayerlink: accept by=<B> other=<A> id=<id> -> accepted (slots X and Y); both see the link. Then, as wanted: remove the link (slayerlink: delete link by=... -> removed <id> (with ...)), and in a new round decline (-> declined) and cancel (-> canceled). If no /slayerlink line appears at all, the client keeps the tab hidden: note it. If anything misbehaves, set SLAYER_LINKS=0 and restart the metagame.
9. Afterwards. Look for social routes answered 404 in the request log, and for any refused line you did not expect. Turn LOG_BODIES off again and delete the body log. Write down the answers to the open questions above; the corrections go into this page and the roadmap.
Deferred
| Item | Why |
|---|---|
| Online status by default (presence over the chat connection) | Online status, EPIC FRIENDS, “In Ramsgate” and a new friend showing without a new login all ride on XMPP presence. It is built into the chat server since 23 September 2026 (Text chat) and never sends a player their own presence, but it stays off (CHAT_PRESENCE=0) until chat itself is live and a party of two has kept both members for a minute with it on (roadmap 3.10). |
| Recent players from the friends service | Never called live; the in-game Recent Players list is kept in the character data. |
| Other friends routes (settings sources, delete all, email lookups) | Never called live. |
| Listing sent party invites | The client might misread them as received. |
| Party finder, console sessions, Phoenix’s presence socket | Never called; polling carries all party state. |
| Voice | Vivox is gone; guild voice is not implemented in 1.4.4 itself. |
| Slayer Link rewards | The two reward routes are not traced yet; a false success could lose a reward. The rest of Slayer Links is built. |
| The v1 guild API | Never called by 1.4.4. |
The 2.1.1 standalone attempt describes the same Phoenix services from the final client, including the wrapped and flat reply forms. Join as a friend explains to players what works today.