-
Notifications
You must be signed in to change notification settings - Fork 72
Contributing
This guide covers the development workflows for working on the Erupe codebase.
Erupe/
├── main.go # Entry point — starts all servers, handles shutdown
├── config.example.json # Template config (copy to config.json)
│
├── config/ # Config loading (Viper-backed)
│ └── config.go # Main Config struct, game version modes (S1–ZZ)
│
├── network/ # Protocol layer
│ ├── packetid.go # Packet ID enum (~200+ opcodes, `go generate` for stringer)
│ ├── crypt_conn.go # Blowfish-encrypted TCP connection
│ ├── crypt_packet.go # Packet framing and checksum
│ ├── mhfpacket/ # ~400 packet structs (one file per message type)
│ │ └── msg_mhf_*.go # Parse/Build for each packet
│ ├── binpacket/ # Binary relay packets (chat, mail, targeted)
│ ├── crypto/ # Low-level Blowfish cipher
│ └── clientctx/ # Per-connection context
│
├── server/
│ ├── channelserver/ # Gameplay server (by far the largest)
│ │ ├── handlers_table.go # PacketID → handler dispatch (~200 entries)
│ │ ├── handlers_quest.go # Quest system
│ │ ├── handlers_guild.go # Guild operations (14 tables)
│ │ ├── handlers_stage.go # Multiplayer rooms/lobbies
│ │ ├── handlers_cast_binary.go # Real-time state relay (position, animation)
│ │ ├── handlers_mail.go # In-game mail
│ │ ├── handlers_shop.go # Shops and gacha
│ │ ├── handlers_*.go # ... ~50 handler files by game system
│ │ ├── sys_session.go # Per-connection state (character, stage, send queue)
│ │ ├── sys_stage.go # Stage lifecycle, player sync
│ │ ├── sys_semaphore.go # Distributed locks (Raviente, guild ops)
│ │ ├── sys_channel_server.go # Server lifecycle, Raviente shared state
│ │ └── compression/ # Save data compression
│ ├── signserver/ # Authentication (TCP 53312)
│ ├── entranceserver/ # World list & character select (TCP 53310)
│ ├── api/ # REST API (port 8080) — launcher, screenshots
│ └── discordbot/ # Discord bot — slash commands, chat relay
│
├── common/ # Shared utility libraries
│ ├── byteframe/ # Binary read/write (big-endian)
│ ├── bfutil/ # Blowfish helpers
│ ├── decryption/ # Game file decryption
│ ├── gametime/ # MHF time ↔ real time conversion
│ ├── mhfcourse/ # Course/subscription definitions
│ ├── mhfitem/ # Item ID lookups
│ ├── mhfmon/ # Monster ID lookups
│ ├── mhfcid/ # Character ID handling
│ ├── token/ # Session token generation
│ ├── pascalstring/ # Length-prefixed string parsing
│ ├── stringstack/ # Stack for stage movement history
│ └── stringsupport/ # String utilities
│
├── schemas/ # PostgreSQL schema management
│ ├── init.sql # Base schema (pg_dump format, bootstraps to v9.1.0)
│ ├── patch-schema/ # Incremental dev patches (apply in order)
│ ├── update-schema/ # Consolidated release schemas
│ └── bundled-schema/ # Demo data (shops, events, gacha)
│
├── bin/ # Game data files (served to clients)
│ ├── quests/ # Quest binaries
│ ├── scenarios/ # Cutscene/scenario files
│ └── events/ # Event quest archives
│
├── docker/ # Docker Compose setup (PostgreSQL, pgAdmin, server)
├── tools/loganalyzer/ # Log analysis utility
├── vendor/ # Vendored Go dependencies
└── .github/ # CI workflows (test, race, coverage, build)
-
Implementing a feature? Start with the
handlers_*.gofile for that game system, then check the packet structs innetwork/mhfpacket/. -
Fixing a bug? Enable packet logging (see Debugging) and trace from
handlers_table.goto the relevant handler. -
Adding a DB table? Create a new patch in
schemas/patch-schema/with the next number in sequence. -
Understanding binary formats?
common/byteframe/is the serialization layer used everywhere.
This is the most common type of contribution. A new packet handler touches 4 files and optionally a 5th for code generation.
Create network/mhfpacket/msg_mhf_your_packet.go. Every packet implements the MHFPacket interface (Parse, Build, Opcode):
package mhfpacket
import (
"errors"
"erupe-ce/common/byteframe"
"erupe-ce/network"
"erupe-ce/network/clientctx"
)
type MsgMhfYourPacket struct {
AckHandle uint32
SomeField uint16
}
func (m *MsgMhfYourPacket) Opcode() network.PacketID {
return network.MSG_MHF_YOUR_PACKET
}
func (m *MsgMhfYourPacket) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
m.AckHandle = bf.ReadUint32()
m.SomeField = bf.ReadUint16()
return nil
}
func (m *MsgMhfYourPacket) Build(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
return errors.New("NOT IMPLEMENTED")
}Parse reads fields from the binary frame in the exact order the client sends them. Build is the inverse — only implement it if the server needs to construct this packet type (most server→client packets reuse MsgSysAck).
The AckHandle field is present on most packets. The client sends it and expects the same value back in the ack response.
Add a case to network/mhfpacket/opcode_to_packet.go:
case network.MSG_MHF_YOUR_PACKET:
return &MsgMhfYourPacket{}Add to server/channelserver/handlers_table.go:
handlerTable[network.MSG_MHF_YOUR_PACKET] = handleMsgMhfYourPacketThen implement the handler in the appropriate handlers_*.go file. The handler signature is always func(s *Session, p mhfpacket.MHFPacket):
func handleMsgMhfYourPacket(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfYourPacket)
// Do work (DB queries, state changes, etc.)
result, err := s.server.db.Query("SELECT ...")
if err != nil {
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
return
}
bf := byteframe.NewByteFrame()
bf.WriteUint32(someValue)
doAckSimpleSucceed(s, pkt.AckHandle, bf.Data())
}If this is a genuinely new packet (not an existing reserve slot), add it to network/packetid.go and regenerate the stringer:
cd Erupe/network && go generateThis regenerates packetid_string.go so debug logs show the packet name instead of a number.
Every handler that receives an AckHandle must respond with one of these (defined in handlers.go):
| Function | When to use |
|---|---|
doAckSimpleSucceed(s, ackHandle, data) |
Single operation succeeded |
doAckSimpleFail(s, ackHandle, data) |
Single operation failed |
doAckBufSucceed(s, ackHandle, data) |
Returning a list/buffer of results |
doAckBufFail(s, ackHandle, data) |
List/buffer query failed |
doAckEarthSucceed(s, ackHandle, frames) |
Multi-world responses (rare) |
stubEnumerateNoResults(s, ackHandle) |
Placeholder for unimplemented enumerate packets |
The difference between Simple and Buf is the IsBufferResponse flag in the ack — the client uses it to determine how to parse the response data.
msg_mhf_create_guild.go → handlers_guild.go:618 is a clean end-to-end example:
- Packet struct has
AckHandleandNamefields - Parse reads them from the binary frame (Shift-JIS → UTF-8 conversion)
- Handler calls
CreateGuild(s, pkt.Name)for DB work - On error:
doAckSimpleFailwith an error code - On success:
doAckSimpleSucceedwith the new guild ID
The session represents one connected player. Key fields available in handlers:
| Field | Type | Purpose |
|---|---|---|
s.charID |
uint32 |
Character ID |
s.stage |
*Stage |
Current multiplayer stage/room |
s.server |
*Server |
Channel server instance |
s.logger |
*zap.Logger |
Session-scoped logger |
s.courses |
[]mhfcourse.Course |
Active subscription courses |
s.semaphore |
*Semaphore |
Current semaphore lock ownership |
s.Name |
string |
Character name |
s.token |
string |
Login token |
s.mailList |
[]int |
Mail ID mapping |
Key methods: s.QueueSendMHF(packet), s.isOp().
The server holds shared state across all sessions on the channel:
| Field | Type | Purpose |
|---|---|---|
s.server.db |
*sqlx.DB |
PostgreSQL connection |
s.server.erupeConfig |
*Config |
Server configuration |
s.server.stages |
map[string]*Stage |
All active stages |
s.server.Channels |
[]*Server |
All channel server instances |
s.server.raviente |
*Raviente |
Raviente raid state |
Key methods: s.server.BroadcastMHF(packet, ignored), s.server.FindSessionByCharID(id).
Binary data serialization. Big-endian by default.
// Writing a response
bf := byteframe.NewByteFrame()
bf.WriteUint32(value)
bf.WriteUint16(count)
bf.WriteBytes(rawData)
data := bf.Data()
// Reading from a packet
bf.ReadUint32()
bf.ReadNullTerminatedBytes()
bf.Seek(10, io.SeekStart)Enable in config.json under DebugOptions:
{
"LogInboundMessages": true,
"LogOutboundMessages": true,
"LogMessageData": true,
"MaxHexdumpLength": 512
}This prints every packet with its opcode name, direction, timing, and hex dump. High-frequency packets (PING, TIME, NOP, POSITION_OBJECT, EXTEND_THRESHOLD, END) are filtered out automatically.
Example output:
[PlayerName] -> [Server]
Opcode: (Dec: 24832 Hex: 0x6100 Name: MSG_MHF_CREATE_GUILD)
Data [24 bytes]:
00000000 61 00 00 00 00 01 00 12 54 65 73 74 00 00 00 00 |a.......Test....|
Set ProxyPort in debug options to redirect all channel server traffic through a proxy on that port, useful for external packet inspection tools.
Set QuestTools: true to enable extra quest-related logging (file loading, backporting, caching).
go test -v ./... # All tests
go test -race ./... -timeout=10m # Race detection (mandatory)
go test -v ./server/channelserver/... # One package
go test -run TestGuildRank ./server/channelserver/... # One test
go test -coverprofile=coverage.out ./... # CoverageRace detection is critical — Erupe uses goroutines extensively with shared mutable state (stages, semaphores, Raviente). The CI pipeline runs -race on every push.
Tests prefixed with IntegrationTest_ or using testing.Short() are skipped in short mode. A separate test database is available via docker/docker-compose.test.yml (PostgreSQL on port 5433, tmpfs-backed for speed):
docker compose -f docker/docker-compose.test.yml up -d-
Unit tests: Table-driven with subtests (
t.Run), test business logic in isolation - Handler tests: Create mock sessions, call handler directly, verify ack response
- Integration tests: Full session lifecycle with simulated connections
GitHub Actions runs on push to main, develop, fix-*, feature-* branches and on PRs:
-
go test -v ./...(10 minute timeout) -
go test -race ./...(10 minute timeout) - Coverage report → Codecov
- Build Linux and Windows binaries
Linting uses golangci-lint.