diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32bdd19a1..0994c72da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Changed
+
+- Shutdown now proceeds in three phases: listeners close immediately on signal, then a passive drain waits up to `ShutdownDrainSeconds` (default 30) for sessions to disconnect naturally, then remaining connections are force-closed. This prevents players from starting new quests after the countdown begins ([#179](https://github.com/Mezeporta/Erupe/issues/179)).
+- Renamed config field `DisableSoftCrash` → `DisableShutdownCountdown` for clarity. The old key is still accepted via a Viper alias so existing `config.json` files keep working without modification ([#179](https://github.com/Mezeporta/Erupe/issues/179)).
+
### Added
- Reverse-engineered user binary data types from `mhfo-hd.dll` via Ghidra: type 1 = character name (max 17B SJIS), type 2 = player profile with self-introduction (208B), type 3 = equipment/appearance snapshot (384B). Added structured parsing with size validation warnings to `handleMsgSysSetUserBinary`.
diff --git a/config.reference.json b/config.reference.json
index 678370d6f..d59b37e3a 100644
--- a/config.reference.json
+++ b/config.reference.json
@@ -2,7 +2,9 @@
"Host": "127.0.0.1",
"BinPath": "bin",
"Language": "en",
- "DisableSoftCrash": false,
+ "DisableShutdownCountdown": false,
+ "ShutdownCountdownSeconds": 10,
+ "ShutdownDrainSeconds": 30,
"HideLoginNotice": true,
"LoginNotices": [
"
Welcome to Erupe SU9.3!
Erupe is experimental software, we are not liable for any
issues caused by installing the software!
■Report bugs on Discord!
■Test everything!
■Don't talk to softlocking NPCs!
■Fork the code on GitHub!
Thank you to all of the contributors,
this wouldn't exist without you."
diff --git a/config/config.go b/config/config.go
index a006614bb..8e6ed2ca5 100644
--- a/config/config.go
+++ b/config/config.go
@@ -64,30 +64,31 @@ func (m Mode) String() string {
// Config holds the global server-wide config.
type Config struct {
- Host string `mapstructure:"Host"`
- BinPath string `mapstructure:"BinPath"`
- Language string
- DisableSoftCrash bool // Disables the 'Press Return to exit' dialog allowing scripts to reboot the server automatically
- ShutdownCountdownSeconds int // Seconds to count down before shutting down (default 10; ignored when DisableSoftCrash is true)
- HideLoginNotice bool // Hide the Erupe notice on login
- LoginNotices []string // MHFML string of the login notices displayed
- PatchServerManifest string // Manifest patch server override
- PatchServerFile string // File patch server override
- DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
- DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
- ClientMode string
- RealClientMode Mode
- QuestCacheExpiry int // Number of seconds to keep quest data cached
- CommandPrefix string // The prefix for commands
- AutoCreateAccount bool // Automatically create accounts if they don't exist
- LoopDelay int // Delay in milliseconds between each loop iteration
- DefaultCourses []uint16
- EarthStatus int32
- EarthID int32
- EarthMonsters []int32
- SaveDumps SaveDumpOptions
- Screenshots ScreenshotsOptions
- Capture CaptureOptions
+ Host string `mapstructure:"Host"`
+ BinPath string `mapstructure:"BinPath"`
+ Language string
+ DisableShutdownCountdown bool `mapstructure:"DisableShutdownCountdown"` // Skip the in-game shutdown countdown (lets scripts restart the server unattended). Previously named DisableSoftCrash — legacy key still accepted.
+ ShutdownCountdownSeconds int // Seconds to count down before shutting down (default 10; ignored when DisableShutdownCountdown is true)
+ ShutdownDrainSeconds int // Additional seconds to wait for sessions to disconnect naturally before force-closing (default 30)
+ HideLoginNotice bool // Hide the Erupe notice on login
+ LoginNotices []string // MHFML string of the login notices displayed
+ PatchServerManifest string // Manifest patch server override
+ PatchServerFile string // File patch server override
+ DeleteOnSaveCorruption bool // Attempts to save corrupted data will flag the save for deletion
+ DisableSaveIntegrityCheck bool // Skip SHA-256 hash verification on load (needed for cross-server save transfers)
+ ClientMode string
+ RealClientMode Mode
+ QuestCacheExpiry int // Number of seconds to keep quest data cached
+ CommandPrefix string // The prefix for commands
+ AutoCreateAccount bool // Automatically create accounts if they don't exist
+ LoopDelay int // Delay in milliseconds between each loop iteration
+ DefaultCourses []uint16
+ EarthStatus int32
+ EarthID int32
+ EarthMonsters []int32
+ SaveDumps SaveDumpOptions
+ Screenshots ScreenshotsOptions
+ Capture CaptureOptions
DebugOptions DebugOptions
GameplayOptions GameplayOptions
@@ -356,6 +357,11 @@ func registerDefaults() {
viper.SetDefault("AutoCreateAccount", true)
viper.SetDefault("LoopDelay", 50)
viper.SetDefault("ShutdownCountdownSeconds", 10)
+ viper.SetDefault("ShutdownDrainSeconds", 30)
+ // Back-compat: old configs use DisableSoftCrash. RegisterAlias makes Viper
+ // treat reads/writes of the old key as the new key, so existing
+ // config.json files keep working without modification.
+ viper.RegisterAlias("DisableSoftCrash", "DisableShutdownCountdown")
viper.SetDefault("DefaultCourses", []uint16{1, 23, 24})
viper.SetDefault("EarthMonsters", []int32{0, 0, 0, 0})
diff --git a/config/config_load_test.go b/config/config_load_test.go
index e88e3bf7a..48ede18d7 100644
--- a/config/config_load_test.go
+++ b/config/config_load_test.go
@@ -142,26 +142,26 @@ func TestLoadConfigDefaultModeWhenInvalid(t *testing.T) {
// TestConfigStruct tests Config structure creation with all fields
func TestConfigStruct(t *testing.T) {
cfg := &Config{
- Host: "localhost",
- BinPath: "/opt/erupe",
- Language: "en",
- DisableSoftCrash: false,
- HideLoginNotice: false,
- LoginNotices: []string{"Welcome"},
- PatchServerManifest: "http://patch.example.com/manifest",
- PatchServerFile: "http://patch.example.com/files",
+ Host: "localhost",
+ BinPath: "/opt/erupe",
+ Language: "en",
+ DisableShutdownCountdown: false,
+ HideLoginNotice: false,
+ LoginNotices: []string{"Welcome"},
+ PatchServerManifest: "http://patch.example.com/manifest",
+ PatchServerFile: "http://patch.example.com/files",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
- ClientMode: "ZZ",
- RealClientMode: ZZ,
- QuestCacheExpiry: 3600,
- CommandPrefix: "!",
- AutoCreateAccount: false,
- LoopDelay: 100,
- DefaultCourses: []uint16{1, 2, 3},
- EarthStatus: 0,
- EarthID: 0,
- EarthMonsters: []int32{100, 101, 102},
+ ClientMode: "ZZ",
+ RealClientMode: ZZ,
+ QuestCacheExpiry: 3600,
+ CommandPrefix: "!",
+ AutoCreateAccount: false,
+ LoopDelay: 100,
+ DefaultCourses: []uint16{1, 2, 3},
+ EarthStatus: 0,
+ EarthID: 0,
+ EarthMonsters: []int32{100, 101, 102},
SaveDumps: SaveDumpOptions{
Enabled: true,
RawEnabled: false,
diff --git a/config/config_test.go b/config/config_test.go
index 65951fd6c..912c41461 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -149,26 +149,26 @@ func TestGetOutboundIP4(t *testing.T) {
// TestConfigStructTypes verifies Config struct fields have correct types
func TestConfigStructTypes(t *testing.T) {
cfg := &Config{
- Host: "localhost",
- BinPath: "/path/to/bin",
- Language: "en",
- DisableSoftCrash: false,
- HideLoginNotice: false,
- LoginNotices: []string{"Notice"},
- PatchServerManifest: "http://patch.example.com",
- PatchServerFile: "http://files.example.com",
+ Host: "localhost",
+ BinPath: "/path/to/bin",
+ Language: "en",
+ DisableShutdownCountdown: false,
+ HideLoginNotice: false,
+ LoginNotices: []string{"Notice"},
+ PatchServerManifest: "http://patch.example.com",
+ PatchServerFile: "http://files.example.com",
DeleteOnSaveCorruption: false,
DisableSaveIntegrityCheck: false,
- ClientMode: "ZZ",
- RealClientMode: ZZ,
- QuestCacheExpiry: 3600,
- CommandPrefix: "!",
- AutoCreateAccount: false,
- LoopDelay: 100,
- DefaultCourses: []uint16{1, 2, 3},
- EarthStatus: 1,
- EarthID: 1,
- EarthMonsters: []int32{1, 2, 3},
+ ClientMode: "ZZ",
+ RealClientMode: ZZ,
+ QuestCacheExpiry: 3600,
+ CommandPrefix: "!",
+ AutoCreateAccount: false,
+ LoopDelay: 100,
+ DefaultCourses: []uint16{1, 2, 3},
+ EarthStatus: 1,
+ EarthID: 1,
+ EarthMonsters: []int32{1, 2, 3},
SaveDumps: SaveDumpOptions{
Enabled: true,
RawEnabled: false,
diff --git a/main.go b/main.go
index 4082cebcc..8ec45b372 100644
--- a/main.go
+++ b/main.go
@@ -14,8 +14,8 @@ import (
"syscall"
"time"
- cfg "erupe-ce/config"
"erupe-ce/common/gametime"
+ cfg "erupe-ce/config"
"erupe-ce/server/api"
"erupe-ce/server/channelserver"
"erupe-ce/server/discordbot"
@@ -401,7 +401,15 @@ func main() {
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
<-sig
- if !config.DisableSoftCrash {
+ // Phase 1: stop accepting new connections immediately so players seeing
+ // the countdown cannot start fresh quests at T-1.
+ if config.Channel.Enabled {
+ for _, c := range channels {
+ c.Shutdown()
+ }
+ }
+
+ if !config.DisableShutdownCountdown {
countdown := config.ShutdownCountdownSeconds
if countdown <= 0 {
countdown = 10
@@ -422,6 +430,31 @@ func main() {
}
if config.Channel.Enabled {
+ // Phase 2: passive drain — give active sessions (mid-quest players)
+ // up to ShutdownDrainSeconds to disconnect on their own.
+ drainSecs := config.ShutdownDrainSeconds
+ if drainSecs < 0 {
+ drainSecs = 0
+ }
+ if drainSecs > 0 {
+ passiveCtx, passiveCancel := context.WithTimeout(context.Background(), time.Duration(drainSecs)*time.Second)
+ // A second signal cancels the passive drain so the force-close
+ // phase runs immediately.
+ go func() {
+ select {
+ case <-sig:
+ logger.Info("Second signal received, skipping passive drain")
+ passiveCancel()
+ case <-passiveCtx.Done():
+ }
+ }()
+ for _, c := range channels {
+ c.DrainPassive(passiveCtx)
+ }
+ passiveCancel()
+ }
+
+ // Phase 3: force-close any stragglers so logoutPlayer runs and saves.
drainCtx, drainCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer drainCancel()
for _, c := range channels {
@@ -451,7 +484,7 @@ func wait() {
}
func preventClose(config *cfg.Config, text string) {
- if config != nil && config.DisableSoftCrash {
+ if config != nil && config.DisableShutdownCountdown {
os.Exit(0)
}
fmt.Println("\nFailed to start Erupe:\n" + text)
diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go
index a20fc0bf9..c87547102 100644
--- a/server/channelserver/sys_channel_server.go
+++ b/server/channelserver/sys_channel_server.go
@@ -249,6 +249,30 @@ func (s *Server) Shutdown() {
}
+// DrainPassive waits for active sessions to disconnect naturally (e.g. players
+// finishing a quest and logging out) without force-closing anything. Returns
+// when the session count reaches zero or ctx is cancelled. Callers should have
+// already invoked Shutdown() to close the listener so no new sessions arrive.
+func (s *Server) DrainPassive(ctx context.Context) {
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ s.Lock()
+ n := len(s.sessions)
+ s.Unlock()
+ if n == 0 {
+ s.logger.Info("Passive drain complete: all sessions disconnected")
+ return
+ }
+ select {
+ case <-ctx.Done():
+ s.logger.Info("Passive drain deadline reached", zap.Int("remaining_sessions", n))
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
// ShutdownAndDrain stops accepting new connections, force-closes every active
// session so that their logoutPlayer cleanup runs (saves character data, removes
// from stages, etc.), then waits until all sessions have been removed from the