ebiten: reconsider `Monitor` and `AppendMonitors` before `RunGame` in v3
Operating System
- Windows
- macOS
- Linux
- FreeBSD
- OpenBSD
- Android
- iOS
- Nintendo Switch
- PlayStation 5
- Xbox
- Web Browsers
What feature would you like to be added?
In v3, change how Monitor and AppendMonitors behave before the game starts on desktops. The replacement is not decided yet. This issue records that the current behavior is not good.
On desktops these two are the only public APIs besides RunGame that initialize GLFW. (*UserInterface).Monitor and (*UserInterface).AppendMonitors in internal/ui/ui_desktop.go call ensureGLFWInit while no backend is running, because initializeGLFW is what populates the monitor list and the initial monitor. initOnMainThread is the only other caller.
One candidate is to return nil, and append nothing, before the game starts. That would match mobiles and browsers, where the monitor is already unavailable that early, and it is a breaking change, hence v3.
A constraint on any fix: SetMonitor documents that it can be called before or after Run, and a *MonitorType can only be obtained from Monitor or AppendMonitors. examples/monitor picks a monitor by a command-line flag before RunGame, so returning nil needs a replacement way to choose the initial monitor, for example a field in RunGameOptions.
Why is this needed?
Three problems, all from initializing GLFW at an arbitrary point before RunGame.
1. GLFW is initialized on whatever goroutine calls it.
Before RunGame there is no main-thread dispatcher, since mainThread is assigned when the backend starts in runMultiThread / runSingleThread. So ensureGLFWInit runs on the calling goroutine. DeviceScaleFactor and ScreenSizeInFullscreen document that they must be called on the main thread before the main loop, and Monitor and AppendMonitors inherit the same requirement.
On macOS the platform initialization creates NSApplication state and registers classes, which belongs on the main thread. On Windows it creates the helper window (internal/glfw/win32_init_windows.go) and binds RegisterDeviceNotificationW to it. Win32 delivers a window's messages only to the owning thread's queue, so a helper window created on a worker goroutine's thread never receives WM_DISPLAYCHANGE or WM_DEVICECHANGE, and PostEmptyEvent, which posts WM_NULL to that same window, cannot wake WaitEvents. The sync.Once makes the state permanent for the process, including the RunGame that follows.
2. The value describes the current machine even for a virtualization guest.
A guest built with -tags ebitenginevm, driven by exp/vmhost, printing Monitor() before RunGame and again in Update:
before RunGame: name="Built-in Retina Display" size=1800x1169
during RunGame: name="" size=320x240The second is the virtual monitor from the host, which is the only monitor a guest should see. The guest also initializes the local window system it is meant to avoid. The backend is chosen at Run from RunGameOptions.VMGuestEndpoint or EBITENGINE_VM_ENDPOINT, so the earlier call cannot know what the process will become.
3. A failed early initialization is fatal to a run that never needed GLFW.
ensureGLFWInit records the failure with setError, which is stored on the shared UserInterface, and both updateTickForVMGuest and the shared game loop in internal/ui/context.go return it. Fault-injecting a failing ensureGLFWInit into the guest only, which is what a guest on a machine with no window system produces:
before RunGame: Monitor()=<nil>
guest session failed: no window systemThe framebuffer backend has the same exposure. Run falls back to it when no window system is available, but ensureGLFWInit has no windowsystem.Available() guard, unlike maybeNewGLFWBackend. This one is read from the code path, not executed.
Until this changes, the godoc of Monitor and AppendMonitors notes the wrong-machine result as a known issue.
Filed by Claude (Claude Code), on behalf of @hajimehoshi.
Source: hajimehoshi/ebiten