mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-02-04 09:15:08 +01:00
add support for more versions
This commit is contained in:
@@ -11,16 +11,58 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Mode string
|
type Mode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ZZ Mode = "ZZ"
|
S1 Mode = iota + 1
|
||||||
Z2 Mode = "Z2"
|
S15
|
||||||
Z1 Mode = "Z1"
|
S2
|
||||||
|
S25
|
||||||
|
S3
|
||||||
|
S35
|
||||||
|
S4
|
||||||
|
S5
|
||||||
|
S55
|
||||||
|
S6
|
||||||
|
S7
|
||||||
|
S8
|
||||||
|
S85
|
||||||
|
S9
|
||||||
|
S10
|
||||||
|
F1
|
||||||
|
F2
|
||||||
|
F3
|
||||||
|
F4
|
||||||
|
F5
|
||||||
|
G1
|
||||||
|
G2
|
||||||
|
G3
|
||||||
|
G31
|
||||||
|
G32
|
||||||
|
GG
|
||||||
|
G5
|
||||||
|
G51
|
||||||
|
G52
|
||||||
|
G6
|
||||||
|
G61
|
||||||
|
G7
|
||||||
|
G8
|
||||||
|
G81
|
||||||
|
G9
|
||||||
|
G91
|
||||||
|
G10
|
||||||
|
G101
|
||||||
|
Z1
|
||||||
|
Z2
|
||||||
|
ZZ
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var versionStrings = []string{"S1.0", "S1.5", "S2.0", "S2.5", "S3.0", "S3.5", "S4.0", "S5.0", "S5.5", "S6.0", "S7.0",
|
||||||
|
"S8.0", "S8.5", "S9", "S10", "FW.1", "FW.2", "FW.3", "FW.4", "FW.5", "G1", "G2", "G3", "G3.1", "G3.2", "GG", "G5",
|
||||||
|
"G5.1", "G5.2", "G6", "G6.1", "G7", "G8", "G8.1", "G9", "G9.1", "G10", "G10.1", "Z1", "Z2", "ZZ"}
|
||||||
|
|
||||||
func (m Mode) String() string {
|
func (m Mode) String() string {
|
||||||
return string(m)
|
return versionStrings[m]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds the global server-wide config.
|
// Config holds the global server-wide config.
|
||||||
@@ -35,7 +77,8 @@ type Config struct {
|
|||||||
PatchServerFile string // File patch server override
|
PatchServerFile string // File patch server override
|
||||||
ScreenshotAPIURL string // Destination for screenshots uploaded to BBS
|
ScreenshotAPIURL string // Destination for screenshots uploaded to BBS
|
||||||
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
|
DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
|
||||||
ClientMode Mode
|
ClientMode string
|
||||||
|
RealClientMode Mode
|
||||||
DevMode bool
|
DevMode bool
|
||||||
|
|
||||||
DevModeOptions DevModeOptions
|
DevModeOptions DevModeOptions
|
||||||
@@ -225,13 +268,18 @@ func LoadConfig() (*Config, error) {
|
|||||||
c.Host = getOutboundIP4().To4().String()
|
c.Host = getOutboundIP4().To4().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToUpper(c.ClientMode.String()) {
|
for i := range versionStrings {
|
||||||
case "Z1":
|
if strings.ToUpper(c.ClientMode) == versionStrings[i] {
|
||||||
c.ClientMode = Z1
|
c.RealClientMode = Mode(i + 1)
|
||||||
case "Z2":
|
c.ClientMode = strings.ToUpper(c.ClientMode)
|
||||||
c.ClientMode = Z2
|
if c.RealClientMode < Z1 {
|
||||||
default:
|
c.ClientMode += " (Debug only)"
|
||||||
c.ClientMode = ZZ
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.RealClientMode == 0 {
|
||||||
|
c.ClientMode = versionStrings[len(versionStrings)-1]
|
||||||
|
c.RealClientMode = ZZ
|
||||||
}
|
}
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -56,7 +56,7 @@ func main() {
|
|||||||
logger := zapLogger.Named("main")
|
logger := zapLogger.Named("main")
|
||||||
|
|
||||||
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
logger.Info(fmt.Sprintf("Starting Erupe (9.3b-%s)", Commit()))
|
||||||
logger.Info(fmt.Sprintf("Client Mode: %s", config.ClientMode.String()))
|
logger.Info(fmt.Sprintf("Client Mode: %s (%d)", config.ClientMode, config.RealClientMode))
|
||||||
|
|
||||||
if config.Database.Password == "" {
|
if config.Database.Password == "" {
|
||||||
preventClose("Database password is blank")
|
preventClose("Database password is blank")
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func (m *MsgMhfEnumerateQuest) Parse(bf *byteframe.ByteFrame, ctx *clientctx.Cli
|
|||||||
m.World = bf.ReadUint8()
|
m.World = bf.ReadUint8()
|
||||||
m.Counter = bf.ReadUint16()
|
m.Counter = bf.ReadUint16()
|
||||||
m.Offset = bf.ReadUint16()
|
m.Offset = bf.ReadUint16()
|
||||||
if _config.ErupeConfig.ClientMode != _config.Z1 {
|
if _config.ErupeConfig.RealClientMode > _config.Z1 {
|
||||||
m.Unk4 = bf.ReadUint8()
|
m.Unk4 = bf.ReadUint8()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func (m *MsgMhfStampcardStamp) Parse(bf *byteframe.ByteFrame, ctx *clientctx.Cli
|
|||||||
m.GR = bf.ReadUint16()
|
m.GR = bf.ReadUint16()
|
||||||
m.Stamps = bf.ReadUint16()
|
m.Stamps = bf.ReadUint16()
|
||||||
_ = bf.ReadUint16()
|
_ = bf.ReadUint16()
|
||||||
if _config.ErupeConfig.ClientMode != _config.Z1 {
|
if _config.ErupeConfig.RealClientMode > _config.Z1 {
|
||||||
m.Reward1 = uint16(bf.ReadUint32())
|
m.Reward1 = uint16(bf.ReadUint32())
|
||||||
m.Reward2 = uint16(bf.ReadUint32())
|
m.Reward2 = uint16(bf.ReadUint32())
|
||||||
m.Item1 = uint16(bf.ReadUint32())
|
m.Item1 = uint16(bf.ReadUint32())
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func (save *CharacterSaveData) Decompress() error {
|
|||||||
func (save *CharacterSaveData) updateSaveDataWithStruct() {
|
func (save *CharacterSaveData) updateSaveDataWithStruct() {
|
||||||
rpBytes := make([]byte, 2)
|
rpBytes := make([]byte, 2)
|
||||||
binary.LittleEndian.PutUint16(rpBytes, save.RP)
|
binary.LittleEndian.PutUint16(rpBytes, save.RP)
|
||||||
if _config.ErupeConfig.ClientMode == _config.ZZ {
|
if _config.ErupeConfig.RealClientMode == _config.ZZ {
|
||||||
copy(save.decompSave[pointerRP:pointerRP+2], rpBytes)
|
copy(save.decompSave[pointerRP:pointerRP+2], rpBytes)
|
||||||
copy(save.decompSave[pointerKQF:pointerKQF+8], save.KQF)
|
copy(save.decompSave[pointerKQF:pointerKQF+8], save.KQF)
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +166,7 @@ func (save *CharacterSaveData) updateStructWithSaveData() {
|
|||||||
save.Gender = false
|
save.Gender = false
|
||||||
}
|
}
|
||||||
if !save.IsNewCharacter {
|
if !save.IsNewCharacter {
|
||||||
if _config.ErupeConfig.ClientMode == _config.ZZ {
|
if _config.ErupeConfig.RealClientMode == _config.ZZ {
|
||||||
save.RP = binary.LittleEndian.Uint16(save.decompSave[pointerRP : pointerRP+2])
|
save.RP = binary.LittleEndian.Uint16(save.decompSave[pointerRP : pointerRP+2])
|
||||||
save.HouseTier = save.decompSave[pointerHouseTier : pointerHouseTier+5]
|
save.HouseTier = save.decompSave[pointerHouseTier : pointerHouseTier+5]
|
||||||
save.HouseData = save.decompSave[pointerHouseData : pointerHouseData+195]
|
save.HouseData = save.decompSave[pointerHouseData : pointerHouseData+195]
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func handleMsgMhfGetUdSchedule(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
var timestamps []uint32
|
var timestamps []uint32
|
||||||
if s.server.erupeConfig.DevMode && s.server.erupeConfig.DevModeOptions.DivaEvent >= 0 {
|
if s.server.erupeConfig.DevMode && s.server.erupeConfig.DevModeOptions.DivaEvent >= 0 {
|
||||||
if s.server.erupeConfig.DevModeOptions.DivaEvent == 0 {
|
if s.server.erupeConfig.DevModeOptions.DivaEvent == 0 {
|
||||||
if s.server.erupeConfig.ClientMode == _config.Z1 {
|
if s.server.erupeConfig.RealClientMode <= _config.Z1 {
|
||||||
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 32))
|
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 32))
|
||||||
} else {
|
} else {
|
||||||
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 36))
|
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 36))
|
||||||
@@ -84,7 +84,7 @@ func handleMsgMhfGetUdSchedule(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
timestamps = generateDivaTimestamps(s, start, false)
|
timestamps = generateDivaTimestamps(s, start, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.server.erupeConfig.ClientMode != _config.Z1 {
|
if s.server.erupeConfig.RealClientMode <= _config.Z1 {
|
||||||
bf.WriteUint32(id)
|
bf.WriteUint32(id)
|
||||||
}
|
}
|
||||||
for i := range timestamps {
|
for i := range timestamps {
|
||||||
|
|||||||
@@ -92,9 +92,12 @@ func handleMsgMhfGetWeeklySchedule(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
|
|
||||||
func generateFeatureWeapons(count int) activeFeature {
|
func generateFeatureWeapons(count int) activeFeature {
|
||||||
max := 14
|
max := 14
|
||||||
if _config.ErupeConfig.ClientMode != _config.ZZ {
|
if _config.ErupeConfig.RealClientMode < _config.ZZ {
|
||||||
max = 13
|
max = 13
|
||||||
}
|
}
|
||||||
|
if _config.ErupeConfig.RealClientMode < _config.GG {
|
||||||
|
max = 12
|
||||||
|
}
|
||||||
if count > max {
|
if count > max {
|
||||||
count = max
|
count = max
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1391,7 +1391,7 @@ func handleMsgMhfEnumerateGuildMember(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
bf.WriteUint32(member.CharID)
|
bf.WriteUint32(member.CharID)
|
||||||
bf.WriteUint16(member.HRP)
|
bf.WriteUint16(member.HRP)
|
||||||
bf.WriteUint16(member.GR)
|
bf.WriteUint16(member.GR)
|
||||||
if s.server.erupeConfig.ClientMode != _config.ZZ {
|
if s.server.erupeConfig.RealClientMode < _config.ZZ {
|
||||||
// Magnet Spike crash workaround
|
// Magnet Spike crash workaround
|
||||||
bf.WriteUint16(0)
|
bf.WriteUint16(0)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package channelserver
|
|||||||
import (
|
import (
|
||||||
"erupe-ce/common/byteframe"
|
"erupe-ce/common/byteframe"
|
||||||
"erupe-ce/common/stringsupport"
|
"erupe-ce/common/stringsupport"
|
||||||
|
_config "erupe-ce/config"
|
||||||
"erupe-ce/network/mhfpacket"
|
"erupe-ce/network/mhfpacket"
|
||||||
"erupe-ce/server/channelserver/compression/deltacomp"
|
"erupe-ce/server/channelserver/compression/deltacomp"
|
||||||
"erupe-ce/server/channelserver/compression/nullcomp"
|
"erupe-ce/server/channelserver/compression/nullcomp"
|
||||||
@@ -54,15 +55,17 @@ func handleMsgMhfLoadLegendDispatch(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
||||||
}
|
}
|
||||||
|
|
||||||
const NaviLength = 552
|
|
||||||
|
|
||||||
func handleMsgMhfLoadHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfLoadHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfLoadHunterNavi)
|
pkt := p.(*mhfpacket.MsgMhfLoadHunterNavi)
|
||||||
|
naviLength := 552
|
||||||
|
if s.server.erupeConfig.RealClientMode <= _config.G7 {
|
||||||
|
naviLength = 280
|
||||||
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
err := s.server.db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", s.charID).Scan(&data)
|
err := s.server.db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", s.charID).Scan(&data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
s.logger.Error("Failed to load hunternavi", zap.Error(err))
|
s.logger.Error("Failed to load hunternavi", zap.Error(err))
|
||||||
data = make([]byte, NaviLength)
|
data = make([]byte, naviLength)
|
||||||
}
|
}
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
doAckBufSucceed(s, pkt.AckHandle, data)
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,10 @@ func handleMsgMhfLoadHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfSaveHunterNavi)
|
pkt := p.(*mhfpacket.MsgMhfSaveHunterNavi)
|
||||||
if pkt.IsDataDiff {
|
if pkt.IsDataDiff {
|
||||||
|
naviLength := 552
|
||||||
|
if s.server.erupeConfig.RealClientMode <= _config.G7 {
|
||||||
|
naviLength = 280
|
||||||
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
// Load existing save
|
// Load existing save
|
||||||
err := s.server.db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", s.charID).Scan(&data)
|
err := s.server.db.QueryRow("SELECT hunternavi FROM characters WHERE id = $1", s.charID).Scan(&data)
|
||||||
@@ -80,7 +87,7 @@ func handleMsgMhfSaveHunterNavi(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
// Check if we actually had any hunternavi data, using a blank buffer if not.
|
// Check if we actually had any hunternavi data, using a blank buffer if not.
|
||||||
// This is requried as the client will try to send a diff after character creation without a prior MsgMhfSaveHunterNavi packet.
|
// This is requried as the client will try to send a diff after character creation without a prior MsgMhfSaveHunterNavi packet.
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
data = make([]byte, NaviLength)
|
data = make([]byte, naviLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform diff and compress it to write back to db
|
// Perform diff and compress it to write back to db
|
||||||
|
|||||||
@@ -25,11 +25,16 @@ func encodeServerInfo(config *_config.Config, s *Server, local bool) []byte {
|
|||||||
|
|
||||||
for serverIdx, si := range serverInfos {
|
for serverIdx, si := range serverInfos {
|
||||||
// Prevent MezFes Worlds displaying on Z1
|
// Prevent MezFes Worlds displaying on Z1
|
||||||
if config.ClientMode == _config.Z1 {
|
if config.RealClientMode <= _config.Z1 {
|
||||||
if si.Type == 6 {
|
if si.Type == 6 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if config.RealClientMode <= _config.G6 {
|
||||||
|
if si.Type == 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
sid := (4096 + serverIdx*256) + 16
|
sid := (4096 + serverIdx*256) + 16
|
||||||
err := s.db.QueryRow("SELECT season FROM servers WHERE server_id=$1", sid).Scan(&season)
|
err := s.db.QueryRow("SELECT season FROM servers WHERE server_id=$1", sid).Scan(&season)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,10 +54,19 @@ func encodeServerInfo(config *_config.Config, s *Server, local bool) []byte {
|
|||||||
bf.WriteUint8(si.Type)
|
bf.WriteUint8(si.Type)
|
||||||
bf.WriteUint8(season)
|
bf.WriteUint8(season)
|
||||||
bf.WriteUint8(si.Recommended)
|
bf.WriteUint8(si.Recommended)
|
||||||
bf.WriteUint8(0) // Prevents malformed server name
|
|
||||||
combined := append(stringsupport.UTF8ToSJIS(si.Name), []byte{0x00}...)
|
if s.erupeConfig.RealClientMode <= _config.GG {
|
||||||
combined = append(combined, stringsupport.UTF8ToSJIS(si.Description)...)
|
bf.WriteUint8(64) // Prevents malformed server name
|
||||||
bf.WriteBytes(stringsupport.PaddedString(string(combined), 65, false))
|
combined := append(stringsupport.UTF8ToSJIS(si.Name), []byte{0x00}...)
|
||||||
|
combined = append(combined, stringsupport.UTF8ToSJIS(si.Description)...)
|
||||||
|
bf.WriteBytes(stringsupport.PaddedString(string(combined), 64, false))
|
||||||
|
} else {
|
||||||
|
bf.WriteUint8(0) // Prevents malformed server name
|
||||||
|
combined := append(stringsupport.UTF8ToSJIS(si.Name), []byte{0x00}...)
|
||||||
|
combined = append(combined, stringsupport.UTF8ToSJIS(si.Description)...)
|
||||||
|
bf.WriteBytes(stringsupport.PaddedString(string(combined), 65, false))
|
||||||
|
}
|
||||||
|
|
||||||
bf.WriteUint32(si.AllowedClientFlags)
|
bf.WriteUint32(si.AllowedClientFlags)
|
||||||
|
|
||||||
for channelIdx, ci := range si.Channels {
|
for channelIdx, ci := range si.Channels {
|
||||||
@@ -101,13 +115,22 @@ func makeSv2Resp(config *_config.Config, s *Server, local bool) []byte {
|
|||||||
serverInfos := config.Entrance.Entries
|
serverInfos := config.Entrance.Entries
|
||||||
// Decrease by the number of MezFes Worlds
|
// Decrease by the number of MezFes Worlds
|
||||||
var mf int
|
var mf int
|
||||||
if config.ClientMode == _config.Z1 {
|
if config.RealClientMode <= _config.Z1 {
|
||||||
for _, si := range serverInfos {
|
for _, si := range serverInfos {
|
||||||
if si.Type == 6 {
|
if si.Type == 6 {
|
||||||
mf++
|
mf++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// and Return Worlds
|
||||||
|
var ret int
|
||||||
|
if config.RealClientMode <= _config.G6 {
|
||||||
|
for _, si := range serverInfos {
|
||||||
|
if si.Type == 5 {
|
||||||
|
ret++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
rawServerData := encodeServerInfo(config, s, local)
|
rawServerData := encodeServerInfo(config, s, local)
|
||||||
|
|
||||||
if s.erupeConfig.DevMode && s.erupeConfig.DevModeOptions.LogOutboundMessages {
|
if s.erupeConfig.DevMode && s.erupeConfig.DevModeOptions.LogOutboundMessages {
|
||||||
@@ -115,7 +138,7 @@ func makeSv2Resp(config *_config.Config, s *Server, local bool) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bf := byteframe.NewByteFrame()
|
bf := byteframe.NewByteFrame()
|
||||||
bf.WriteBytes(makeHeader(rawServerData, "SV2", uint16(len(serverInfos)-mf), 0x00))
|
bf.WriteBytes(makeHeader(rawServerData, "SV2", uint16(len(serverInfos)-(mf+ret)), 0x00))
|
||||||
return bf.Data()
|
return bf.Data()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
ps "erupe-ce/common/pascalstring"
|
ps "erupe-ce/common/pascalstring"
|
||||||
"erupe-ce/common/stringsupport"
|
"erupe-ce/common/stringsupport"
|
||||||
"erupe-ce/common/token"
|
"erupe-ce/common/token"
|
||||||
|
_config "erupe-ce/config"
|
||||||
"erupe-ce/server/channelserver"
|
"erupe-ce/server/channelserver"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -77,8 +78,11 @@ func (s *Session) makeSignResponse(uid int) []byte {
|
|||||||
bf.WriteBool(true) // Use uint16 GR, no reason not to
|
bf.WriteBool(true) // Use uint16 GR, no reason not to
|
||||||
bf.WriteBytes(stringsupport.PaddedString(char.Name, 16, true)) // Character name
|
bf.WriteBytes(stringsupport.PaddedString(char.Name, 16, true)) // Character name
|
||||||
bf.WriteBytes(stringsupport.PaddedString(char.UnkDescString, 32, false)) // unk str
|
bf.WriteBytes(stringsupport.PaddedString(char.UnkDescString, 32, false)) // unk str
|
||||||
bf.WriteUint16(char.GR)
|
if s.server.erupeConfig.RealClientMode >= _config.G7 {
|
||||||
bf.WriteUint16(0) // Unk
|
bf.WriteUint16(char.GR)
|
||||||
|
bf.WriteUint8(0) // Unk
|
||||||
|
bf.WriteUint8(0) // Unk
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
friends := s.server.getFriendsForCharacters(chars)
|
friends := s.server.getFriendsForCharacters(chars)
|
||||||
|
|||||||
Reference in New Issue
Block a user