Files
90CNServer/server/internal/services/dnfbridge/guild_protocol.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

1093 lines
42 KiB
Go

package dnfbridge
import (
"encoding/binary"
"os"
"strconv"
"strings"
"time"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
)
// NoPack sub_FCCF70 renders six rows at a time, but its page counter divides
// the op1452 total-result count by 30. The request page is therefore a
// zero-based 30-row page, not the number of currently visible controls.
const currentGuildPageSize = 30
// currentGuildMembershipNotificationMsgID is NOTIPACKET_JOIN_GUILD_INFO in
// the current client. It intentionally shares the numeric value 0x134 with
// class-1 CMDPACKET_WITHDRAW_MONEY; the class-0 notification namespace is
// separate from the command namespace.
const currentGuildMembershipNotificationMsgID uint16 = 0x0134
type currentGuildSearchRequest struct {
Mode byte
Page int
Category byte
Query string
}
func parseCurrentGuildSearchRequest(body []byte) (currentGuildSearchRequest, bool) {
if len(body) < 1 {
return currentGuildSearchRequest{}, false
}
request := currentGuildSearchRequest{Mode: body[0]}
if request.Mode == 2 {
if len(body) != 6 {
return currentGuildSearchRequest{}, false
}
request.Page = int(binary.LittleEndian.Uint32(body[1:5]))
request.Category = body[5]
return request, true
}
raw, next, ok := readCurrentRawDstr(body, 1)
if !ok || next != len(body) {
return currentGuildSearchRequest{}, false
}
query, err := decodeCharacterName(raw)
if err != nil {
return currentGuildSearchRequest{}, false
}
request.Query = strings.TrimSpace(query)
return request, true
}
func parseCurrentGuildDSTR(body []byte, offset int) (string, int, bool) {
raw, next, ok := readCurrentRawDstr(body, offset)
if !ok {
return "", offset, false
}
value, err := decodeCharacterName(raw)
if err != nil {
return "", offset, false
}
return strings.TrimSpace(value), next, true
}
func parseCurrentGuildUint32(body []byte, offset int) (uint32, int, bool) {
if offset < 0 || offset > len(body)-4 {
return 0, offset, false
}
return binary.LittleEndian.Uint32(body[offset : offset+4]), offset + 4, true
}
func currentGuildNumericID(value string) uint32 {
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
if err != nil {
return 0
}
return uint32(parsed)
}
// buildCurrentGuildApplicationNoticeBody follows the raw op365 callback at
// NoPack sub_31A4E30/sub_31AAA90. A non-zero guild ID resolves the cached guild,
// then removes a matching tuple from the guild-management application list.
// This callback does not assign the current actor's guild membership.
// The zero form is a no-op when there is no established membership to bind.
func buildCurrentGuildApplicationNoticeBody(guildID uint32, accountSlot byte, characterName string) []byte {
var writer packetWriter
if guildID == 0 || strings.TrimSpace(characterName) == "" {
writer.writeUint32(0)
writer.writeByte(0)
writer.writeRawDstr(nil)
return writer.bytes()
}
writer.writeUint32(guildID)
writer.writeByte(accountSlot)
writeClientNameDstr(&writer, characterName)
return writer.bytes()
}
// buildCurrentGuildMembershipNotificationBody follows NoPack sub_1D8D520,
// registered as the class-0 NOTIPACKET_JOIN_GUILD_INFO (0x134) callback. This
// updates the board's pending-guild state, not the actor's membership. The
// actor's typed mode0 tail owns the joined guild ID. The present branch consumes:
//
// u8 present, u32 guild id, DSTR guild name, DSTR master name,
// u32 unused, u32 member count, u8 join mode, DSTR promotion
//
// Only the second u32 reaches sub_26AE4C0, the guild member-count setter.
func buildCurrentGuildMembershipNotificationBody(guild *dnfguild.Guild) []byte {
var writer packetWriter
if guild == nil {
writer.writeByte(0)
return writer.bytes()
}
writer.writeByte(1)
writer.writeUint32(guild.ID)
writeClientNameDstr(&writer, guild.Name)
writeClientNameDstr(&writer, guild.MasterName)
writer.writeUint32(0)
writer.writeUint32(uint32(len(guild.Members)))
writer.writeByte(guild.JoinMode)
writeClientNameDstr(&writer, guild.Promotion)
return writer.bytes()
}
func buildCurrentGuildAccountListBody(characters []currentGuildCharacterProjection) []byte {
if len(characters) > 255 {
characters = characters[:255]
}
var writer packetWriter
writer.writeByte(byte(len(characters)))
for _, character := range characters {
writeClientNameDstr(&writer, character.CharacterName)
writeClientNameDstr(&writer, character.GuildName)
writer.writeByte(character.ApplicationPending)
}
return writer.bytes()
}
// buildCurrentGuildAccountIndexBody follows current NoPack sub_FCEFB0 exactly.
// After the common success marker, op1453 carries complete guild objects and
// then account-character mappings. The three DSTR fields are name, master and
// promotion in that order. Swapping master and promotion makes the current
// client render the promotion as the guild master and leaves recommendation
// rows backed by an incoherent detail object.
//
// The mapping byte reaches sub_FCBB70 -> sub_2272670's region-name lookup.
// This local profile advertises region 0 for every account character.
func buildCurrentGuildAccountIndexBody(guilds []*dnfguild.Guild, characters []currentGuildCharacterProjection) []byte {
rows := make([]*dnfguild.Guild, 0, min(len(guilds), 255))
for _, guild := range guilds {
if guild != nil {
rows = append(rows, guild)
if len(rows) == 255 {
break
}
}
}
mappings := make([]currentGuildCharacterProjection, 0, min(len(characters), 255))
for _, character := range characters {
if character.GuildID != 0 {
mappings = append(mappings, character)
if len(mappings) == 255 {
break
}
}
}
var writer packetWriter
writer.writeByte(byte(len(rows)))
for _, guild := range rows {
writeCurrentGuildRichRow(&writer, guild)
}
writer.writeByte(byte(len(mappings)))
for _, character := range mappings {
writer.writeUint32(character.GuildID)
writer.writeByte(0)
writeClientNameDstr(&writer, character.CharacterName)
}
return writer.bytes()
}
// buildCurrentGuildSearchListBody follows NoPack sub_FCE850 (op1452). After
// the common success byte the client reads:
//
// u32 total result count, u8 zero-based page, u8 row count,
// rows of {u32 id, u8 join mode, u32 member limit, u16 member count,
// u8 level, DSTR name, DSTR master, DSTR promotion}.
//
// The row is the same rich-guild structure consumed by op1453. Returning only
// the success byte leaves the native page and row fields uninitialised, which
// is why the board displayed 49/26948815, 12336 and six placeholder G icons.
func buildCurrentGuildSearchListBody(total int, page int, guilds []*dnfguild.Guild) []byte {
if total < 0 {
total = 0
}
if page < 0 {
page = 0
}
if page > 255 {
page = 255
}
rows := make([]*dnfguild.Guild, 0, min(len(guilds), 255))
for _, guild := range guilds {
if guild != nil {
rows = append(rows, guild)
if len(rows) == 255 {
break
}
}
}
var writer packetWriter
writer.writeUint32(uint32(total))
writer.writeByte(byte(page))
writer.writeByte(byte(len(rows)))
for _, guild := range rows {
writeCurrentGuildRichRow(&writer, guild)
}
return writer.bytes()
}
func writeCurrentGuildRichRow(writer *packetWriter, guild *dnfguild.Guild) {
writer.writeUint32(guild.ID)
writer.writeByte(guild.JoinMode)
writer.writeUint32(currentGuildRichRowTagsMask(guild))
writer.writeUint16(uint16(min(len(guild.Members), 0xffff)))
writer.writeByte(byte(min(guild.Level, 0xff)))
writeClientNameDstr(writer, guild.Name)
writeClientNameDstr(writer, guild.MasterName)
writeClientNameDstr(writer, guild.Promotion)
}
// currentGuildRichRowTagsMask returns the u32 the rich guild row carries between
// its join mode and its member count.
//
// Live evidence (2026-09-11, packet_log 15:24:08Z): the slot used to carry the
// member limit. A master had just saved 有爱/欢迎萌新/搬砖 (op740 tags bitmask 7,
// later 0x13 = 有爱/欢迎萌新/决斗场) yet another account's guild board rendered
// 攻坚队/单挑/阿拉德好公民. The historic value, dnfguild.DefaultMemberLimit
// (1000 == 0b1111101000), has exactly bits 3, 5 and 6 set -- the three names the
// board showed, with 决斗场 (bit 4) absent, which is the fingerprint of the client
// reading this u32 as the 公会倾向 mask. The guild-side getter (00FCAFB0) renders
// "公会倾向 : 无" when the mask is zero and otherwise "公会倾向 : " plus one name
// per set bit (sub_228C820 over the mask table {1,2,4,8,16,32,64} for
// 有爱/欢迎萌新/搬砖/攻坚队/决斗场/单挑/阿拉德好公民), so a stored member limit
// shows up as three arbitrary tags. The member limit still reaches the client
// through op70's member-capacity benefit row.
//
// Operators can restore or probe the slot without a rebuild:
//
// environment DNF90_GUILD_ROW_TAGS_SLOT, or
// file guild_row_tags_slot.txt, holding "tags" (default), "limit", or a decimal
func currentGuildRichRowTagsMask(guild *dnfguild.Guild) uint32 {
switch slot := currentGuildRichRowTagsSlotOverride(); slot {
case "limit":
if guild == nil {
return 0
}
return uint32(max(guild.MemberLimit, 0))
case "":
return currentGuildTagsMask(guild)
default:
if parsed, err := strconv.ParseUint(slot, 10, 32); err == nil {
return uint32(parsed)
}
return currentGuildTagsMask(guild)
}
}
func currentGuildRichRowTagsSlotOverride() string {
const key = "DNF90_GUILD_ROW_TAGS_SLOT"
raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return raw
}
// currentGuildChannelPermissionBit is the grade-table bit the 公会频道设置 dialog
// checks before it sends its save. The dialog itself tests permission 1
// (00FAC0BA push 1 ; 00FAC0BE call sub_26A3BE0, which shifts 1 by that number
// and ANDs it with gradeTable[myGrade].permissions at 026A3C37), so the same bit
// decides whether the button may be enabled at all.
const currentGuildChannelPermissionBit = 1
// currentGuildChannelSettingByte returns op70's byte after the guild-name DSTR,
// which the client stores at guildManager+0x30 and treats as "this viewer may
// open and save the 公会频道设置 dialog".
//
// Live evidence (2026-09-12): with the historic 0 the master could not press the
// button either -- 00FABC98 test eax,eax / je disables it -- while op127 already
// carried the viewer's grade correctly. The viewer's grade-table permission for
// the dialog's own save check is therefore the value that keeps the button in
// step with the save: the master's grade 1 mask (0x3FFFF) has the bit, plain
// members (mask 0) do not, so the tooltip 只有 "会长" 才能使用 stays truthful.
//
// Operators can probe the field without a rebuild:
//
// environment DNF90_GUILD_CHANNEL_FLAG, or
// file guild_channel_flag.txt, holding
// perm (default) the viewer's grade-table permission bit, 0 or 1
// grade the viewer's grade byte
// master 1 for the guild master, 0 for everyone else
// <0-255> that value verbatim
func currentGuildChannelSettingByte(guild *dnfguild.Guild, viewerGrade byte) byte {
switch mode := currentGuildChannelFlagOverride(); mode {
case "grade":
if viewerGrade >= 100 { // sub_1D85270's nonmember sentinel
return 0
}
return viewerGrade
case "master":
if guild != nil && viewerGrade == dnfguild.DefaultMasterGrade {
return 1
}
return 0
case "", "perm":
if guild == nil || viewerGrade > 6 {
return 0
}
if dnfguild.GradePermissionsFor(guild, viewerGrade)&(1<<currentGuildChannelPermissionBit) != 0 {
return 1
}
return 0
default:
if parsed, err := strconv.ParseUint(mode, 10, 8); err == nil {
return byte(parsed)
}
return 0
}
}
func currentGuildChannelFlagOverride() string {
const key = "DNF90_GUILD_CHANNEL_FLAG"
raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return raw
}
// The byte right after 公会等级 in the op70 body lands in the client's guild
// object at +0x80 (sub_1D8B560 reads it at 01D8B6C5 and stores it at 01D8B6E0;
// the live object at [0x51B2754]+0x80 is 0 while we send 0). The 公会基地 window
// renders 当前公会基地 : %d阶段 (text 21502, 00... 0309FCE0) from a value that no
// decoded packet fills yet, so this slot is the current candidate for it and can
// be driven from a file without a rebuild:
//
// environment DNF90_GUILD_BASE_STAGE_BYTE, or
// file guild_base_stage_byte.txt, holding
// off (default) keep the historical 0
// stage the guild's 公会基地 stage (1..5)
// <0-255> that value verbatim
func currentGuildBaseStageByte(guild *dnfguild.Guild) byte {
switch mode := currentGuildBaseStageByteOverride(); mode {
case "", "off":
return 0
case "stage":
if guild == nil {
return 0
}
return guild.BaseStage
default:
if parsed, err := strconv.ParseUint(mode, 10, 8); err == nil {
return byte(parsed)
}
return 0
}
}
func currentGuildBaseStageByteOverride() string {
const key = "DNF90_GUILD_BASE_STAGE_BYTE"
raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
return raw
}
// currentGuildTagsMask collapses the stored tag index list into the bitmask the
// current client renders.
func currentGuildTagsMask(guild *dnfguild.Guild) uint32 {
if guild == nil {
return 0
}
mask := uint32(0)
for _, tag := range guild.Tags {
if tag < 7 {
mask |= 1 << tag
}
}
return mask
}
// buildCurrentGuildRecommendationBody follows NoPack sub_1D24680 exactly.
// The current client consumes an op762 success envelope, then a u8 row count
// followed by {u32 guild id, DSTR guild name, DSTR guild master name}. These
// summary rows seed the promotion-board model; only after that model exists
// does the client issue per-name op745 detail requests.
func buildCurrentGuildRecommendationBody(guilds []*dnfguild.Guild) []byte {
rows := make([]*dnfguild.Guild, 0, min(len(guilds), 255))
for _, guild := range guilds {
if guild != nil {
rows = append(rows, guild)
if len(rows) == 255 {
break
}
}
}
var writer packetWriter
writer.writeByte(byte(len(rows)))
for _, guild := range rows {
writer.writeUint32(guild.ID)
writeClientNameDstr(&writer, guild.Name)
writeClientNameDstr(&writer, guild.MasterName)
}
return writer.bytes()
}
type currentGuildCharacterProjection struct {
CharacterName string
GuildName string
GuildID uint32
ApplicationPending byte
}
func buildCurrentGuildSearchResultBody(guild *dnfguild.Guild) []byte {
var writer packetWriter
writer.writeUint32(guild.ID)
writeClientNameDstr(&writer, guild.Name)
writer.writeUint16(uint16(len(guild.Members)))
writeClientNameDstr(&writer, guild.MasterName)
writer.writeUint32(uint32(guild.Level))
tags := guild.Tags
if len(tags) > 255 {
tags = tags[:255]
}
writer.writeByte(byte(len(tags)))
for _, tag := range tags {
writer.writeByte(tag)
}
writeClientNameDstr(&writer, guild.Promotion)
writer.writeByte(guild.JoinMode)
writer.writeUint32(uint32(guild.MemberLimit))
return writer.bytes()
}
// currentGuildMemberOfflineSeconds is how long the member has been away, which
// is the only field in the row that separates an offline member from an online
// one -- the channel byte 0xff merely turns into member+0x34 == -1, which the
// window renders as a flag rather than dropping the row. An online member
// reports zero. An offline member whose last-online mark was never stamped
// falls back to its join time so the value is still non-zero; reporting zero
// there would make it look online.
func currentGuildMemberOfflineSeconds(
member dnfguild.Member,
online bool,
now time.Time,
) uint32 {
if online {
return 0
}
since := member.LastOnlineAt
if since.IsZero() {
since = member.JoinedAt
}
if since.IsZero() {
return 0
}
elapsed := now.Sub(since).Seconds()
if elapsed <= 0 {
return 0
}
if elapsed >= float64(^uint32(0)) {
return ^uint32(0)
}
return uint32(elapsed)
}
// currentGuildRowKeyBytes returns the two row bytes that sit between the
// advancement byte and the channel byte of a guild member record.
//
// Confirmed live on 2026-09-12: with 0/0 the member window renders
// "ch181.未知"; with the advertised server id in the first byte the window
// renders "ch181.<频道名>". The first byte is therefore member+0x31, the
// primary key of the channel-name lookup performed by the guild channel column
//
// currentGuildRowKeyBytes, and its value is the advertised server id -- the
// same value the channel directory packet advertises in
// writeChannelInfoPrefix (instance.json: channelAdvertiseServerIndex, default
// 1, which is also channelServerID()'s fallback).
//
// The second byte lands at member+0x30 and becomes a boolean at member+0x180
// (sub_1D21FD0: cmp byte [ebp-0x281],0 / seta). member+0x180 is read by the
// member-list builder (00FAAD90, gates drawing
// Interface/newstyle/windows/community/friend.img) and by the channel-follow
// handler (00FB52D3, passed as a flag into sub_325F590 -> op804). It is NOT
// used by the channel-name lookup, so the default mirrors the server id, which
// is the combination verified live.
//
// Operators can still override both without a rebuild:
//
// environment DNF90_GUILD_ROW_KEY, or
// file guild_row_key.txt, holding 1 or 2 space separated decimals:
// <member+0x30> [member+0x31] (0 0 restores the historic wire form)
const guildRowKeyDefaultServerID = byte(1)
func currentGuildRowKeyBytes() (byte, byte) {
const key = "DNF90_GUILD_ROW_KEY"
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return guildRowKeyDefaultServerID, guildRowKeyDefaultServerID
}
fields := strings.Fields(raw)
if len(fields) > 2 {
fields = fields[:2]
}
values := make([]byte, 0, 2)
for _, field := range fields {
parsed, err := strconv.Atoi(field)
if err != nil || parsed < 0 || parsed > 255 {
return guildRowKeyDefaultServerID, guildRowKeyDefaultServerID
}
values = append(values, byte(parsed))
}
var first, second byte
if len(values) > 0 {
first = values[0]
}
if len(values) > 1 {
second = values[1]
} else {
second = first
}
return first, second
}
// buildCurrentGuildMemberListBody follows NoPack sub_1D21FD0 exactly. The
// current handler consumes the common success marker before these fields.
func buildCurrentGuildMemberListBody(guild *dnfguild.Guild, includeGuildIdentity, includeCursor bool, onlineChannels map[string]byte, now time.Time) []byte {
var writer packetWriter
if guild == nil {
writer.writeUint32(0)
if includeGuildIdentity {
writer.writeRawDstr(nil)
writer.writeUint16(0)
}
writer.writeUint16(0)
if includeCursor {
writer.writeUint32(0)
}
return writer.bytes()
}
// sub_269A120 returns the actor's guild ID, not its character ID.
writer.writeUint32(guild.ID)
if includeGuildIdentity {
writeClientNameDstr(&writer, guild.Name)
writer.writeUint16(uint16(min(len(guild.Members), 0xffff)))
}
members := dnfguild.SortedMembers(guild)
if len(members) > 0xffff {
members = members[:0xffff]
}
writer.writeUint16(uint16(len(members)))
for _, member := range members {
writer.writeUint32(currentGuildNumericID(member.CharacterID))
writeClientNameDstr(&writer, member.Name)
writeClientNameDstr(&writer, member.Memo)
writer.writeUint16(uint16(member.Level))
writer.writeByte(byte(numericCharacterStat(member.Job)))
// Advancement byte. sub_1D21FD0 reads it here, copies it through
// sub_3A51780 and splits it into a low nibble and bits 4..6 exactly
// like the role-select card does, so sending 0 rendered every member
// as their base class.
writer.writeByte(byte(numericCharacterStat(member.GrowType)))
// The two bytes between the advancement byte and the channel byte land
// at member+0x30 and member+0x31. sub_1D21FD0 reads them as the 3rd and
// 4th byte of the row and hands the 3rd one to sub_1D2218C -> [ebp-0x290]
// -> member+0x31, which the guild window's channel column then uses as
// the FIRST key of sub_1892CA0(guildChannelStore = 0x5188FF0, key1, key2):
//
// 00FA7730: mov eax,[esi+0x34] ; channel (key2)
// movzx ecx,byte [esi+0x31] ; key1
// call sub_18A5110 ; call sub_1892CA0
// if (result == 0) name = sub_367BE00(0x26C) ; "未知"
// else name = *(wstring*)(result + 4)
// sprintf("ch%02d.%s", channel, name)
//
// Sending 0 for that byte makes every lookup miss, so every online member
// renders "ch181.未知" instead of "ch181.<频道名>". currentGuildRowKeyBytes
// lets an operator sweep the correct value without a rebuild.
key1, key2 := currentGuildRowKeyBytes()
writer.writeByte(key1)
writer.writeByte(key2)
channel := currentGuildMemberOfflineChannel
online := false
if encoded, found := onlineChannels[member.CharacterID]; found {
channel = encoded
online = true
}
writer.writeByte(channel)
writer.writeByte(0)
writer.writeByte(0)
writer.writeByte(member.Grade) // sub_1D21FD0 -> member+324
// Seconds this member has been offline, not mileage. sub_1D21FD0 stores
// this u32 at member+0x154, immediately before the roster key at
// +0x158, and 86JP's GuildPacketBuilder writes OfflineSeconds here:
// zero for an online member, now - lastOnline for an offline one. This
// tree sent Mileage, which is always 0, so an offline member looked
// exactly like an online one in the only field that distinguishes them.
writer.writeUint32(currentGuildMemberOfflineSeconds(member, online, now))
// Roster key. sub_1D21FD0 stores this u32 at member+0x158 and
// sub_26A9C30 uses it as the key of the guild's std::map at
// guild+0x2D8 (its size lives at guild+0x2E4, cleared together with
// the member vector by sub_26A90E0). Sending 0 for every row made all
// members collapse onto one map entry, so the window rendered exactly
// one member. usdof_v54 `opcode0043-guild_member_list` writes
// stage3_guild_member_roster_key(member.character_id) in this slot.
writer.writeUint32(currentGuildNumericID(member.CharacterID))
// Offline-listable marker. sub_FB4910 rebuilds the member window in
// several passes over the roster: the first keeps rows whose
// member+0x3C resolved to a live peer, the second keeps rows that are
// unresolved but still carry a channel, and the third
// (00FB4C89..00FB4D0F) is the only pass that can take an offline row
// -- it requires member+0x3C == 0, member+0x34 < 0 *and*
// member+0x15C != 0 (00FB4C95 cmp dword [eax+0x15c],0 / je skip).
// This byte lands at member+0x15C (sub_1D21FD0 reads it right after
// the roster key at 01D2221E and zero-extends it at 01D2234C), and
// sending 0 dropped every offline member before the window ever saw
// it -- which is why the "show offline members" checkbox looked dead.
// The checkbox itself was never the problem: 00FB4F19 only skips the
// closing erase pass that would remove those same rows again.
// Only offline rows need the marker. The manual sort in sub_FAD7F0
// calls sub_FA6F00, which orders any record with +0x15C != 0 ahead of
// every other row before it looks at the sort column, so marking
// online rows too would pin the whole list. 1 is the value the client
// itself tests for (00FC1813 / 00F9E3BD cmp ...,1 / sete).
offlineListable := byte(0)
if !online {
offlineListable = 1
}
writer.writeByte(offlineListable)
writer.writeRawDstr(nil)
// Second member view (00FC4E20..) repeats the same offline test and
// additionally requires member+0x181 != 0 (00FC4E35 / 00FC4F2B).
// sub_1D21FD0 turns this byte into that flag with a setne (01D223AF).
writer.writeByte(offlineListable)
// member+0x182, the region byte. It has to match the op1454 settings
// row and be non-zero for the member context menu to send anything;
// see currentGuildMemberRegion.
writer.writeByte(currentGuildMemberRegion)
}
if includeCursor {
writer.writeUint32(0)
}
return writer.bytes()
}
// Class 0/op70, sub_1D8B560. The fixed grade table is six {u32 permissions,
// char name[24]} rows. All optional lists/flags keep their exact empty encoding.
func buildCurrentGuildManagementBody(guild *dnfguild.Guild, onlineCount int, now time.Time, viewerGrade byte) []byte {
if guild == nil {
guild = &dnfguild.Guild{}
}
var writer packetWriter
writeClientNameDstr(&writer, guild.Name)
// sub_1D8B560 reads this byte into guildManager+0x30 (01D8B5F2..01D8B5F7).
// The 公会频道设置 dialog is the only window that gates a button on it:
// 00FABC93 call [mgr+0x30] ; 00FABC98 test eax,eax ; je -> disabled, and the
// disabled tooltip is string 31583 "只有 \"%s\" 才能使用。" with the grade-1
// name from the op70 grade table. Sending 0 greyed the button for everyone,
// the master included; see currentGuildChannelSettingByte.
writer.writeByte(currentGuildChannelSettingByte(guild, viewerGrade))
writer.writeByte(0)
writer.writeUint16(uint16(min(len(guild.Members), 0xffff)))
writer.writeByte(0)
// Guild notice. sub_1D8B560 reads this DSTR into a 256-unit buffer,
// assigns it to guild+0x18 and pops it once per session through a
// template string; it is the field the notice edit box is refilled from.
// The 1024-unit DSTR after the experience u32 is the separate
// introduction slot (guild+0x20).
writeClientNameDstr(&writer, guild.Notice)
writer.writeByte(byte(guild.Level)) // actor template +844
writer.writeByte(0)
// guild+0x80 in the client: currentGuildBaseStageByte documents the probe.
writer.writeByte(currentGuildBaseStageByte(guild))
writer.writeByte(guild.Mark) // sub_26AE4F0 updates the displayed guild mark
writer.writeUint32(guild.Experience)
// Guild master, as shown in the window's 会长 field. sub_1D8B560 assigns
// this DSTR to guild+0x20 and the board (sub_00FCC5E0 at 00FCC721) pours
// guild+0x20 into that text control. Live proof: while this slot carried
// the misrouted op154 text the 会长 field rendered exactly that text, and
// the notice slot above rendered exactly its own. The next DSTR is a
// 17-unit name that sub_2690140 stores on the local actor instead, so the
// master name has to be written twice.
writeClientNameDstr(&writer, guild.MasterName)
writeClientNameDstr(&writer, guild.MasterName)
writer.writeUint16(uint16(min(onlineCount, 0xffff)))
writeClientNameDstr(&writer, guild.Promotion)
for grade := byte(0); grade < 6; grade++ {
// op748 edits these; dnfguild.GradePermissionsFor falls back to the
// fixed table this used to hardcode for grades the master never
// touched.
writer.writeUint32(dnfguild.GradePermissionsFor(guild, grade))
var name []byte
for _, r := range guild.GradeNames[grade] {
encoded := rosterNameBytes(string(r))
if len(name)+len(encoded) > 23 {
break
}
name = append(name, encoded...)
}
writeFixedBytes(&writer, name, 23)
writer.writeByte(0)
}
// 公会福利 rows. Upstream emits one row per ACTIVE guild benefit, in a fixed
// content order; this tree only ever wrote 6 (member capacity) and 21
// (contract), so a guild that bought 金库扩容 / 公会 BUFF / 公会传送次数 saw an
// empty 公会福利 panel for them even though the benefit was persisted and in
// force. Each row is {u8 type, u32 expires(unix, 0 = never), u8 mode, u32 value}.
type benefitRow struct {
typ byte
expires time.Time
mode byte
value uint32
}
rows := make([]benefitRow, 0, 8)
if guild.MemberLimit > 0 {
rows = append(rows, benefitRow{typ: 6, value: uint32(guild.MemberLimit)})
}
if contract, found := guild.Benefits[21]; found && contract.Active(now) {
rows = append(rows, benefitRow{typ: 21, expires: contract.ExpiresAt, mode: contract.Mode, value: contract.Value})
}
if guild.CargoLimit > dnfguild.DefaultCargoLimit {
// Type 4 is 公会金库扩容 and its value is the ABSOLUTE row count, which is
// what the client renders; only report it once it differs from the default.
rows = append(rows, benefitRow{typ: 4, value: uint32(guild.CargoLimit)})
}
// Keep content order stable; the native client treats each row as a separate
// active guild benefit. Types 3 and 5 are the two Guild_Status HUD rows.
for _, typ := range []byte{1, 2, 3, 5, 7} {
if benefit, found := guild.Benefits[typ]; found && benefit.Active(now) {
rows = append(rows, benefitRow{typ: typ, expires: benefit.ExpiresAt, mode: benefit.Mode, value: benefit.Value})
}
}
writer.writeByte(byte(len(rows)))
for _, row := range rows {
expires := uint32(0)
if !row.expires.IsZero() {
expires = uint32(row.expires.Unix())
}
writer.writeByte(row.typ)
writer.writeUint32(expires)
writer.writeByte(row.mode)
writer.writeUint32(row.value)
}
writer.writeUint32(guild.Mileage)
writer.writeByte(0)
writer.writeUint32(0)
writer.writeByte(0)
writer.writeUint32(guild.RecommendedRegion)
writer.writeUint32(guild.RecommendedChannel)
writer.writeUint32(guild.AuraMark)
writer.writeUint32(guild.AuraFrame)
writer.writeUint16(0)
writer.writeByte(boolByte(guild.AllianceRecruitment))
writer.writeByte(0)
tags := currentGuildTagsMask(guild)
writer.writeUint32(tags)
writer.writeByte(guild.JoinMode)
return writer.bytes()
}
// buildCurrentGuildApplicationListBody projects op352's applicant rows.
// sub_1D30B10 reads a u32 count and then, per row: u32 character id,
// DSTR name, u8, u8, u8, u8, DSTR message, u32, u8, u8. The four bytes between
// the name and the message are a character card, and the client maps them by
// position (record base = ebp-0x86C in the parser, fields at record+0x24..):
//
// - read 1 (01D30C0A -> record+0x24, 01D30D10): the job byte. The list's row
// builder loads record+0x24 and record+0x25 and hands both to the class
// call at 03197BFB (sub_249D7F0), so a level in this slot renders the
// applicant with another class's avatar.
//
// - read 2 (01D30C18 -> sub_3A51780 at 01D30C8D, split into record+0x25 grow
// and record+0x26 advancement): the packed advancement byte, the same value
// the member row carries (member.GrowType); sending 0 rendered every
// applicant as their base class.
//
// - read 3 (01D30C26 -> record+0x27): the level. The applicant window builds
// the hover bubble from exactly this byte:
// 03197DAF movzx ecx,byte [eax+0x27] ; 03197DB4 push 0x42AA1F4 ('Lv.%d').
// Before this fix the level sat in read 1 and read 3 carried 0, which is why
// the bubble read "Lv.0" and the card showed the wrong class.
//
// - read 4 (01D30C34 -> record+0x28): not mapped yet and stays 0.
//
// - the last read (01D30C68) lands at record+0x54 (01D30C94), and that is
// the field the approve/deny request echoes back: the sender at 03198789
// pushes [esi] as the character id and [esi+0x54] as the second field.
// decideCurrentGuildApplication rejects any request whose region byte is
// not channelAdvertiseID(), so sending 0 here made every 通过/拒绝 fail
// with the generic error -- confirmed by the captured op350 body
// 2b000000 00 (character 43, region 0). Read 5 (01D30C5A) becomes the
// boolean at record+0x50.
func buildCurrentGuildApplicationListBody(applications []dnfguild.Application) []byte {
var writer packetWriter
writer.writeUint32(uint32(len(applications)))
for _, application := range applications {
writer.writeUint32(currentGuildNumericID(application.CharacterID))
writeClientNameDstr(&writer, application.Name)
writer.writeByte(byte(numericCharacterStat(application.Job)))
writer.writeByte(byte(numericCharacterStat(application.GrowType)))
writer.writeByte(byte(application.Level))
writer.writeByte(0)
writeClientNameDstr(&writer, application.Message)
writer.writeUint32(uint32(application.CreatedAt.Unix()))
writer.writeByte(0)
writer.writeByte(currentGuildMemberRegion)
}
return writer.bytes()
}
// currentGuildMileageHistoryBody builds class1/op747's PAYLOAD, i.e. what
// follows the success byte the transport already writes: the client's handler
// 0x01D11310 reads a u32 row count and then copies that many rows verbatim as
// 48-byte structs (call 0x34E3AC0 with size 0x30 at 01D11380).
//
// Two live failures shaped this. (1) The handler used to answer with the bare
// success byte, so the client read the count out of invalid memory and memcpyed
// 48 bytes per bogus row: 0xC0000005 eip 0x03B10747, eax=0x098B101F,
// access_address=0x098B1000 at 2026-09-12 00:44:36, the moment 公会积分 was
// clicked. (2) Writing the success byte here as well shifted the count by one
// byte -- the window then rendered one row of leftover memory (a zero-filled name
// column, "getStr(20952)==none+8" in the content column, 1995.08.15 for the
// date) -- so this body must start at the count.
//
// The row struct is partly mapped from a live probe (2026-09-12, switch
// guild_mileage_rows.txt=3) plus the client's own initialiser. Probe rows carried
// character id 1000+i, name "P<i>", amount 111+i and time 1700000000+i, and the
// window showed:
//
// 角色名 P1 P2 P3 -> +0x04 is the fixed name buffer
// 日期/时间 1970.01.01 08:16:40+i -> +0x00 is the unix timestamp, and 1000
// seconds lands on 1970-01-01 08:16:40 in
// this client's local (UTC+8) rendering
// 内容 公会成员每日签到 + 1700000000 -> +0x28 is the points figure
//
// The content column also resolves a reason text (the string block 20991..20999:
// 20991 拒绝占领战申请, 20992 使用积分累计道具, 20993 占领战申请, 20994 公会擂台战,
// 20995 公会内容, 20996 金币捐赠, 20997 通关公会推荐地下城, 20998 公会成员每日签到,
// 20999 加入公会) through the 0x30-byte row, which is what the garbage row exposed
// when it printed the DLL's fallback "getStr(20952)==none" debug text.
//
// +0x00 u32 unix seconds
// +0x04 28-byte fixed character-name buffer (GBK, NUL padded)
// +0x20 u16 0 (unmapped)
// +0x22 u8 reason -- the client turns this byte into a text id as
// id = 21000 - byte, pinned twice live: byte 0 rendered the text of 21000
// and byte 0x0B rendered 20989 公会会长每日签到, which is why every row
// (member rows included) showed the master's wording until the pick below
// started sending 2 (21000 - 2 = 20998 公会成员每日签到) for non-masters.
// +0x24 u32 character id
// +0x28 u32 points
// +0x2C u8 0
// +0x2D .. 0x2F padding
//
// Rows are emitted for the stored history by default; the mode switch is only a
// safety valve and a way to map the remaining reason values.
func currentGuildMileageHistoryBody(rows [][]byte) []byte {
valid := make([][]byte, 0, len(rows))
for _, row := range rows {
if len(row) == currentGuildMileageRowSize {
valid = append(valid, row)
}
}
var writer packetWriter
writer.writeUint32(uint32(len(valid)))
for _, row := range valid {
writer.writeBytes(row)
}
return writer.bytes()
}
const currentGuildMileageRowSize = 0x30
// currentGuildMileageRowKind is the 会长签到 reason byte. The client maps the row
// byte to a text id as id = 21000 - byte, which the live captures pinned twice:
// byte 0 rendered the text of 21000 and byte 0x0B rendered 20989
// (公会会长每日签到), so the master's own attendance row carries 0x0B.
const currentGuildMileageRowKind = byte(0x0B)
// currentGuildMileageMemberReason is the 公会成员每日签到 reason byte for every
// member that is not the guild master: 21000 - 2 = 20998.
const currentGuildMileageMemberReason = byte(2)
// currentGuildMileageReasonByte picks the reason row byte for one history row.
// A row that recorded its own reason keeps it (公会聊天 10 / 同公会成员组队 12);
// the older 签到 rows carry zero and fall back per role.
func currentGuildMileageReasonByte(guild *dnfguild.Guild, entry dnfguild.MileageHistory) byte {
if entry.Reason != 0 {
return entry.Reason
}
if guild != nil && guild.MasterCharacterID != "" && entry.CharacterID == guild.MasterCharacterID {
return currentGuildMileageRowKind
}
return currentGuildMileageMemberReason
}
func currentGuildMileageRow(characterID uint32, name string, reason byte, amount, unixSeconds uint32) []byte {
row := make([]byte, currentGuildMileageRowSize)
binary.LittleEndian.PutUint32(row[0x00:], unixSeconds)
encoded := rosterNameBytes(name)
if len(encoded) > 27 {
encoded = encoded[:27]
}
copy(row[0x04:0x20], encoded)
row[0x22] = reason
binary.LittleEndian.PutUint32(row[0x24:], characterID)
binary.LittleEndian.PutUint32(row[0x28:], amount)
return row
}
func buildCurrentGuildJoinCheckBody(guild *dnfguild.Guild) []byte {
var writer packetWriter
if guild == nil {
writer.writeRawDstr(nil)
writer.writeRawDstr(nil)
writer.writeUint32(0)
writer.writeUint32(0)
writer.writeByte(0)
return writer.bytes()
}
writeClientNameDstr(&writer, guild.Name)
writeClientNameDstr(&writer, guild.MasterName)
writer.writeUint32(uint32(len(guild.Members)))
writer.writeUint32(uint32(guild.MemberLimit))
writer.writeByte(guild.JoinMode)
return writer.bytes()
}
func buildCurrentGuildApplyBody(guild *dnfguild.Guild) []byte {
var writer packetWriter
writer.writeUint32(guild.ID)
writeClientNameDstr(&writer, guild.Name)
writeClientNameDstr(&writer, guild.MasterName)
writer.writeUint32(uint32(len(guild.Members)))
writer.writeByte(guild.JoinMode)
return writer.bytes()
}
// buildCurrentGuildAttendanceDetailBody projects 今日签到成员 (op746).
//
// sub_1D04F90 (registered for 746 at 01D372AD) reads a u16 row count and then,
// per row, one length-prefixed client name through sub_3457D20 with a 0x80
// buffer, appending each name to the guild manager (sub_26AA5A0). The previous
// u32 zero happened to be safe -- its first two bytes are the u16 zero, so the
// loop never ran -- but it could never report a signed-in member either, which is
// what 今日签到成员 (%d/%d) displays.
func buildCurrentGuildAttendanceDetailBody(guild *dnfguild.Guild, now time.Time) []byte {
var writer packetWriter
names := currentGuildAttendanceNames(guild, now)
if len(names) > 0xffff {
names = names[:0xffff]
}
writer.writeUint16(uint16(len(names)))
for _, name := range names {
writeClientNameDstr(&writer, name)
}
return writer.bytes()
}
// currentGuildAttendanceNames lists the members whose AttendedAt falls on the
// same local day as now, the set Registry.Attend stamps once per day.
func currentGuildAttendanceNames(guild *dnfguild.Guild, now time.Time) []string {
if guild == nil {
return nil
}
year, month, day := now.In(now.Location()).Date()
names := make([]string, 0, len(guild.Members))
for _, member := range dnfguild.SortedMembers(guild) {
if member.AttendedAt.IsZero() {
continue
}
attendedYear, attendedMonth, attendedDay := member.AttendedAt.In(now.Location()).Date()
if attendedYear != year || attendedMonth != month || attendedDay != day {
continue
}
names = append(names, member.Name)
}
return names
}
// buildCurrentGuildRefreshInfoBody follows NoPack sub_1D0ECD0. The parser
// consumes two u8 state flags, one u32 value and one trailing u8 flag. The
// first flag asks the client to immediately issue another op339 when set, so
// the stable projection must keep it clear. The remaining optional systems
// are not represented by the local guild aggregate and are sent as their
// exact empty states rather than fabricated values.
func buildCurrentGuildRefreshInfoBody() []byte {
var writer packetWriter
writer.writeByte(0)
writer.writeByte(0)
writer.writeUint32(0)
writer.writeByte(0)
return writer.bytes()
}
// buildCurrentGuildBoardEmptyBody follows NoPack sub_1D0EE50's exact empty
// branch: the first u8 selects the normal board result and the second u8 says
// that no board record is present.
func buildCurrentGuildBoardEmptyBody() []byte {
return []byte{0, 0}
}
// buildCurrentGuildAttendanceBody follows the empty lists accepted by NoPack
// sub_1D34610. Both list counts are read unconditionally by the client.
func buildCurrentGuildAttendanceBody() []byte {
return []byte{0, 0}
}
func buildCurrentGuildInfoBody(characterID uint32, guild *dnfguild.Guild) []byte {
var writer packetWriter
writer.writeUint32(characterID)
if guild == nil {
writer.writeRawDstr(nil)
} else {
writeClientNameDstr(&writer, guild.Name)
}
return writer.bytes()
}
// buildCurrentGuildLegacyGradeBody follows NoPack sub_1D22770. The first
// status byte is consumed for notification formatting, then the client uses
// the member selector, character name and target grade to update its model.
func buildCurrentGuildLegacyGradeBody(memberSelector byte, name string, grade byte) []byte {
var writer packetWriter
writer.writeByte(0)
writer.writeByte(memberSelector)
writeClientNameDstr(&writer, name)
writer.writeByte(grade)
return writer.bytes()
}
// buildCurrentGuildGradeBody projects the member roster op1454/op1455 carry.
// NoPack sub_1D259E0 (op1454 tail-jumps into it from sub_1D268D0) consumes the
// guild id, an op1454-only u16, a u16 row count, then {u8 region, DSTR name,
// u8} rows, each becoming a 0x30-byte record in the global container at
// 0x517970C.
//
// The DSTR is a *member character name*, not a grade name. sub_FBE4D0 walks
// the guild member records (sub_26A3810 counts them at stride 0x184) and joins
// each one against these rows on (row string == record name, row+4 ==
// record+0x182) before copying record+0x31 into row+0x2C, and sub_FBE700 looks
// a row up by that same (name, region) pair. This used to send the four grade
// names with the grade number in the region byte, so the member settings list
// rendered 会长/副会长/优秀会员/普通会员 as though they were four members and
// no row ever joined a real record -- the grade shown per row comes from the
// joined record, which is why every fake row collapsed into one heading.
//
// firstPage distinguishes the two opcodes. The op1454-only u16 is stored at
// 0x51796EC by sub_FBD600, which then clears the container (sub_FBCFA0), so
// op1454 is the first page and op1455 appends to it: that u16 is the roster
// total, and op1455 must answer with an empty page or every member would be
// listed twice.
//
// row+0x28 is the member's grade. sub_FBE280 is the tab filter and copies a
// row into the visible list only when the selected tab matches it: tab 1 wants
// 2, tab 2 wants 3, and tab 3 wants 4 or 0 (00FBE359, 00FBE364, 00FBE36F).
// Sending 0 for everyone therefore piled the whole roster under 普通会员.
func buildCurrentGuildGradeBody(guild *dnfguild.Guild, firstPage bool) []byte {
var writer packetWriter
if guild == nil {
writer.writeUint32(0)
if firstPage {
writer.writeUint16(0)
}
writer.writeUint16(0)
return writer.bytes()
}
members := dnfguild.SortedMembers(guild)
if len(members) > 0xffff {
members = members[:0xffff]
}
writer.writeUint32(guild.ID)
if !firstPage {
// Everything went out with op1454; terminate the paging.
writer.writeUint16(0)
return writer.bytes()
}
writer.writeUint16(uint16(len(members)))
writer.writeUint16(uint16(len(members)))
for _, member := range members {
// Must equal the region byte the member row carries at member+0x182.
writer.writeByte(currentGuildMemberRegion)
writeClientNameDstr(&writer, member.Name)
writer.writeByte(member.Grade)
}
return writer.bytes()
}