服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
740 lines
26 KiB
Go
740 lines
26 KiB
Go
package mail
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
MailboxListNotificationMessageID uint16 = 0x0061
|
|
mailboxListNotificationMessageID = MailboxListNotificationMessageID
|
|
mailboxRemoveNotificationMessageID uint16 = 0x0062
|
|
MailboxAlarmNotificationMessageID uint16 = 0x0063
|
|
// Current NoPack.exe registers class0/op718 beside mailbox op97/op98/op99
|
|
// notifications as the response that fills IDC_TREE_MYCHARACTER after
|
|
// class1/op789 requests a server node.
|
|
mailboxRecipientListMessageID uint16 = 718
|
|
mailboxRecipientListNameMaxBytes = 29
|
|
mailboxItemRawSize = 0x77
|
|
// The two avatar extension blocks the current client reads after the fixed
|
|
// item row target a 30-byte jewel socket and a single colour DWORD, the same
|
|
// pair the list-1 item-list rows already carry.
|
|
mailboxAvatarSocketBytes = 30
|
|
mailboxAvatarColorBytes = 4
|
|
)
|
|
|
|
// BuildListNotification creates the complete clear-first class0/op97 mailbox
|
|
// snapshot used by an open mailbox and by trusted live GM deliveries.
|
|
func BuildListNotification(result OpenResult) ([]byte, error) {
|
|
return buildMailboxListNotification(result.Mails, result.NotLoaded, result.ObservedAt)
|
|
}
|
|
|
|
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
|
|
return alignedcmd.UpperResponse{
|
|
MsgID: opcode,
|
|
Body: append([]byte(nil), body...),
|
|
Classification: dnfproto.DefaultChannelClassification,
|
|
AllowCodec: true,
|
|
}
|
|
}
|
|
|
|
func class0Response(messageID uint16, body []byte) alignedcmd.UpperResponse {
|
|
return alignedcmd.UpperResponse{
|
|
MsgID: messageID,
|
|
Body: append([]byte(nil), body...),
|
|
Classification: 0,
|
|
AllowCodec: true,
|
|
}
|
|
}
|
|
|
|
func buildMailboxOpenAck(total int) []byte {
|
|
if total < 0 {
|
|
total = 0
|
|
}
|
|
if total > 0xffff {
|
|
total = 0xffff
|
|
}
|
|
body := []byte{0x01, 0x00, 0x00}
|
|
binary.LittleEndian.PutUint16(body[1:], uint16(total))
|
|
return body
|
|
}
|
|
|
|
func buildSuccessAck() []byte {
|
|
return []byte{0x01}
|
|
}
|
|
|
|
// buildExtractAck writes the op95 acknowledgement: success flag, u32 result
|
|
// count, then three u32 per result.
|
|
//
|
|
// sub_1D225F0 collects those triples and hands them to sub_27156D0, which reads
|
|
// them as (letter id, claim object, mode):
|
|
//
|
|
// - the first dword is the container key, so it must be the letter id.
|
|
// - the second dword is passed to sub_2708220(entry, claimObject), which scans
|
|
// the entry's claim-object vector at +0x70 and returns the paired item object
|
|
// at +0x84. It has to be the same claim object the summary row gave that
|
|
// slot; with the letter id in both places a multi-attachment letter only ever
|
|
// retires its first slot, and a zero here never matches at all.
|
|
// - the third dword's low word must stay zero; a non-zero value diverts to
|
|
// sub_27154E0 instead of the normal extraction path.
|
|
//
|
|
// A key or claim object that does not resolve is harmless - the applier just
|
|
// advances to the next triple - so a stale row can never fault the client.
|
|
func buildExtractAck(claimed []ClaimedAttachment) []byte {
|
|
body := make([]byte, 1+4+len(claimed)*12)
|
|
body[0] = 0x01
|
|
binary.LittleEndian.PutUint32(body[1:5], uint32(len(claimed)))
|
|
offset := 5
|
|
for _, row := range claimed {
|
|
binary.LittleEndian.PutUint32(body[offset:offset+4], row.LetterID)
|
|
binary.LittleEndian.PutUint32(body[offset+4:offset+8], row.ClaimObject)
|
|
offset += 12
|
|
}
|
|
return body
|
|
}
|
|
|
|
func buildChangeStateAck(mailIDs []uint32, status uint16) []byte {
|
|
body := make([]byte, 1+4+len(mailIDs)*6)
|
|
body[0] = 0x01
|
|
binary.LittleEndian.PutUint32(body[1:5], uint32(len(mailIDs)))
|
|
offset := 5
|
|
for _, mailID := range mailIDs {
|
|
binary.LittleEndian.PutUint32(body[offset:offset+4], mailID)
|
|
binary.LittleEndian.PutUint16(body[offset+4:offset+6], status)
|
|
offset += 6
|
|
}
|
|
return body
|
|
}
|
|
|
|
func buildQueryCharacterAck(nameRaw []byte, grow byte, level uint16, job byte) []byte {
|
|
body := make([]byte, 0, 1+4+len(nameRaw)+8)
|
|
body = append(body, 0x01)
|
|
var scratch [4]byte
|
|
binary.LittleEndian.PutUint32(scratch[:], uint32(len(nameRaw)))
|
|
body = append(body, scratch[:]...)
|
|
body = append(body, nameRaw...)
|
|
body = append(body, grow)
|
|
var levelBytes [2]byte
|
|
binary.LittleEndian.PutUint16(levelBytes[:], level)
|
|
body = append(body, levelBytes[:]...)
|
|
// sub_1D0BAD0 reads job plus four trailing state bytes after level. The
|
|
// 1 in the third state field matches the handler's initialized normal state.
|
|
body = append(body, job, 0x00, 0x01, 0x00, 0x00)
|
|
return body
|
|
}
|
|
|
|
func buildRecipientCharacterListBody(serverID byte, characters []dnfrepo.CharacterRecord, selectedCharacterID uint16) ([]byte, error) {
|
|
writer := mailboxBodyWriter{}
|
|
writer.u8(serverID)
|
|
writer.u8(0)
|
|
count := 0
|
|
for _, character := range characters {
|
|
if count == math.MaxUint8 {
|
|
break
|
|
}
|
|
characterID, err := strconv.ParseUint(strings.TrimSpace(character.CharacterID), 10, 32)
|
|
if err != nil || characterID == 0 {
|
|
return nil, fmt.Errorf("%w: invalid mailbox account-role character id %q", ErrMailboxInvalidRequest, character.CharacterID)
|
|
}
|
|
if uint32(characterID) == uint32(selectedCharacterID) {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(character.Name)
|
|
if name == "" {
|
|
return nil, fmt.Errorf("%w: mailbox account-role character %d has no name", ErrMailboxInvalidRequest, characterID)
|
|
}
|
|
nameRaw, err := encodeMailboxText(name, mailboxRecipientListNameMaxBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: mailbox account-role name %q: %v", ErrMailboxInvalidRequest, name, err)
|
|
}
|
|
writer.u32(uint32(characterID))
|
|
writer.u32(uint32(len(nameRaw)))
|
|
writer.raw(nameRaw)
|
|
writer.u8(0)
|
|
count++
|
|
}
|
|
writer.data[1] = byte(count)
|
|
return writer.bytes(), nil
|
|
}
|
|
|
|
func buildErrorAck(code byte) []byte {
|
|
if code == 0 {
|
|
code = 1
|
|
}
|
|
return []byte{0x00, code}
|
|
}
|
|
|
|
// buildMailboxListNotification follows the current client's
|
|
// sub_1D913C0 reader exactly: attachment/gold summaries, the deferred-page
|
|
// count, then the detailed text/status rows. The common 0x77 item row is
|
|
// deliberately retained as a bounded opaque client item row; its front fields
|
|
// are projected separately because the client reads both representations.
|
|
func buildMailboxListNotification(mails []dnfrepo.MailRecord, notLoaded int, now time.Time) ([]byte, error) {
|
|
if len(mails) > math.MaxUint16 {
|
|
return nil, fmt.Errorf("%w: mailbox page has %d mails", ErrMailboxInvalidRequest, len(mails))
|
|
}
|
|
|
|
summaries := make([]mailboxSummary, 0, len(mails))
|
|
for _, durableMail := range mails {
|
|
mail := mailboxPresentationMail(durableMail)
|
|
mailID, err := mailboxWireID(mail.MailID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
wrote := false
|
|
for index, attachment := range mail.Attachments {
|
|
// An empty slot is one that has already been handed over. Skip it
|
|
// instead of rejecting the page: one unusable row must never cost the
|
|
// player the whole mailbox, which is exactly what happened when such
|
|
// rows reached this builder and every op96 answered
|
|
// "mailbox snapshot unavailable".
|
|
if attachment.ItemID <= 0 || attachment.Count <= 0 {
|
|
continue
|
|
}
|
|
if len(summaries) >= math.MaxUint8 {
|
|
return nil, fmt.Errorf("%w: mailbox page has more than %d attachment summaries", ErrMailboxInvalidRequest, math.MaxUint8)
|
|
}
|
|
summaries = append(summaries, mailboxSummary{
|
|
mail: mail,
|
|
mailID: mailID,
|
|
claimObject: mailboxSlotClaimObject(mailID, index),
|
|
attachment: &attachment,
|
|
includeGold: !wrote,
|
|
// A wallet-currency voucher is money the client has to print:
|
|
// its cell's amount comes from this slot, not from the item row's
|
|
// count (see mailboxAttachmentWireAmount).
|
|
amount: mailboxAttachmentWireAmount(attachment),
|
|
})
|
|
wrote = true
|
|
}
|
|
if !wrote && mail.Gold > 0 {
|
|
if len(summaries) >= math.MaxUint8 {
|
|
return nil, fmt.Errorf("%w: mailbox page has more than %d summaries", ErrMailboxInvalidRequest, math.MaxUint8)
|
|
}
|
|
summaries = append(summaries, mailboxSummary{mail: mail, mailID: mailID, claimObject: mailID, includeGold: true})
|
|
wrote = true
|
|
}
|
|
// The client keeps the current message identity from the summary pass.
|
|
// A text-only mail therefore needs one intentionally empty seed record.
|
|
if !wrote {
|
|
if len(summaries) >= math.MaxUint8 {
|
|
return nil, fmt.Errorf("%w: mailbox page has more than %d summaries", ErrMailboxInvalidRequest, math.MaxUint8)
|
|
}
|
|
summaries = append(summaries, mailboxSummary{mail: mail, mailID: mailID, claimObject: mailID, seedOnly: true})
|
|
}
|
|
}
|
|
|
|
writer := mailboxBodyWriter{}
|
|
writer.u8(byte(len(summaries)))
|
|
// Zero makes sub_1D913C0 clear both current mail containers before this
|
|
// complete snapshot; subsequent page batches would use one here.
|
|
writer.u8(0)
|
|
for _, summary := range summaries {
|
|
if err := writer.writeSummary(summary, now); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
writer.u16(clampMailboxU16(notLoaded))
|
|
writer.u16(uint16(len(mails)))
|
|
for _, durableMail := range mails {
|
|
mail := mailboxPresentationMail(durableMail)
|
|
if err := writer.writeDetail(mail); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return writer.bytes(), nil
|
|
}
|
|
|
|
// mailboxPresentationMail projects old full-inventory system mail with the
|
|
// current Chinese copy. Claimed mail is always projected as an empty, read
|
|
// receipt; this also protects the UI while an older durable row is being
|
|
// repaired by Owner.Open.
|
|
func mailboxPresentationMail(mail dnfrepo.MailRecord) dnfrepo.MailRecord {
|
|
switch strings.TrimSpace(mail.Metadata["source"]) {
|
|
case "magic_box_reward_inventory_full":
|
|
mail.SenderName = "系统"
|
|
mail.Title = "背包已满:礼盒奖励"
|
|
mail.Body = "背包空间不足,礼盒奖励已通过邮件发送。请清理对应道具分页后领取。"
|
|
case "dungeon_card_reward_inventory_full":
|
|
mail.SenderName = "系统"
|
|
mail.Title = "背包已满:通关奖励"
|
|
mail.Body = "背包空间不足,通关奖励已通过邮件发送。请清理对应道具分页后领取。"
|
|
}
|
|
if mail.Claimed {
|
|
mail.Gold = 0
|
|
mail.Attachments = nil
|
|
}
|
|
return mail
|
|
}
|
|
|
|
func buildMailboxRemoveNotification(mailIDs []uint32) []byte {
|
|
writer := mailboxBodyWriter{}
|
|
writer.u32(uint32(len(mailIDs)))
|
|
for _, mailID := range mailIDs {
|
|
writer.u32(mailID)
|
|
}
|
|
return writer.bytes()
|
|
}
|
|
|
|
// buildMailboxAlarmNotification is the exact one-WORD class0/0x63 body. It
|
|
// is kept here for the session directory path, which may notify an online
|
|
// recipient independently from the sender's command response.
|
|
func buildMailboxAlarmNotification(count int) []byte {
|
|
writer := mailboxBodyWriter{}
|
|
writer.u16(clampMailboxU16(count))
|
|
return writer.bytes()
|
|
}
|
|
|
|
// BuildAlarmNotification produces the current class0/0x63 one-WORD payload
|
|
// used when a recipient already has an active game session.
|
|
func BuildAlarmNotification(count int) []byte {
|
|
return buildMailboxAlarmNotification(count)
|
|
}
|
|
|
|
type mailboxSummary struct {
|
|
mail dnfrepo.MailRecord
|
|
mailID uint32
|
|
claimObject uint32
|
|
attachment *dnfrepo.MailAttachment
|
|
includeGold bool
|
|
// amount is the money this one attachment cell is worth, for the attachment
|
|
// kinds the client renders as a number. The letter's own gold wins when this
|
|
// row carries it (includeGold).
|
|
amount uint32
|
|
seedOnly bool
|
|
}
|
|
|
|
// mailboxAttachmentWireAmount is the amount the client prints on one attachment
|
|
// cell. The current op0x61 reader takes it from the summary's gold slot, NOT
|
|
// from the item row's count:
|
|
//
|
|
// FUN_034e3a50(&gold); ; the summary's amount slot
|
|
// FUN_034e3a50(&itemID);
|
|
// if (itemID - 0x28eb7dU < 9) gold = (itemID - 0x28eb7c) * 1000000; ; gold_1m..gold_9m
|
|
// else if (itemID - 0x28eb86U < 3) gold = (itemID - 0x28eb85) * 10000000; ; gold_10m..30m
|
|
// ...
|
|
// FUN_0270bfb0(0, claim, .., gold, ..); ; that value becomes the row's amount
|
|
//
|
|
// so a gold certificate is priced by the client itself from its item id, while
|
|
// every other cell shows whatever this slot carried. A 代币券 attachment is money
|
|
// too - it is credited to the account wallet on claim - and with a zero here the
|
|
// seller's sale letter showed an icon and "0" next to it.
|
|
func mailboxAttachmentWireAmount(attachment dnfrepo.MailAttachment) uint32 {
|
|
// VoucherKindOf is the same predicate the claim path uses to route these
|
|
// attachments to the account wallet, so presentation and payment can never
|
|
// disagree about which items are money.
|
|
if dnfcerashop.VoucherKindOf(attachment.ItemID) == dnfcerashop.VoucherNone {
|
|
return 0
|
|
}
|
|
if attachment.Count <= 0 || attachment.Count > math.MaxUint32 {
|
|
return 0
|
|
}
|
|
return uint32(attachment.Count)
|
|
}
|
|
|
|
// mailboxMaxAttachmentsPerMail is 86JP's per-letter attachment cap, so four bits
|
|
// address every slot of a letter.
|
|
const mailboxMaxAttachmentsPerMail = 16
|
|
|
|
// MailboxAttachmentSlotFlag marks a claim object that addresses one attachment
|
|
// slot instead of the whole letter.
|
|
//
|
|
// 86JP uses 0x40000000 (MailboxRepository.AttachmentClaimFlag) and documents the
|
|
// contract this depends on: the client merges summary rows by the letter id near
|
|
// the end of the row and gives every distinct leading dword its own attachment
|
|
// slot. Writing the letter id in both fields is what collapses a letter into one
|
|
// visible item. The flag itself is moved below the reference's value because the
|
|
// client faulted in its op95 handler while ids above 0x40000000 were on the wire;
|
|
// real mail ids stay far below this range, so an encoded slot can never be
|
|
// mistaken for a letter.
|
|
const MailboxAttachmentSlotFlag uint32 = 0x10000000
|
|
|
|
// MailboxAttachmentClaimObject encodes one attachment slot: the flag, the letter
|
|
// id, then the slot index in the low four bits.
|
|
func MailboxAttachmentClaimObject(mailID uint32, index int) (uint32, error) {
|
|
if index < 0 || index >= mailboxMaxAttachmentsPerMail {
|
|
return 0, fmt.Errorf("%w: attachment index %d is outside 0..%d", ErrMailboxInvalidRequest, index, mailboxMaxAttachmentsPerMail-1)
|
|
}
|
|
if mailID == 0 || mailID > (MailboxAttachmentSlotFlag-1)/mailboxMaxAttachmentsPerMail {
|
|
return 0, fmt.Errorf("%w: mail id %d cannot carry an attachment claim object", ErrMailboxInvalidRequest, mailID)
|
|
}
|
|
return MailboxAttachmentSlotFlag | (mailID * mailboxMaxAttachmentsPerMail) | uint32(index), nil
|
|
}
|
|
|
|
// mailboxSlotClaimObject is the addressing used on the wire. A slot that cannot
|
|
// be encoded - past the sixteenth attachment, or a letter id too large to carry
|
|
// an index - falls back to the letter id instead of failing. Such a row then
|
|
// merges into the letter's first slot exactly as it did before slots existed,
|
|
// which costs that one row its own icon; failing here would cost the player the
|
|
// whole mailbox page.
|
|
func mailboxSlotClaimObject(mailID uint32, index int) uint32 {
|
|
claimObject, err := MailboxAttachmentClaimObject(mailID, index)
|
|
if err != nil {
|
|
return mailID
|
|
}
|
|
return claimObject
|
|
}
|
|
|
|
// DecodeMailboxClaimObject splits one op95 entry. A value below the flag still
|
|
// addresses the whole letter, which is what the client sends for a gold-only or
|
|
// text-only row and what every older client build sends.
|
|
func DecodeMailboxClaimObject(claimObject uint32) (mailID uint32, attachmentIndex int, singleSlot bool) {
|
|
if claimObject < MailboxAttachmentSlotFlag {
|
|
return claimObject, 0, false
|
|
}
|
|
value := claimObject &^ MailboxAttachmentSlotFlag
|
|
return value / mailboxMaxAttachmentsPerMail, int(value % mailboxMaxAttachmentsPerMail), true
|
|
}
|
|
|
|
type mailboxBodyWriter struct{ data []byte }
|
|
|
|
func (w *mailboxBodyWriter) u8(value byte) { w.data = append(w.data, value) }
|
|
|
|
func (w *mailboxBodyWriter) u16(value uint16) {
|
|
var encoded [2]byte
|
|
binary.LittleEndian.PutUint16(encoded[:], value)
|
|
w.data = append(w.data, encoded[:]...)
|
|
}
|
|
|
|
func (w *mailboxBodyWriter) u32(value uint32) {
|
|
var encoded [4]byte
|
|
binary.LittleEndian.PutUint32(encoded[:], value)
|
|
w.data = append(w.data, encoded[:]...)
|
|
}
|
|
|
|
func (w *mailboxBodyWriter) raw(value []byte) { w.data = append(w.data, value...) }
|
|
|
|
// optionalBlob writes the current client's `u32 length + payload` extension
|
|
// block (sub_3457C50: one sub_34E3A50 length followed by a sub_34E3AC0 read of
|
|
// exactly that many bytes). An absent payload is written as a zero length, which
|
|
// leaves the client's freshly constructed target object at its defaults.
|
|
func (w *mailboxBodyWriter) optionalBlob(data []byte) {
|
|
if len(data) == 0 || len(data) > math.MaxUint32 {
|
|
w.u32(0)
|
|
return
|
|
}
|
|
w.u32(uint32(len(data)))
|
|
w.raw(data)
|
|
}
|
|
|
|
func (w *mailboxBodyWriter) dstring(value string, maxBytes int) error {
|
|
raw, err := encodeMailboxText(value, maxBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.u32(uint32(len(raw)))
|
|
w.raw(raw)
|
|
return nil
|
|
}
|
|
|
|
func (w *mailboxBodyWriter) writeSummary(summary mailboxSummary, now time.Time) error {
|
|
senderID, err := mailboxSenderWireID(summary.mail.SenderCharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// First dword: the claim object, which must be distinct per attachment.
|
|
//
|
|
// The client keys mailbox entries by the letter id at the end of this row and
|
|
// merges same-letter rows through sub_2709990. That merge keeps two parallel
|
|
// vectors on the entry - claim objects at +0x70 and their item objects at
|
|
// +0x84 - and appends the pair only after scanning +0x70 for the incoming
|
|
// claim object: on a hit it aborts the whole merge (`xor al,al; ret`). Writing
|
|
// the letter id into every row of a letter therefore collapses it to a single
|
|
// visible attachment, which is exactly the "one item per mail" defect.
|
|
w.u32(summary.claimObject)
|
|
w.u32(senderID)
|
|
if err := w.dstring(summary.mail.SenderName, maxMailboxRecipientBytes); err != nil {
|
|
return fmt.Errorf("sender name: %w", err)
|
|
}
|
|
// This slot is the cell's amount as far as the client is concerned - it
|
|
// prints it beside the cell's icon and never reads the item row's count for
|
|
// it (see mailboxAttachmentWireAmount). A row that owns the letter's gold
|
|
// shows that gold; every other row shows its own amount, and only the
|
|
// wallet-currency vouchers have one.
|
|
amount := summary.amount
|
|
if summary.includeGold {
|
|
if summary.mail.Gold < 0 || summary.mail.Gold > math.MaxUint32 {
|
|
return fmt.Errorf("%w: mail=%d gold=%d", ErrMailboxInvalidRequest, summary.mailID, summary.mail.Gold)
|
|
}
|
|
if summary.mail.Gold > 0 {
|
|
amount = uint32(summary.mail.Gold)
|
|
}
|
|
}
|
|
w.u32(amount)
|
|
|
|
if summary.attachment == nil {
|
|
w.u32(0)
|
|
w.u8(0)
|
|
w.u32(0)
|
|
w.u16(0)
|
|
w.u8(0)
|
|
w.u32(0)
|
|
w.u8(0)
|
|
w.u8(0)
|
|
w.u16(0)
|
|
w.raw(make([]byte, mailboxItemRawSize))
|
|
} else {
|
|
attachment := *summary.attachment
|
|
if attachment.ItemID > math.MaxUint32 || attachment.Count > math.MaxUint32 {
|
|
return fmt.Errorf("%w: attachment item/count outside wire range", ErrMailboxInvalidRequest)
|
|
}
|
|
raw := mailboxAttachmentRaw(attachment, now)
|
|
w.u32(uint32(attachment.ItemID))
|
|
w.u8(1)
|
|
w.u32(binary.LittleEndian.Uint32(raw[6:10]))
|
|
w.u16(binary.LittleEndian.Uint16(raw[0x0b:0x0d]))
|
|
w.u8(raw[0x0d])
|
|
w.u32(binary.LittleEndian.Uint32(raw[0x0e:0x12]))
|
|
w.u8(raw[0x12])
|
|
w.u8(raw[0x13])
|
|
w.u16(binary.LittleEndian.Uint16(raw[0x14:0x16]))
|
|
if mailboxAttachmentRequiresClass26(attachment) {
|
|
// The current type-26 branch consumes this DWORD and its detail flag
|
|
// before the fixed item row. We have no durable creature-detail UOW,
|
|
// so the explicit zero flag requests only the proven generic item
|
|
// projection instead of fabricating the three detail dwords.
|
|
w.u32(0)
|
|
w.u8(0)
|
|
}
|
|
w.raw(raw)
|
|
if mailboxAttachmentRequiresAvatarBlobs(attachment) {
|
|
// Current sub_1D913C0 keeps reading after the fixed 0x77 row when
|
|
// the attachment's item-table type is 0..11: sub_229C570 gates the
|
|
// avatar branch on that range and calls sub_3457C50 for the jewel
|
|
// socket, then sub_225C9B0(.., itemID, 3) re-tests the same range
|
|
// through sub_229ACD0 and reads the colour block. Both are the
|
|
// `u32 length + payload` extension already written for list-1 item
|
|
// rows, and a zero length keeps the client's constructed defaults.
|
|
w.optionalBlob(mailboxAttachmentFixedHexBlob(
|
|
attachment,
|
|
mailboxAvatarSocketBytes,
|
|
"avatar_socket_data", "reserved2", "jewel_socket", "jewelSocket",
|
|
))
|
|
w.optionalBlob(mailboxAttachmentFixedHexBlob(
|
|
attachment,
|
|
mailboxAvatarColorBytes,
|
|
"avatar_color_data", "color_data", "tail_data", "tailData",
|
|
))
|
|
}
|
|
}
|
|
w.u32(mailboxRemainingSeconds(summary.mail, now))
|
|
if summary.seedOnly {
|
|
w.u32(0)
|
|
} else {
|
|
w.u32(summary.mailID)
|
|
}
|
|
w.u8(mailboxMailType(summary.mail))
|
|
return nil
|
|
}
|
|
|
|
func (w *mailboxBodyWriter) writeDetail(mail dnfrepo.MailRecord) error {
|
|
mailID, err := mailboxWireID(mail.MailID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
senderID, err := mailboxSenderWireID(mail.SenderCharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.u32(mailID)
|
|
w.u32(senderID)
|
|
if err := w.dstring(mail.SenderName, maxMailboxRecipientBytes); err != nil {
|
|
return fmt.Errorf("sender name: %w", err)
|
|
}
|
|
if err := w.dstring(mail.Body, maxMailboxMessageBytes); err != nil {
|
|
return fmt.Errorf("body: %w", err)
|
|
}
|
|
w.u32(mailboxUnix(mail.CreatedAt))
|
|
w.u16(mailboxLetterStat(mail))
|
|
w.u8(mailboxMailType(mail))
|
|
return nil
|
|
}
|
|
|
|
func (w mailboxBodyWriter) bytes() []byte { return append([]byte(nil), w.data...) }
|
|
|
|
func mailboxAttachmentRaw(attachment dnfrepo.MailAttachment, now time.Time) []byte {
|
|
raw := make([]byte, mailboxItemRawSize)
|
|
if len(attachment.RawEntry) == mailboxItemRawSize {
|
|
copy(raw, attachment.RawEntry)
|
|
}
|
|
binary.LittleEndian.PutUint16(raw[0:2], 0)
|
|
binary.LittleEndian.PutUint32(raw[2:6], uint32(attachment.ItemID))
|
|
binary.LittleEndian.PutUint32(raw[6:10], uint32(attachment.Count))
|
|
if attachment.Bind && raw[0x0d] == 0 {
|
|
raw[0x0d] = 1
|
|
}
|
|
if !attachment.ExpireAt.IsZero() && attachment.ExpireAt.Unix() > 0 {
|
|
binary.LittleEndian.PutUint32(raw[0x38:0x3c], mailboxUnix(attachment.ExpireAt))
|
|
if mailboxAttachmentIsStackable(attachment) {
|
|
binary.LittleEndian.PutUint16(raw[0x0b:0x0d], mailboxAttachmentRemainingUseSeconds(attachment.ExpireAt, now))
|
|
}
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func mailboxAttachmentIsStackable(attachment dnfrepo.MailAttachment) bool {
|
|
return strings.EqualFold(strings.TrimSpace(attachment.Extra["item_kind"]), "stackable") ||
|
|
strings.TrimSpace(attachment.Extra["stackable_type"]) != ""
|
|
}
|
|
|
|
func mailboxAttachmentRemainingUseSeconds(expiresAt time.Time, now time.Time) uint16 {
|
|
if expiresAt.IsZero() || now.IsZero() || !expiresAt.After(now) {
|
|
return 0
|
|
}
|
|
remaining := expiresAt.Sub(now)
|
|
seconds := int64(remaining / time.Second)
|
|
if remaining%time.Second != 0 {
|
|
seconds++
|
|
}
|
|
if seconds > math.MaxUint16 {
|
|
return math.MaxUint16
|
|
}
|
|
return uint16(seconds)
|
|
}
|
|
|
|
func mailboxAttachmentRequiresClass26(attachment dnfrepo.MailAttachment) bool {
|
|
return mailboxAttachmentEquipmentType(attachment) == "creature"
|
|
}
|
|
|
|
// mailboxAttachmentRequiresAvatarBlobs reports whether the current client will
|
|
// consume the two trailing extension blocks for this attachment. Current
|
|
// sub_229C570/sub_229ACD0 both gate them on item-table type <= 11, which is the
|
|
// avatar family the PVF names `[hat avatar]` .. `[weapon avatar]`; `[creature]`
|
|
// is 26 and ordinary equipment starts at 12, so neither reads them.
|
|
func mailboxAttachmentRequiresAvatarBlobs(attachment dnfrepo.MailAttachment) bool {
|
|
return strings.HasSuffix(mailboxAttachmentEquipmentType(attachment), " avatar")
|
|
}
|
|
|
|
// mailboxAttachmentEquipmentType normalizes the durable PVF `[equipment type]`
|
|
// text kept on the attachment. The runtime PVF stores it as a backtick-quoted
|
|
// bracket tag such as `[weapon avatar]`, while older rows and tests carry the
|
|
// bare token, so both forms must resolve to the same category.
|
|
func mailboxAttachmentEquipmentType(attachment dnfrepo.MailAttachment) string {
|
|
tag := strings.ToLower(strings.TrimSpace(attachment.Extra["mailbox_equipment_type"]))
|
|
tag = strings.TrimSpace(strings.Trim(tag, "`"))
|
|
tag = strings.TrimPrefix(tag, "[")
|
|
tag = strings.TrimSuffix(tag, "]")
|
|
return strings.TrimSpace(tag)
|
|
}
|
|
|
|
// mailboxAttachmentFixedHexBlob decodes the first present hex-encoded extra and
|
|
// returns exactly size bytes. The current client reads these blocks into fixed
|
|
// targets - a 30-byte jewel socket built by sub_3775940 and the single colour
|
|
// DWORD built by sub_3775970 and read back as one dword - so an over-long
|
|
// durable value must never reach the wire.
|
|
func mailboxAttachmentFixedHexBlob(attachment dnfrepo.MailAttachment, size int, keys ...string) []byte {
|
|
for _, key := range keys {
|
|
raw := strings.TrimSpace(attachment.Extra[key])
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
raw = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "", "0x", "", "0X", "").Replace(raw)
|
|
decoded, err := hex.DecodeString(raw)
|
|
if err != nil || len(decoded) == 0 {
|
|
continue
|
|
}
|
|
fixed := make([]byte, size)
|
|
copy(fixed, decoded)
|
|
return fixed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mailboxLetterStat(mail dnfrepo.MailRecord) uint16 {
|
|
if mailboxMailSaved(mail) {
|
|
return 3
|
|
}
|
|
if mail.Read {
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func mailboxMailType(mail dnfrepo.MailRecord) byte {
|
|
value, err := strconv.ParseUint(strings.TrimSpace(mail.Metadata["mail_type"]), 10, 8)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return byte(value)
|
|
}
|
|
|
|
func mailboxRemainingSeconds(mail dnfrepo.MailRecord, now time.Time) uint32 {
|
|
if mailboxMailSaved(mail) || mailboxMailUnlimited(mail) || now.IsZero() {
|
|
return 0
|
|
}
|
|
expiresAt := mail.ExpireAt
|
|
if expiresAt.IsZero() && !mail.CreatedAt.IsZero() {
|
|
expiresAt = mail.CreatedAt.Add(mailboxNormalLifetime)
|
|
}
|
|
if expiresAt.IsZero() || !expiresAt.After(now) {
|
|
return 0
|
|
}
|
|
remaining := int64(expiresAt.Sub(now) / time.Second)
|
|
if remaining > math.MaxUint32 {
|
|
return math.MaxUint32
|
|
}
|
|
return uint32(remaining)
|
|
}
|
|
|
|
func mailboxWireID(value string) (uint32, error) {
|
|
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
|
|
if err != nil || parsed == 0 {
|
|
return 0, fmt.Errorf("%w: invalid wire mail id %q", ErrMailboxInvalidRequest, value)
|
|
}
|
|
return uint32(parsed), nil
|
|
}
|
|
|
|
func mailboxSenderWireID(value string) (uint32, error) {
|
|
if strings.TrimSpace(value) == "" {
|
|
return 0, nil
|
|
}
|
|
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%w: invalid sender id %q", ErrMailboxInvalidRequest, value)
|
|
}
|
|
return uint32(parsed), nil
|
|
}
|
|
|
|
func mailboxUnix(value time.Time) uint32 {
|
|
if value.IsZero() || value.Unix() <= 0 {
|
|
return 0
|
|
}
|
|
if value.Unix() > math.MaxUint32 {
|
|
return math.MaxUint32
|
|
}
|
|
return uint32(value.Unix())
|
|
}
|
|
|
|
func clampMailboxU16(value int) uint16 {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value >= math.MaxUint16 {
|
|
return math.MaxUint16
|
|
}
|
|
return uint16(value)
|
|
}
|
|
|
|
func encodeMailboxText(value string, maxBytes int) ([]byte, error) {
|
|
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(value))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(encoded) > maxBytes {
|
|
return nil, fmt.Errorf("length %d exceeds %d", len(encoded), maxBytes)
|
|
}
|
|
return encoded, nil
|
|
}
|