Skip to content

Linux and Web on SDL 2, and a Linux window that works with tiling window managers - #91

Merged
greenfire27 merged 10 commits into
developmentfrom
linux-sdl2-tiling
Sep 19, 2026
Merged

greenfire27 merged 10 commits into
developmentfrom
linux-sdl2-tiling

Conversation

@greenfire27

Copy link
Copy Markdown
Collaborator

Summary

This moves the Linux and Web back-ends from SDL 1.2 to SDL 2, and makes the Linux window work with tiling window managers such as Hyprland without breaking stacking desktops.

  • Linux builds SDL 2.32.10 from source with the engine and links it statically, so there is no SDL package to install. The tree is Torque3D's vendored Engine/lib/sdl, minus SDL's 51 MB of tests. See engine/lib/sdl/README-TORQUE2D.md.
  • The Web build uses Emscripten's port of the same 2.32.10.
  • Shared code. The SDL code the two back-ends share lives in engine/source/platformSDL: the key table, typed-text bridge, clipboard and message box. It is adapted from Torque3D's, following the aim of keeping the two engines close.
  • Why the move: SDL 1.2 now ships in two implementations, genuine 1.2.15 and the sdl12-compat shim distributions ship. They disagreed about exactly what a window manager cares about: windows, events and fullscreen.

Commits

Commit Change
221ede88 Tiling. windowedRes decides floating vs tiled; the engine follows the window manager's fullscreen; Linux honours $pref::Video::fullScreen. (On SDL 1.2.)
09a71466 Linux on SDL 2 (vendored, static). One window and GL context for the process, so resizes no longer reload textures; platformSDL; CI and scripts install SDL's X11 build headers instead of libsdl1.2-dev.
d169ad89 Text boxes insert typed characters as UTF-8 instead of a single byte. Works for both input models: the character on the key-down (Windows, macOS) or its own KEY_NULL event (SDL 2).
a9bc9358 Web on SDL 2, sharing Linux's input. Accented typing and the mouse wheel work; the canvas follows the browser.
6d797bbd Linux message boxes appear again and no longer abort a debug build. SIGCHLD is no longer ignored, and there is a zenity fallback where SDL's X11 box has no core fonts. gotoWebPage() goes through SDL_OpenURL.
6d2423f7 Text-input focus: a key typed in a text box no longer fires game bindings, text input follows the keyboard, hidden or deactivated boxes let go of it, and a Tab bug is fixed.

CHANGELOG.md [Unreleased] has every user-visible change, written for someone building a game.

What changes for a game

  • Window size. An empty $pref::Video::windowedRes lets a tiling window manager size the window. A set size opens the window floating at exactly that size, still resizable. The editor now leaves it empty; AppCore keeps 1024 768. Windows and macOS fall back to defaultResolution for an empty value.
  • Fullscreen (Linux and Web) is desktop fullscreen. A window-manager fullscreen (Hyprland's SUPER+F) is tracked by isFullScreen().
  • Typing.
    • Characters arrive as their own events on Linux and the Web, so dead keys, compose and input methods work, and text boxes store and edit UTF-8 correctly.
    • A key bound in the GlobalActionMap works while a text box has the keyboard and types nothing, as in Torque3D.
  • Mouse wheel. On Linux a notch is $pref::Input::MouseWheelSpeed (120 by default, as Windows reports it) instead of 10; GUI scrolling is unchanged. The wheel works on the Web for the first time.
  • Behaviour change on all platforms: hiding or deactivating a focused text box now runs its onLoseFirstResponder and onBlur, as in Torque3D.

Testing

  • CI: green on all 9 jobs at 6d2423f7 (Windows ×4, Linux x86_64, Linux 32-bit, macOS, iOS, Android).
  • Unit tests: 383 pass on Linux, including new suites for the key table, typed-text decoding, text-box input and first-responder handling.
  • Linux desktop (Arch, Hyprland 0.56, Xwayland), driving the real editor with XTest input:
    • windows: tiled by default, floating at an exact size, SUPER+F in and out, SUPER+T, a fullscreen start;
    • typing: ASCII, compose-key accents stored as UTF-8, Backspace and arrows over multibyte characters, key repeat;
    • the clipboard both ways, wheel scrolling;
    • message boxes in Debug and Release;
    • no binding fired while typing, and no text leaked after the console closed.
  • Web, in headless Chromium over the DevTools protocol:
    • the editor boots;
    • typing ASCII and café naïve into the console after choosing a project;
    • wheel scrolling;
    • canvas resize.

Not tested:

  • stacking Linux desktops (GNOME, KDE);
  • Windows and macOS behaviour of the GUI changes, beyond compiling in CI;
  • the TorqueScript integration suite (tests/run.ps1).

Notes for reviewers

  • engine/lib/sdl is 28 MB of vendored SDL source. Filter it out when reading the diff.
  • Torque3D bugs deliberately not copied are noted in the commit messages: a UTF-8-into-UTF-16 memcpy, a 256-entry scancode table, a left Shift reported as both, a Retry button returning OK, a clipboard leak.

Follow-ups, not in this PR

  • Three existing bugs in engine/source/string/unicode.cc, worked around rather than fixed. oneUTF16toUTF32 drops the 0x10000 offset, oneUTF8toUTF32 writes through a NULL out-pointer on ASCII, and isSurrogateRange excludes both ends.
  • Native Wayland, which first needs the fonts moved from Xft to FreeType.
  • Wiki updates for the windowedRes rule, the Linux build dependencies, and global key bindings in text boxes.
  • On-screen and remote virtual keyboards map to the wrong keys under SDL 2's X11 driver; real keyboards are fine.
  • Web: getDoubleClickTime answers 3,000,000 ms.

🤖 Generated with Claude Code

https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp

greenfire27 and others added 10 commits September 12, 2026 11:32
On Hyprland the engine's window did whatever the window manager made of it. It
was tiled whatever size the game asked for, and floating it (SUPER+T) brought
back an 800x600 that no preference named: InitOpenGL created the window from
$pref::Video::resolution, which nothing sets before the canvas exists, and a
tiling window manager remembers the size a window first appeared at.

The window is now created at the size canvas.cs is about to ask for, and
$pref::Video::windowedRes says whether that size matters. Empty means it does
not: the window opens resizable at defaultResolution and a tiler is free to
tile it. A size means exactly that size. The window is created fixed-size, so
its minimum and maximum size hints are in place before it first maps, which is
what makes Hyprland -- and i3 and sway -- float it. The hints are held for
500 ms and then cleared straight on the X server, so the player can still
resize it and no texture is reloaded to do it. Clearing them at once loses a
race: Hyprland decides a moment after the map, and a window whose hints are
gone by then is tiled. Holding them 100 ms was already enough.

Following the window manager took a second X connection. Genuine SDL 1.2 draws
into a child of the window the window manager manages, so anything read off
SDL_SysWMinfo's window was reading the wrong one; wmwindow is the toplevel. And
sdl12-compat, which distributions now ship as SDL 1.2, passes on no X events at
all -- not even the resizes while the window manager has the window
fullscreen. The back-end now selects structure and property events on the
toplevel itself, and settles a resize or a _NET_WM_STATE change the way a drag
was already settled. SUPER+F is followed into and out of fullscreen: under
sdl12-compat by going fullscreen with the window manager, since a windowed
SDL_SetVideoMode there asks to leave again; under genuine SDL 1.2 as a plain
resize, because its SDL_FULLSCREEN puts up an override-redirect window of its
own that the window manager never sees.

canvas.cs now honours $pref::Video::fullScreen on Linux, keyed on
$platformUnixType because the web build calls itself x86UNIX too. That is right
under sdl12-compat and wrong under genuine SDL 1.2 on Wayland, where the game
draws unscaled in a corner. This branch moves the engine to SDL 2 before it
merges, and SDL 2 does fullscreen through the window manager everywhere.

The editor's preferences leave windowedRes empty, so the editor tiles; new
projects keep AppCore's 1024x768 and float at it. Windows and macOS read
windowedRes when they create the window too, so both now fall back to
defaultResolution for an empty one rather than opening at a placeholder and
resizing. Video::getDesktopResolution on Linux also had width and height the
wrong way round.

Tested on Hyprland against both SDL 1.2s -- a hand-built genuine 1.2.15 and
Arch's sdl12-compat 1.2.68 -- with an empty and an explicit windowedRes,
SUPER+F in and out, and SUPER+T. Not tested on a stacking desktop, and the
Windows and macOS changes have only been compiled by CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
The Linux back-end was written against SDL 1.2, which now comes in two
implementations that disagree about exactly what a window manager cares
about. Genuine 1.2.15 draws into a child of its toplevel and goes
fullscreen with an override-redirect window of its own; the sdl12-compat
shim that distributions ship as SDL 1.2 passes on none of the window
manager's events, and not even its resizes while it holds the window
fullscreen. This week's tiling work needed a second X connection just to
see what Hyprland had done to the window.

It now runs on SDL 2.32.10, vendored in engine/lib/sdl from Torque3D's
tree (without SDL's 51 MB of tests) and built as a static library, so
the engine still ships as one executable. It is the same version
Emscripten's port gives the Web build, which moves to SDL 2 next.

- One window and one GL context for the life of the process. Resizes,
  tiling and fullscreen go through SDL_SetWindowSize and desktop
  SDL_SetWindowFullscreen around the context already there, so the 150 ms
  settle delay and the texture reload on every mode change are gone, with
  the second X connection and the check for which SDL 1.2 was linked.
- The exact-size rule stays. A window with an explicit windowedRes is
  created fixed-size and made resizable after 500 ms; the phase-0
  prototype showed Hyprland needs the hold (released at 0 ms, the window
  tiled three runs in three).
- SDL 2 never flags a fullscreen the window manager imposes, only the
  resize, so _NET_WM_STATE is read through SDL's own X display to keep
  isFullScreen() and $pref::Video::fullScreen true. toggleFullScreen() no
  longer demands that a fullscreen size be in the resolution list, which
  refused whatever size a drag or a tiling layout had left.
- One SDL_PollEvent loop a frame; keyboard, text and mouse events go to
  UInputManager::processEvent.
- engine/source/platformSDL, shared with the Web back-end: Torque3D's
  scancode key table, sized to SDL's scancode range (Torque3D's 256
  entries let media keys read past the end); modifiers reported per side
  (Torque3D reports a left Shift as both); key names and bind-by-character
  through the current layout, Torque3D's a78235dd fix without its detour
  through key names; the SDL_TEXTINPUT bridge, posting each UTF-16 unit
  as a character-only KEY_NULL event and decoding the UTF-8 properly
  (Torque3D copies the bytes into 16-bit slots), with text input switched
  at the end of the frame as Torque3D switches it; SDL's clipboard (freed,
  where Torque3D leaks it); and Torque3D's message box, whose Retry
  button returned OK.
- The mouse wheel is SDL_MOUSEWHEEL, because SDL 2 gives buttons 4 and 5
  to the side buttons.
- x86UNIXMessageBox, the Xlib clipboard and the Xlib keymap reader are
  deleted.

CI and the build scripts install SDL's X11 build headers instead of
libsdl1.2-dev; libxext-dev is the one SDL insists on.

Tested: 348 unit tests pass, 16 of them new (the key table, modifiers,
and UTF-8 decoding including surrogate pairs and malformed bytes). On
Hyprland the editor tiles with its own preferences, floats centred at an
explicit 1024x768 and stays resizable, and follows SUPER+F in and out,
SUPER+T, and a fullscreen start. Typing, the clipboard and alert dialogs
were not driven on the desktop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
GuiTextEditCtrl keeps its text as UTF-8 with the caret as a byte offset,
but inserted a key's character as string(1, event.ascii): one byte cut
from a UTF-16 code unit. 'e'-acute became the lone byte 0xE9, and
anything past U+00FF kept only its low byte.

A keyboard event's ascii is now treated as one UTF-16 code unit and
inserted as its UTF-8, through the engine's own oneUTF32toUTF8 (so a
character beyond the BMP pairs across two events and comes out as
U+FFFD, as everywhere else in the engine). The pair is combined here
rather than by oneUTF16toUTF32, whose pairing omits the 0x10000 offset
and reads U+1F600 as U+F600; and oneUTF8toUTF32 is always handed a
unitsWalked pointer, because its ASCII fast path writes through it
without the NULL check the rest of the function has. The caret, Backspace, Delete,
the arrows, Shift-selection, overwrite mode and click positioning all
step whole UTF-8 characters, so nothing lands inside one.

Both input models work. A key-down carrying its character (Windows,
macOS, the SDL 1.2 Linux back-end) is typed as before; a key-down with
ascii 0 still does what the key does and types nothing; and a
character-only event -- keyCode KEY_NULL, the model the SDL 2 Linux and
Web back-ends will use and Windows IME already uses -- goes straight to
insertion, never through the Ctrl/Alt shortcut handling. AltGr (left
Ctrl + right Alt with a printable character, as Windows reports it) is
typed rather than treated as a Ctrl shortcut, and control codes are
never inserted, which also stops Ctrl+Shift+letter typing one on
Windows.

KEY_NULL character events are kept out of the script onKeyDown/onKeyUp
callbacks (they have no key name, and asking for one logs an error per
character) and out of GuiInputCtrl's key capture.

The pure parts are statics -- isTypedCharacter, isCharacterEvent,
isAltGrCharacter, composeTypedCharacter, and GuiTextEditSelection's
next/previousCharacterStart -- tested in guiTextInputTests.cc along
with the editing paths through onKeyDown; the font check is a virtual
the test subclass stands in for, since the unit tests cannot load a font.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
The Web back-end ran on Emscripten's JavaScript SDL 1.2, which reports
keys but not the characters they type. Text came from a US-only table
that dropped anything outside printable ASCII, so "café naïve" arrived as
"caf nave". The wheel handler was commented out, the canvas never
followed the browser, and getDesktopResolution() answered from a
1024x768 desktop made up in platform.js.

It now runs on Emscripten's SDL 2 port -- 2.32.10, the version the Linux
build vendors -- and shares the Linux build's input: platformSDL's
scancode table and SDL_TEXTINPUT bridge, and the same input manager.

- One canvas and one WebGL context, made by SDL 2 for the life of the
  page. LEGACY_GL_EMULATION hooks the context SDL creates through
  Emscripten's EGL, which renders identically (the port's phase-0
  prototype passed 12 of 12 pixel checks), and the build stays on WebGL
  1, which the emulation requires.
- A page that sizes #canvas with CSS decides its size, and the canvas
  follows the browser window; otherwise it opens at windowedRes or
  defaultResolution, as on Linux. The desktop resolution is the real
  screen.
- One SDL_PollEvent loop. The copied event list and its re-entrancy
  workaround, TORQUE_SETVIDEOMODE and the unused alert helpers are gone,
  and so are two 200 ms background sleeps that SDL 2's focus-loss events
  would now wake: in a page, a sleep is a busy-wait.
- SDL 2 really hides the pointer, where the JavaScript SDL 1.2 did not,
  so only Input::setCursorState hides it -- while the canvas draws its own.
- Message boxes stay in EmscriptenAlerts.cpp and platform.js: SDL 2 has
  no SDL_ShowMessageBox in a browser.
- Gamma, minimize and the accumulation-buffer attributes are gone as
  no-ops, and EmscriptenEvents.cpp, which held only stubs, is deleted.

Release wasm grows from 3,161,157 to 3,527,632 bytes, 103 KB gzipped;
the preloaded .data is unchanged.

Tested in headless Chromium through the DevTools protocol, with real
key, mouse and wheel events: the editor boots to the Project Manager in
Debug and Release; a typed echo(...) runs and "café naïve" arrives
intact; three wheel notches scroll a list 90 px; resizing the browser
window resizes the canvas and the GUI. The Linux build still builds, and
its 372 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
Two faults stood in front of every message box on the SDL 2 Linux build.

ProcessControlInit ignored SIGCHLD, so that a child the engine started
would not linger as a zombie. That also has the kernel reap every child
the moment it exits, before anything can wait for it -- and SDL shows its
X11 message box from a forked child and waits for it. The wait failed,
and a debug build stopped on SDL's assertion (SDL_x11messagebox.c:857,
rc == pid) and exited with status 42 as soon as a script called
messageBox(). SIGCHLD is now left at the default: everything that starts
a child waits for it, and the one thing that didn't,
Platform::openWebBrowser, now does.

Behind that, SDL's X11 box is drawn in the X server's core fonts and asks
for one 12-point size of them, which a Wayland-first desktop may not
install at all; this Omarchy machine has none. SDL can show a box with
zenity instead -- its Wayland driver does -- but it only turns to another
driver's box before its video has started, never with a window open. So
when SDL_ShowMessageBox fails, sdlMsgBox.cpp runs zenity itself, adapted
from SDL 2.32's SDL_waylandmessagebox.c, and prints the alert only if that
fails too.

openWebBrowser goes through SDL_OpenURL (xdg-open), as Torque3D does, and
still honours $Pref::Unix::WebBrowser, starting that browser from a child
that exits at once so there is nothing to wait for. Every browser it used
to try was started with argv[0] = NULL, which ends the argument list
before the address.

Tested with a Release build on Hyprland: messageBox(..., "OkCancel")
shows the zenity box and Return answers 1; gotoWebPage() with
$Pref::Unix::WebBrowser set to a stand-in script hands it the address;
and no child is left a zombie. A standalone SDL 2.32.10 program shows the
same X11 failure without the engine ("No message system available"), and
"msgbox child process failed" once SIGCHLD is ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
Under SDL 2 a key press arrives without its character -- the character
follows as an event of its own -- so a text box that took its text from
the key press no longer had a reason to consume the key. It passed every
key on, and the game's action maps fired while the player typed: q in a
chat box typed a q and fired whatever was bound to q. A key that types
now belongs to the text box that has the keyboard (letters, digits,
space, punctuation and the number pad, alone, with Shift or with AltGr);
keys that type nothing and Ctrl or Alt shortcuts go on as before; and a
release is never consumed, since a map only fires a release for a press
it took.

The desktop test that found it also found text input left on after the
console closed. Text input was tied to any awake text field rather than
to the keyboard: a focused field that went to sleep was never told it
had lost the keyboard, and GuiCanvas::setFirstResponder(NULL) -- which
popDialog calls when the last dialog goes -- did nothing at all, so the
old control kept the canvas's pointer. Text input now follows the
keyboard alone, as in Torque3D, which never had the count. Hiding or
deactivating a control that holds the keyboard, directly or anywhere
above it, lets go through the canvas so the control hears it; sleep and
removal let go silently, because the callback runs script mid-pop; and
the canvas drops a responder that isn't awake and visible before
sending it a key. None of these gaps is new -- on Windows and SDL 1.2 a
hidden field still holding the pointer would have been typed into as
well -- SDL 2 only made them visible.

Tab told the old field it had lost the keyboard a second time, after
the new one had taken it, so its AltCommand ran twice and, on SDL, its
request to turn text input off came last and the box Tab moved to
couldn't type. The extra calls are gone, as they are in Torque3D.

A key bound in the GlobalActionMap works while a text box has the
keyboard, and types nothing -- Torque3D's rule. The text box leaves it
to the map, and on SDL the text queued behind the key is thrown away by
turning text input off for a moment (ActionMap::getGlobalMap and
isAction find the binding as processButton would). Torque3D also skips
every other action map while text input is on; that part isn't copied,
because the editor's Ctrl+` console toggle is an ordinary ActionMap.

Tested: 383 unit tests pass, 11 of them new (guiTextInputTests.cc and
guiFirstResponderTests.cc). On Hyprland, before and after: z pressed
with the console closed produced text, and now produces none; q in a
Toy Box value field typed a q and fired a q binding, and now only types;
Tab then 7 lost the 7, and now types it; a key bound in the
GlobalActionMap fires and types nothing. The Web input manager compiles
with emcc; the Web build itself was not re-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019qUbGQ6PD6Mqtvnxn67CLp
…features

SDL's configure leaves out each X11 extension whose headers it can't
find, and says nothing: on an Ubuntu box with only libx11-dev and
libxext-dev the engine built and ran, but its SDL had no XRandR, Xcursor,
XInput2, XFixes or screensaver support, and getDesktopResolution()
answered 3840x1080 for two 1920x1080 monitors. The packages were listed
in the build scripts and CI, but nothing required them.

engine/lib/CMakeLists.txt now looks for every one of those headers before
SDL does and stops with the missing packages named, and the commands that
install them on Debian/Ubuntu, Arch and Fedora. It uses the result
variables SDL's own find_file calls use, so SDL reuses what is found,
and one left NOTFOUND is searched for again on the next configure:
installing the package and configuring again is enough.

Tested: a fresh configure on Ubuntu 22.04 without the five packages stops
and names exactly those five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQuhage1yEjBVuCFyB5Edm
Two conflicts, both where development and this branch changed the same
lines. GuiControl::onSleep keeps development's reset of mFittedWidth and
this branch's quiet clearFirstResponder(this), which lets go of the
keyboard without running script from the middle of a sleep. CHANGELOG.md
keeps both sets of Fixed entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQuhage1yEjBVuCFyB5Edm
The merge of development stored guiControl.cc with LF endings where
development stores CRLF, so the whole file -- 3,000 lines -- read as
changed against development for the 65 that were. The content is the
merge's exactly; only the endings are development's again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VfbP8wSFrZusjvSHHfv5z5
android-actions/setup-android@v3 installs "tools platform-tools" by
default, and the legacy "tools" package is gone from the SDK repository:
the action's own sdkmanager call fails with "Failed to find package
'tools'" before the build starts. Nothing in the engine is involved --
the same workflow passed on this branch on 12 September, and this was the
repository's first CI run since.

The action's v4 installs platform-tools alone (v4.0.2, "Fix for removed
tools package"), on node24 and a current cmdline-tools. The NDK and CMake
are still installed by the step after it, which needs only sdkmanager on
the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQuhage1yEjBVuCFyB5Edm
@greenfire27
greenfire27 merged commit 2167e75 into development Sep 19, 2026
18 checks passed
@greenfire27
greenfire27 deleted the linux-sdl2-tiling branch September 19, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant