Files
DarkflameServer/docs/Guilds.md
Aaron Kimbrell 8a53562629 docs: chat histories and chat flags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:23:23 -05:00

201 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Guilds
The 1.10.64 client's guild system, and what DLU does with it.
Evidence tags: **[G]** verified in Ghidra (legouniverse.exe 1.10.64), **[F]** the client's Scaleform UI
(`res/ui/ingame/*.gfx`, decompiled with JPEXS), **[D]** client data (cdclient, locale, scripts, maps), **[I]** inferred
(no client code decides it; DLU's choice). No live capture contains guild traffic (the available captures were scanned
for chat ids 0x16-0x1c, client ids 0x25-0x30 and game message 626: none).
## Enabling the client side
- Everything guild related in the client is gated by `FeatureGating::GetIsFeatureEnabled("guilds")` [G]: the guild
component registers no handlers, the create/invite/leave UI does nothing and `PlayerReady` sends nothing.
- `GetIsFeatureEnabled` (0x00a32ef0) is true when the name is in either of `FeatureGating`'s two name sets [G]. They are
filled by `AddFeatureGate` (0x00402670) from the client's `cdclient.fdb` table `FeatureGating` (`LWODataCache::Load`)
and from every event string in the login response (`PacketHandler_MSG_CLIENT_LOGIN_RESPONSE`, call at 0x00b33466) [G].
The shipped 1.10.64 table has no `guilds` row [D], so a stock client turns guilds on only when the login response
names it: set one of `event_1` … `event_8` in sharedconfig.ini to `guilds`. No client file needs changing.
- The Guild Master NPC (LOT 3001, script `scripts\ai\FV\L_GUILD_CREATE.lua`: `onUse` → `DisplayGuildCreateBox`) exists
in the cdclient [D] but is placed in no 1.10.64 `.lvl` file [D].
## UI
| UI file | Calls to C++ | Messages it listens for |
|---|---|---|
| `guildcreate.gfx` [F] | `RequestGuildCreate {guildName}` | `ToggleGuildCreate` |
| `guild.gfx` [F] | `RequestGuildInvite {playerName}`, `LeaveGuild {}`, UI `GuildUIClosed` | `ToggleGuildUI`, `LoadGuildList {guildName, guildMates[] {name, online, zone, rank}}` |
| `statusbar.gfx` [F] | `RequestGuildUI {}` (guild button) | `EnableGuild {bDisplay}` (shows the guild button) |
| `chatbox.gfx` [F] | `ChatString_FlashToCPP {chatField}` | `PlayerInGuild`, `PlayerLeftGuild`, `SendChat` channel 10 → guild tab |
- Guild create input: `maxlength 32` [F]; `LWOGuildComponent::SendTMPGuildCreate` copies at most 30 characters [G].
- The guild window has no kick, promote or disband control [F]. No slash command for guilds is registered in
`SlashCommandHandler::InitializeCommands` [G].
- Guild chat: typing in the guild tab sends `/g <text>` [F]. `/g` is not a client command [G], so it reaches the server
as a `ParseChatMessage` like any unknown command.
- The guild list shows rank as text: 1 `Leader`, 2 `Officer`, 3 `Veteran`, 4 `Recruit`, anything else nothing [G]
(`LWOGuildComponent::SendGuildListToUI`). Online members' `zone` is `ZoneTable.DisplayDescription` of the map id [G].
Online members are listed first, each group sorted by name case-insensitively [G] (`RefreshGuildUI`).
## Client component state
- `LWOCharacterComponent` holds `guildId` (u64) and `guildName` [G]. They come only from its serialization (below).
`GetCharIsInGuild` / `GetCharGuildInfo` answer from them (`guildId != 0`) [G]. `SetCharGuildInfo` and
`GuildSetStatus` have no handler anywhere [G].
- `LWOGuildComponent` holds the member map (`GuildData`: name, two dates, `map<LWOOBJID, {name, zone, rank, online}>`)
[G], filled by `GUILD_DATA` and the add/remove/online packets.
### Character component serialization (social info) [G]
`LWOCharacterComponent::Deserialize`, after the GM info and activity blocks:
| Field | Type |
|---|---|
| dirty flag | bit |
| guild id | u64 |
| guild name length | u8 |
| guild name | length × UTF-16 |
| is LEGO club member | bit |
| country code | u32 |
On a non-construction update the client re-renders the name billboard (`GuildRenderName`, `ReRenderNameBillboard`).
## Triggers (client) [G]
| Event | Client does |
|---|---|
| `PlayerReady` | if in guild (component guild id ≠ 0): send `GUILD_GET_ALL` |
| `ChatConnectionUpdate` (connected) | same |
| `RequestGuildData` (sent by the client itself after `GUILD_CREATE_RESPONSE` created or `GUILD_INVITE_CONFIRM` success) | send `GUILD_GET_ALL` |
| UI `RequestGuildCreate` | if not in guild: world packet `TMP_GUILD_CREATE`; else local "MSG_GUILD_ALREADY_IN_GUILD" |
| UI `RequestGuildInvite` | `CheckInviteSpamming` (type 4, local rate limit), then `GUILD_INVITE` |
| UI `LeaveGuild` | if in guild: `GUILD_LEAVE` |
| Message box `MSG_GUILD_NAME_INVITED_YOU_TO_THE_GUILDNAME_GUILD!` | `GUILD_INVITE_RESPONSE`, button 1 → declined 0, otherwise declined 1 |
| `GuildInvite` from an ignored player | `GUILD_INVITE_RESPONSE` declined 1, no box |
| `DisplayGuildCreateBox` (network GM 626) | UI `ToggleGuildCreate {bShow: true}` (bShow from the message is not used) |
## Packets
Offsets are into the packet after the 1-byte `0x53`: u16 connection type, u32 id, u8 padding, then data at 7 (wire
offset 8). All strings are fixed-size UTF-16 buffers read to the first NUL.
### Client → server
The client sends chat-service packets (connection type 2) through its world as `ROUTE_PACKET`; DLU's world forwards
them to chat with the player's object ID (`WorldPackets::RoutePacket::ToChat`, which drops the first 4 data bytes).
| Packet | Id | Bytes | Layout (from offset 7) | Evidence |
|---|---|---|---|---|
| World `TMP_GUILD_CREATE` | 4 / 20 | 69 | wchar[31] name (≤ 30 characters) | [G] `SendTMPGuildCreate` |
| Chat `GUILD_INVITE` | 2 / 23 | 81 | u64 0, wchar[33] player name | [G] `SendGuildInvitePacket` |
| Chat `GUILD_INVITE_RESPONSE` | 2 / 24 | 16 | u64 0, u8 declined | [G] `LWOGuildComponent::SendMessage` |
| Chat `GUILD_LEAVE` | 2 / 25 | 81 | u64 0, then 66 bytes of stack (the client's length is wrong) | [G] `LeaveGuild` |
| Chat `GUILD_GET_ALL` | 2 / 28 | 15 | u64 0 | [G] `SendGuildGetAll` |
`GUILD_CREATE` (22), `GUILD_KICK` (26) and `GUILD_GET_STATUS` (27) are never sent by the client [G].
### Server → client (`MessageType::Client`, connection type 5)
| Packet | Id | Layout (from offset 7) | Client does | Evidence |
|---|---|---|---|---|
| `GUILD_CREATE_RESPONSE` | 37 | u8 result, u64 at 8 (unread; DLU: guild id [I]), wchar[31] name at 16 | 0 → "MSG_GUILD_GUILD_NAME_CREATED" (name) + `RequestGuildData`; 1 → "…NAME_CANT_BE_USED"; 2 → "…NAME_ALREADY_IN_USE"; else "…COULD_NOT_BE_CREATED" (chat channel 0) | [G] |
| `GUILD_GET_STATUS_RESPONSE` | 38 | — | no handler | [G] |
| `GUILD_INVITE` | 39 | wchar[33] inviter name, wchar[31] guild name at 73 | "MSG_GUILD_NAME_WANTS_YOU_TO_BE_IN_NAME_GUILD!" unless the inviter is ignored, then game message `GuildInvite` → message box | [G] |
| `GUILD_INVITE_INITIAL_RESPONSE` | 40 | u8 code, wchar[33] name | 0 "…INVITE_SENT_TO_NAME", 1 "MSG_GENERIC_NAME_IS_NOT_ONLINE", 2 "…NAME_IS_ALREADY_IN_A_GUILD", 3 "…ALREADY_HAS_A_GUILD_INVITE_PENDING", else "…COULD_NOT_INVITE_NAME" | [G] |
| `GUILD_INVITE_FINAL_RESPONSE` | 41 | u8 code, wchar[33] name | 0 "…NAME_HAS_JOINED_THE_GUILD", 1 "…DECLINED_YOUR_INVITATION", 2 "…IS_NOT_ONLINE", else "CLIENTMSG_COULD_NOT_INVITE_NAME" | [G] |
| `GUILD_INVITE_CONFIRM` | 42 | u8 failed, wchar[33] guild name | 0 → "MSG_GUILD_YOU_JOINED_THE_GUILD_NAME" + `RequestGuildData`; else "…YOU_COULD_NOT_BE_ADDED…" | [G] |
| `GUILD_ADD_PLAYER` | 43 | wchar[33] name, u64 id at 73, u8 rank at 81, LWOZoneID at 82, u8 online at 90 | "…NAME_HAS_JOINED_THE_GUILD" (channel 10); adds the member if the guild data is loaded | [G] |
| `GUILD_REMOVE_PLAYER` | 44 | u8 reason (0 left, 1 kicked), wchar[33] name, u64 id at 74, u64 new leader at 82 | message (channel 10); id = self → guild UI cleared (`PlayerLeftGuild`, `EnableGuild` false); else member removed, new leader's rank set to 1 | [G] |
| `GUILD_LOGIN_LOGOUT` | 45 | wchar[33] name, u64 id at 73, u8 online at 81, LWOZoneID at 82, u8 world-update-only at 90 | unless world-update-only: "…GUILDMATE_NAME_LOGGED_IN/OFF" (channel 10); sets online + zone | [G] |
| `GUILD_RANK_CHANGE` | 46 | — | no handler | [G] |
| `GUILD_DATA` | 47 | below | `PopulateGuildData` | [G] |
| `GUILD_STATUS` | 48 | — | no handler | [G] |
`GuildSetPlayerRank` (game message 590) is a no-op in `LWOGuildComponent` [G]: a rank change reaches the client only
through a new `GUILD_DATA`.
#### `GUILD_DATA` [G]
| Offset | Type | Field |
|---|---|---|
| 7 | u8 | status; only 0 is used (anything else: ignored) |
| 8 | wchar[31] | guild name |
| 70 | wchar[11] | date (ends up in `GuildData.wsJoinDate`) |
| 92 | wchar[11] | date (ends up in `GuildData.wsFoundDate`) |
| 114 | i32 | reputation (not used) |
| 118 | i32, i32, u16 | not read |
| 128 | u16 | member count (0: ignored) |
| 130 | member × count | |
Member (84 bytes): u8 rank, u8 online, LWOZoneID zone (8 bytes, read only when online), u64 object id, wchar[33] name.
The dates are not shown by the UI (`setGuildInfo(name, "")` [F]); their format is unknown ([I] DLU sends `MM/DD/YYYY`).
On success the client shows the guild button and guild chat tab (`PlayerInGuild`, `EnableGuild` true).
### Game message
| Message | Id | Direction | Fields | Evidence |
|---|---|---|---|---|
| `DisplayGuildCreateBox` | 626 | server → client | bit bShow | [G] Serialize 0x00dbb260, Deserialize 0x00dbb2a0 |
Game messages 578-596 (`GuildGetSize` … `GuildRenderName`) are client-local [G]: their Serialize/Deserialize slots are
the shared no-op stubs (e.g. `GuildInvite` vtable 0x015932ac: 0x00411820, 0x004175d0); the packet handlers above create
them.
## Chat channels
`eChatChannel` 10 `GUILD`, 11 `GUILDNOTIFY` [G][F]. Guild chat arrives at the client as `MSG_CHAT_PRIVATE_CHAT_MESSAGE`
(or general chat) with channel 10; `PacketHandler_MSG_CHAT_PRIVATE_CHAT_MESSAGE` passes the channel to the UI, which
routes 10 to the guild tab [G][F]. Channel 11 has no UI box [F].
## Limits
| Rule | Value | Evidence |
|---|---|---|
| Name length | ≤ 30 characters (client), ≥ 3 [I] | [G] / [I] |
| Name characters | letters, digits, space, `'` `-` `.`; no leading/trailing or double spaces [I] | [I] |
| Name uniqueness | case-insensitive [I] | "…NAME_ALREADY_IN_USE" exists [G] |
| Members | `guild_max_members` (chat config, default 100) [I] | none in client |
| Ranks | 1 Leader, 2 Officer, 3 Veteran, 4 Recruit | [G] |
| One guild per character, one pending invite per character | | "…ALREADY_IN_A_GUILD", "…INVITE_PENDING" [G] |
## Locale strings [D]
`MSG_GUILD_*` (41 ids) and `UI_GUILD_*`, `UI_GUILDCREATE_*`, `UI_CHAT_CHGUILD`, `UI_CHAT_GUILD_CHAT` in `locale.xml`.
No cdclient table is about guilds (besides `FeatureGating`, which lacks the row; the login response's events turn it on).
## DLU implementation
- **The chat server is the guild authority** (`dChatServer/GuildManager`, connected in `ChatGuilds`): create, invite,
answer, leave, kick, ranks, disband, online status, guild chat. State is in the database (`guilds`, `guild_members`,
`guild_invites`, `guild_events`; MySQL migration 97, SQLite 80); client packets go to members through their worlds
(`WorldRoutePacket`).
- World → chat: `GUILD_CREATE` (from the client's `TMP_GUILD_CREATE`), `GUILD_KICK`, `GUILD_SET_RANK` and
`GUILD_DISBAND` (DLU ids appended to `MessageType::Chat`), guild chat as `GENERAL_CHAT_MESSAGE` channel 10. Chat → world:
`GUILD_GET_STATUS` with a character's guild id and the name others may see; the world puts it in the character
component. The character component reads the guild from the database when the character loads.
- Ranks: leader and officers invite; the leader kicks anyone, officers kick veterans and recruits; the leader sets any
rank, officers move members between veteran and recruit. New members are recruits. A leader who leaves hands the
guild to the highest-ranked, longest-serving member; the last member out ends it. A guild whose leader's character was
deleted gets the next member as leader. Rank changes send every online member `GUILD_DATA` again.
- A new member's inviter gets `GUILD_INVITE_FINAL_RESPONSE` joined and the list again; the other online members get
`GUILD_ADD_PLAYER` (both would print "has joined" for the inviter). Disbanding sends each online member a
`GUILD_REMOVE_PLAYER` about themselves, which makes their client drop the guild.
- One pending invite per character, answerable for `guild_invite_timeout` seconds (600) and dropped when the character
logs off. `guild_max_members` (100) per guild. Both in chatconfig.ini.
- Names: `GuildNameRules` (3-30 of letters, digits, space ' - ., no space at either end or twice; surrounding spaces the
player typed are dropped); unique without regard to case; the chat filter's deny list refuses a name (`BAD_NAME`); a
name the allow list doesn't cover makes the guild but waits for moderation, and until it is approved other players see
no guild name (members see it in the guild window).
- `GUILD_DATA` dates are `MM/DD/YYYY` (UTC): the member's join date and the guild's creation date.
- Slash commands (the client has none for guilds): `/g` or `/guild <text>` (what the guild chat tab sends; chat filter
and mute as zone chat), `/guildcreate` (opens the create box), `/gkick <name>`, `/grank <name>
<officer|veteran|recruit>`, `/gleader <name>`, `/gdisband confirm`. The Guild Master script (LOT 3001) opens the
create box when used.
- Dashboard: **Guilds** page (`guilds_manage`): list, members, history, approve or reject a name (rejected: renamed
"Guild <id>"), rename, remove a member, disband; pending names also in the Review Queue. Changes are audited, added to
the guild's history, and sent to the chat server (`ePlayerAction::GUILD_CHANGED` through master), which tells online
members. Guild chat is logged as channel "guild" with the guild's ID, and is private chat (`chat_private`); each
guild's chat history is at `/chat_log/guild/<id>` (**Guild chat** on the guild's card).
- Not done: the client has no packet for a member's name change (`GuildUpdatePlayerName` is local), so a renamed
character shows under the new name after the members' next `GUILD_DATA`; the guild reputation field is always 0.