Table of Contents
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Contributing
This guide covers the development workflows for working on the Erupe codebase.
Project Directory Guide
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)
Where to look first
- 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.
Adding a Packet Handler
This is the most common type of contribution. A new packet handler touches 4 files and optionally a 5th for code generation.
1. Define the packet struct
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.
2. Register the packet in the opcode switch
Add a case to network/mhfpacket/opcode_to_packet.go:
case network.MSG_MHF_YOUR_PACKET:
return &MsgMhfYourPacket{}
3. Register and implement the handler
Add to server/channelserver/handlers_table.go:
handlerTable[network.MSG_MHF_YOUR_PACKET] = handleMsgMhfYourPacket
Then 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())
}
4. Add the packet ID constant
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 generate
This regenerates packetid_string.go so debug logs show the packet name instead of a number.
ACK Response Types
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.
Real Example
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
What Handlers Have Access To
Session (s *Session)
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().
Server (s.server)
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).
ByteFrame (byteframe.NewByteFrame())
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)
Debugging
Packet Logging
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....|
Proxy
Set ProxyPort in debug options to redirect all channel server traffic through a proxy on that port, useful for external packet inspection tools.
Quest Debugging
Set QuestTools: true to enable extra quest-related logging (file loading, backporting, caching).
Testing
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 ./... # Coverage
Race detection is critical — Erupe uses goroutines extensively with shared mutable state (stages, semaphores, Raviente). The CI pipeline runs -race on every push.
Integration Tests
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
Test Patterns
- 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
CI
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.