Skip to content
setdefPublic

About

Lota is currently in a pre-alpha, technical preview stage.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

65 Commits

Folders and files

Repository files navigation

Lota

Certified Shovelware

Lota lets you make 2D games in Ruby. It is an independent project, not made, endorsed, or supported by the LÖVE Development Team. It builds a modified version of the C++ engine that LÖVE games run on and drives it from Ruby instead of Lua, so a game behaves the same in both, and a LÖVE tutorial still reads true once its calls are translated by a handful of rules.

The name comes from the Lota Bowl, a classic Indian magic trick: the magician empties a brass vessel, and it fills itself again, and again, as often as it is poured out. A game loop does the same, every frame.

Lota is a plan, not yet a gem you can install. When it is, its gems will be for Apple Silicon Macs and for Linux with glibc on x86_64 and arm64. Contributors start with ADR 001, which records what Lota is and why, and Plan 001, which orders the work.

Running a game

A Lota game is a folder, as a LÖVE game is (ADR 003). main.rb defines the game's callbacks as methods on Lota, and an optional conf.rb changes its settings before the window opens:

# conf.rb
def Lota.conf(t)
  t.window.title = "My Game"
  t.window.width = 640
  t.window.height = 480
end

# main.rb
def Lota.load
  @x = 0
end

def Lota.update(dt)
  @x = (@x + 120 * dt) % Lota::Graphics.width
end

def Lota.keypressed(key)
  Lota::Event.quit if key == :escape
end

Run it with lota run path/to/game, or lota run from inside the folder. Arguments after the folder reach Lota.load. Lota opens the modules and the window your settings ask for, in the order LÖVE's boot.lua does, then runs Lota.run, translated from love.run: each frame handles queued events, calls update(dt), clears the window to the background color, calls draw, and shows the result. Every callback is optional, and each receives only as many of LÖVE's arguments as it declares, with nil for a required parameter beyond them, as in Lua, while an optional parameter keeps its default. Numbers LÖVE holds as integers, such as a mouse button or a resized window's size, arrive as Integers, and the rest as Floats. lota is always there, so lota.graphics and def lota.update(dt) read as a LÖVE tutorial's love.graphics and function love.update(dt) do; as in LÖVE, lota.timer is nil when your settings turn the timer off (t.modules.timer = false). Settings LÖVE does not know are accepted and ignored, as LÖVE ignores keys it does not read, and a setting never set reads as nil, as a missing key does in Lua. A game whose t.version names a version of LÖVE's engine other than the one Lota builds on gets LÖVE's compatibility warning, printed and shown in a dialog before the window opens, as LÖVE's boot.lua warns a game made for LÖVE 11. Lota::Event.poll returns an Enumerator of the queued events, or takes a block, where LÖVE's returns an iterator for Lua's for, and an exit status such as Lota::Event.quit(3)'s is an Integer. Every engine object includes Lota::Object, LÖVE's name for its root type, and answers object_type with LÖVE's name for its type ("Texture") and type_of? for that type and its parents ("Object"), as LÖVE's type and typeOf do. type is another name for object_type, except on a class that also has LÖVE's getType, a Body, Shape, Joint, Source, or Cursor, which answers that as get_type (body.get_type is :dynamic) and has no type at all, so a call meant for one never answers the other. The game's folder is on $LOAD_PATH, so require "player" loads the player.rb beside main.rb, and the working directory stays where you ran lota run. Files your game names are read from its folder too, as LÖVE's filesystem reads them, so Lota::Graphics::Font.new("assets/pixel.ttf", 16) finds the font in the game's assets folder wherever you started it. What your game saves goes to its save directory, where LÖVE's would go but for one folder (ADR 003, section 8): ~/Library/Application Support/Lota/<identity> on macOS, where LÖVE's love program saves in a LOVE folder, and $XDG_DATA_HOME/lota/<identity> on Linux, or ~/.local/share/lota/<identity> without XDG_DATA_HOME, where love saves in a love folder, the identity being your game folder's name until conf.rb sets t.identity. A shipped game, fused into an app of its own, saves in ~/Library/Application Support/<identity>, or on Linux ~/.local/share/<identity>, as LÖVE's fused game does. Lota::Filesystem.lines returns an Enumerator where LÖVE returns an iterator. A function that returns several values returns an Array of all of them, also when it fails, so contents, size = Lota::Filesystem.read("save.dat") reads a file, Lota::Filesystem.read("missing.dat") returns [nil, message], and if Lota::Filesystem.write("save.dat", data).first asks what Lua's if love.filesystem.write(...) asks. A table LÖVE returns as a record is a frozen Data with LÖVE's field names, so Lota::Filesystem.info("save.dat")&.size is the file's size, or nil when there is no such file. A record also answers [] with a field's name, as a Lua table answers brackets, so Lota::Graphics.texture_types["2d"] reads the one field no method can be named. A table of settings a function takes is keywords, so Lota::Graphics::Canvas.new(1024, 768, mipmaps: :auto) makes a canvas whose mipmaps keep it from shimmering when drawn smaller, and Lota::Graphics.set_canvas(mask, stencil: true) draws into a canvas with a stencil buffer, where LÖVE takes setCanvas({mask, stencil = true}); Lota::Graphics.canvas = mask is the plain form, and a Texture the engine hands back, as Lota::Graphics.canvas does, is the same Ruby object you made. A setting or argument LÖVE 12 still accepts but has deprecated, such as t.window.highdpi, works as it does in LÖVE, and its first use prints LÖVE's notice of what replaced it, unless the game is fused, as LÖVE prints it. A function LÖVE keeps to call later takes a block or anything that answers call, as Signal.trap does, so Lota::Graphics.capture_screenshot { |image_data| ... } receives the next frame the window shows. Sounds come from Lota::Audio::Source.new("click.wav", :static), which LÖVE's OpenAL Soft plays, and Lota::Sound::SoundData.new("click.wav") decodes every sample of one for your game to read or change, as a Float from -1 to 1 (sound.sample(0), sound.sample = 0, 0.5, by rule 6), with LÖVE's LuaJIT arithmetic, while Lota::Sound::Decoder.new("music.ogg") decodes one a piece at a time, and either plays as a Source, as Lota::Audio::Source.new(sound) plays a SoundData you made, and positioned against the listener, as source.position = x, y and Lota::Audio.position = x, y place a mono sound and its hearer; where there is no audio device, as on some build servers, LÖVE prints Could not open device. and opens its silent audio module instead, and so does Lota. Every Data, a SoundData, an ImageData, or a FileData, includes Lota::Data, LÖVE's root type Data, for its size, its bytes as a string, and the numbers in them, and clone or dup of one of these, a Decoder, or a Source makes LÖVE's copy, a new engine object of its own. Where LÖVE runs LuaJIT code of its own in place of a method's C++, as for ImageData#pixel, pixel=, and map_pixel, Lota does what the LuaJIT code does, in double precision. Lota::Physics is LÖVE's physics, Box2D: make a Lota::Physics::World, give it Bodys with CircleShapes, RectangleShapes, or other Shapes, join them with Joints, and call world.update(dt) each frame; distances are in pixels, divided by Lota::Physics.meter, 30 by default. A Body, Shape, or Joint read back, as from world.bodies or body.shapes, is the same Ruby object you made, and keeps the user_data you give it. world.callbacks = begin_contact, end_contact, pre_solve, post_solve sets the four functions LÖVE's setCallbacks takes, each anything that answers call (a lambda, a Method, an object of your own) or nil, and world.contact_filter = ->(a, b) { ... } decides which Shapes collide; each receives only as many of LÖVE's arguments, the two Shapes, the Contact, and post_solve's impulses, as it declares, as your game's callbacks do, and world.callbacks gives back exactly the four you set.

An error in your game shows LÖVE's error screen in its window, as LÖVE's love.errorhandler does (ADR 003, section 7): the error and its Ruby backtrace, where LÖVE shows a Lua traceback, in white on LÖVE's blue, until you press Escape or close the window, which exits with 1. Ctrl+C copies the error. The error and its backtrace are printed too. Define Lota.errorhandler(error) to replace the screen, as a LÖVE game defines love.errorhandler: it receives what your game raised, and returns a frame for Lota to call until the frame returns an exit status, or nil to exit with 1.

Using a checkout from your game

Until there is a gem to install, a game can use a checkout of Lota through Bundler. Clone Lota and build its extension as contributors do (see Development, below: bin/setup, then bundle exec rake compile, which puts the compiled extension in the checkout's lib/lota), then point your game's Gemfile at the checkout:

# Gemfile
source "https://rubygems.org"

gem "lota", path: "../lota"

Run bundle install in the game's folder, then bundle exec lota run .. Run bundle exec rake compile in the checkout again after each pull, since Bundler loads the checkout's lib as it is and builds nothing itself.

Development

Clone with submodules. vendor/love holds the LÖVE source Lota builds against, vendor/love-apple-dependencies holds the prebuilt libraries LÖVE links on macOS (SDL3, FreeType, HarfBuzz, OpenAL Soft, and the rest), and vendor/love-appimage-source holds the Makefile LÖVE's own Linux CI builds those libraries from source with. bin/setup also runs bundle exec rake sources:fetch, which downloads the source archive of each framework or library whose license asks the gem to carry its source, so far OpenAL Soft's 1.24.3 release (about 1 MB), into vendor/sources, which git ignores, and keeps it only when its SHA-256 is the one ext/lota/licenses.rb records; on Linux it also runs the Makefile's downloads, the release of each library it builds, into vendor/sources/linux. The gate never downloads anything:

git clone --recurse-submodules https://github.com/setdef/lota.git
bin/setup
bundle exec rake

bin/setup installs the pinned tools from mise.toml, CMake, Ninja, clang-format, and clang-tidy among them, and on Linux patchelf. On Linux, first install the packages LÖVE's own Linux CI installs to build its dependencies (the apt-get install in .github/workflows/ci.yml's Linux gate, for Ubuntu 22.04 or newer), and run the tests on a display: the CI runs them on Xvfb with Openbox, as LÖVE's runs Lövetest. mise compiles Ruby from source with ruby-build, which needs its suggested build environment, because mise's precompiled Ruby for macOS, from jdx/ruby, is built without ZJIT, and rake bench measures ZJIT. Ruby builds ZJIT only with Rust 1.85 or newer, so mise first installs the Rust that mise.toml pins and compiles Ruby with it; an install that finds no Rust fails rather than leave ZJIT out. Compiling takes about three minutes, once. mise does not compile a Ruby of the pinned version it installed before; mise install --force ruby replaces it, and ruby --zjit -e 'p RubyVM::ZJIT.enabled?' prints true from a Ruby that has ZJIT. bundle exec rake builds LÖVE's engine and the Lota::Native extension into tmp/engine/build, on Linux after rake dependencies has built LÖVE's dependencies into tmp/dependencies with that Makefile, from what sources:fetch downloaded, as LÖVE's Linux CI builds them (tasks/linux_build.rb), runs the tests (the native ones a second time under AddressSanitizer and UndefinedBehaviorSanitizer), then runs the C++ gates over Lota's own C++ in ext/. A clean run takes about six minutes, most of it compiling the engine twice and building the reference love the parity harness compares Lota with; a run with nothing to rebuild takes under a minute. To work on the native side alone:

  • bundle exec rake compile builds the engine and the extensions and copies Lota::Native to lib/lota/native.bundle. rake-compiler runs ext/lota/extconf.rb, which configures the CMake project in ext/lota and writes a Makefile that builds it. The source gem's gem install runs the same script, which stops there, since the gem ships no LÖVE sources, and names the precompiled gem. CMake decides what to rebuild, so rake compile always calls it, and a build with no changes takes a second or two.
  • bundle exec rake test:native rebuilds everything with ASan and UBSan into tmp/sanitize/build and runs test/native, test/lovetest, and test/sanitizers against it. ASan has to be in the Ruby process before anything it instruments, so the task starts Ruby directly with DYLD_INSERT_LIBRARIES; a shell or a binstub in between would drop it, because macOS removes DYLD_ variables when it starts a protected binary such as /bin/sh. On Linux the sanitizers are GCC's, which builds LÖVE's Linux engine, loaded with LD_PRELOAD together with the C++ runtime, which ASan's interceptor of C++ exceptions must find when it starts, before Ruby, which is C, loads it for an extension, and with LeakSanitizer off, as macOS's ASan runs without it. test/sanitizers holds deliberate memory bugs, and only this task runs it.
  • LÖVE's physics module holds Lua in its engine classes, so Lota changes it with its one patch, ext/lota/engine/physics.patch (ADR 001, section 2): when CMake configures the engine it copies vendor/love/src/modules/physics into the build tree's patched/love, applies the patch with patch, and compiles the copy, whose headers come before LÖVE's own, so vendor/love is never written to. A file the patch no longer applies to stops the configure. The patched files keep LÖVE's code, and replace each read or push of the Lua stack with C++ parameters and results, and each Lua reference with the Reference interfaces in the patch's new Reference.h, which ext/lota/native/physics.cpp implements. To change it, apply it to a copy of the module, edit the copy, and write the patch again with diff -ruN, paths starting a/src and b/src.
  • bundle exec rake engine:sources regenerates the list of LÖVE sources the engine compiles on the platform it runs on, ext/lota/engine/sources_macos.cmake or sources_linux.cmake, after a bump of vendor/love. rake engine:sources:check fails until you do, so review the difference before you commit it; each platform's list is checked on that platform.
  • bundle exec rake bindings:generate writes the bindings ext/lota/bindings.rb lists from LÖVE's wrap_*.cpp files, by ADR 001's rules and the names binding_exceptions.rb records, into ext/lota/native/generated and sig/lota/native/generated.rbs (tasks/bindings), and the Ruby name the rules give every function of LÖVE's API into lib/lota/error_message/names.rb, a frozen Hash that every Ractor can read, which Lota::ErrorMessage translates the engine's error messages with as Ruby raises them. Those files are never edited by hand. Where LÖVE replaces a wrap function's C++ with LuaJIT code of its own, in a wrap_*.lua file, the generator knows it, refuses to port the C++, and bindings.rb lists the function with bound_from_lua, for Lota's hand-written port of the Lua. rake bindings:check fails, before the engine builds, until the committed files match what the generator writes. rake "bindings:report[tmp/report.txt]" lists every LÖVE function that fits no rule, fits several, or leaves a rule's decision open, and every function the parser cannot read, each with its file, line, and why.
  • bundle exec rake package:native builds the precompiled gem for the platform it runs on, pkg/lota-<version>-arm64-darwin.gem, or on Linux x86_64-linux-gnu or aarch64-linux-gnu, from rake compile's bundle (tasks/native_gem.rb). On Linux it carries the shared libraries the bundle needs, and those they need, in lib/lota/libraries, each under the soname it is needed by, without its debugging information, its rpath pointed by patchelf at that folder ($ORIGIN) as the bundle's is, as LÖVE's AppImage carries them, relying on the system for glibc and GCC's C++ runtime alone (tasks/native_format.rb); its platform names glibc, so RubyGems does not pick it on musl. What follows describes the macOS gem; the Linux gem's record is the same in its own terms. It carries the bundle, the frameworks it loads (SDL3, FreeType, HarfBuzz, OpenAL Soft, libmodplug, libvorbis, and the libogg libvorbis loads, arm64 only and without headers) in lib/lota/frameworks, and a record of who wrote them under which licenses, by the REUSE Specification as the repository is: its REUSE.toml is the repository's with an annotation for the bundle and one for each framework, written from ext/lota/licenses.rb, and its LICENSES holds the text of every license its files use, the ones only the gem uses coming from ext/lota/licenses. The bundle's annotation names the libraries the linker put in it, which the build reads from the linker's map (lib/lota/native.map in the build tree) and ninja's dependency log. The bundle's rpaths, which point into vendor/, are replaced by @loader_path/frameworks, and the bundle and every framework are signed again ad hoc and verified with codesign --verify --strict. A framework, or a file the bundle was compiled from, that ext/lota/licenses.rb does not record stops the build, and so does a gem reuse lint does not call compliant. OpenAL Soft is a framework of its own, which the bundle links as LÖVE's macOS build does, so a player can replace it, and its license, the GNU Library General Public License, asks for its source with every copy of its object code, so the gem carries the release's source archive in sources/, with an annotation of its own in REUSE.toml naming every license and notice in it; the build copies it from vendor/sources after checking its SHA-256, and stops without it.
  • bundle exec rake package:check checks that the source gem's gem install stops with that message, then runs reuse lint on the precompiled gem's unpacked package, checks that each framework whose record names a source archive is in it with its annotation and the archive, the one recorded, and loads the gem from it. rake package:install builds the precompiled gem as package:native does, then delivers it as rake "package:deliver[path/to/lota-<version>-arm64-darwin.gem]" delivers a gem built elsewhere: it installs the gem into an empty GEM_HOME that sees no other gem, runs lota --version, and runs lota run on two games: one that opens a window and quits with a status of its own, and test/lovetest/game, which runs the translated Lövetest tests against LÖVE's expected images, with Minitest installed beside the gem. Each run plays its sounds through OpenAL Soft's null backend (ALSOFT_DRIVERS=null), so it needs no audio device, and must end with its expected status and output, load Lota, its bundle, and SDL3 only from the installed gem, and load nothing from the repository's lib/, which a guard loaded into the run checks. dyld reports what a run loads on macOS; on Linux the guard reports what the run mapped (/proc/self/maps), and every library that is neither the system's nor Ruby's must be the installed gem's. package:deliver builds nothing, so it runs where the gem was never built. Both checks fail on any warning gem build prints. They take seconds, and bundle exec rake runs package:check and package:install.
  • bundle exec rake cpp runs cpp:format, cpp:no_nolint, and cpp:tidy; rake cpp:format:fix rewrites the layout clang-format reports. cpp:tidy starts a clang-tidy for each source, as many at once as the machine has cores, as LLVM's run-clang-tidy does, since each of Lota's sources parses Rice's headers, which one clang-tidy for all of them did one after another.
  • bundle exec rake lint:workflows checks .github/workflows with actionlint, and each step's script with shellcheck. Both are pinned in mise.toml, and the task fails without shellcheck, which actionlint would skip without a word.

GitHub Actions runs .github/workflows/ci.yml on every push and pull request, on GitHub's Apple Silicon runners with macOS 15 (macos-15), whose session is logged in, so the tests open real windows there as on your Mac, and beside them on Ubuntu 22.04 runners for x86_64 and arm64 (ubuntu-22.04, ubuntu-22.04-arm), whose Linux gate and delivery jobs do on Linux what the macOS ones do, on a display of Xvfb's with Openbox, as LÖVE's own Linux CI runs Lövetest, and cache the libraries rake dependencies builds. The Gate job installs the Ruby that mise.toml pins with ruby/setup-ruby and the other tools in mise.toml with mise, runs bundle exec rake, fails if the gate changed a file (its lint:fix step corrects what it can, which would otherwise pass unnoticed), and builds the precompiled gem with rake package:native as an artifact. Compiled objects come from ccache, which reuses one only when the compiler, its arguments, and every file it reads are the same. The Delivery job then downloads that gem on a fresh runner that builds nothing and fetches only vendor/love, for LÖVE's expected images, installs the same Ruby, and runs rake package:deliver on it, so the walking skeleton's acceptance test, Lövetest's love.test.graphics.clear, passes from the installed gem. CI's Ruby comes from ruby/setup-ruby rather than mise, and mise skips both Ruby and the Rust it pins for compiling Ruby, because setup-ruby's Ruby is already built by ruby-build with Rust, as the Ruby mise compiles is, so it has ZJIT without compiling Ruby on every run.

Bindings live in ext/lota/native. Each one includes native/binding.hpp, never Rice's own header, because binding.hpp is where every love::Object gets LÖVE's memory model (ADR 001, section 5): a Ruby wrapper holds one reference from the moment Ruby wraps the object until the garbage collector frees the wrapper. ext/lota/native_test is a test-only extension, LotaNativeTest, that the tests load from the build tree; the gem never packages it. Lota::Native has no RBS generator yet, so its signatures in sig/lota/native.rbs are written by hand, and rbs:parity checks them against the compiled extension.

Graphics and window calls run only on the process's main thread, as LÖVE keeps love.graphics and love.window there, and raise ThreadError anywhere else. Ruby's garbage collector can free a wrapper on any thread, so a wrapper of a graphics or window object freed off the main thread hands its reference to a release queue, which the main thread drains with Lota::Native.drain_release_queue (and at exit). Every class bound from those two modules belongs in ReleasedOnMainThread in binding.hpp.

test/parity is the parity harness (Plan 001): it runs a published LÖVE game, unchanged, in a reference love built from vendor/love, and the game's Ruby translation in Lota, both with a fixed dt and the same scripted keys, and compares every frame the two show by Lövetest's rule. The games are pinned submodules under test/fixtures (the arkanoid tutorial, Gravity-Wars, and KleleAtoms, whose two runs must also save the same settings and game, byte for byte), which Lota's lint and REUSE checks and the gem leave out, as they leave out vendor/; their translations live in test/games. test/parity/lua/conf.lua is the harness's side in LÖVE: it mounts the game, wraps love.update to hold keys in SDL's keyboard state and pass the fixed dt, and wraps love.draw to save each frame with love.graphics.captureScreenshot. Random numbers come from LÖVE's own generator on both sides, as Plan 001 has them: the Lua side points math.random and math.randomseed at love.math.random and love.math.setRandomSeed, which Lota::Math.random and random_seed= port, and both sides fix the clock a game seeds from, os.time() in Lua and Time.now in Ruby, at the script's clock, so a game that seeds from the time draws the same numbers in both. Where a game needs a key press rather than a held key, as on Escape to quit, the script presses it: both sides also push SDL's key down and key up events into SDL's queue with SDL_PushEvent, which LÖVE's own event code turns into keypressed and keyreleased, and which, unlike a key macOS reports, does not need the window to have keyboard focus. A dialog either engine shows, such as LÖVE's warning that a game was made for another version, has Return pressed on it from inside the game's own process: a run loop timer in AppKit's modal panel mode, which runs only while a dialog is up, presses the dialog's default button, the one Return presses, through LuaJIT's FFI on the Lua side and LotaNativeTest.press_return_on_dialogs on the Ruby side, so it needs neither keyboard focus nor Accessibility permission. The mouse moves and clicks as the script says, on both sides: the pointer is held where the script's latest move put it with love.mouse.setPosition, which updates SDL's mouse state and queues mousemoved, and a click pushes SDL's button events at the pointer's position, which LÖVE turns into mousepressed and mousereleased. Both sides also fix love.timer.getDelta at the script's dt and seed LÖVE's generator with the script's clock before the game's load. Each run, LÖVE's and Lota's, has a home directory of its own (CFFIXED_USER_HOME), so a game that saves, as KleleAtoms saves its settings and a game, starts with nothing saved and leaves nothing in yours, and the runs make no sound and play the same with or without an audio device. OpenAL Soft's wave file writer, which the rest of the tests play into (test/alsoft.conf, as LÖVE's CI plays Lövetest's sounds), mixes on the wall clock, in steps of 20 milliseconds, so where a sound starts in its file depends on how fast the engine ran. So both runs load liblota_loopback (ext/lota/native_test/loopback.cpp, built beside the test extension) with DYLD_INSERT_LIBRARIES, which interposes OpenAL Soft's alcOpenDevice and alcCreateContext so that LÖVE's unchanged audio module gets a loopback device (ALC_SOFT_loopback), 32-bit float stereo at 48 kHz, OpenAL Soft's defaults, which mixes only when told to; after the game's update, each side tells it to mix the frame's dt, through LuaJIT's FFI in LÖVE and LotaNativeTest.render_loopback in Lota, into a file of the run's own. Mixing is then on game time, and KleleAtoms' two mixes, its chain reaction's overlapping explosions among them, must match sample for sample. Both sides also decode the sound files a script lists, KleleAtoms' and Lövetest's, with SoundData and Decoder when the game loads, and must decode the same samples, byte for byte. The test suite does the same for every test and every program a test starts (test/test_helper.rb), but for the loopback device. Their windows also stay in the background, so a test run takes no keyboard focus from what you are typing in: every test, and every program a test or rake package:install starts, LÖVE's among them, gets SDL's hints SDL_MAC_BACKGROUND_APP=1, SDL_WINDOW_ACTIVATE_WHEN_SHOWN=0, and SDL_WINDOW_ACTIVATE_WHEN_RAISED=0, and since AppKit activates a program that shows a dialog regardless, the code that presses Return on dialogs, on both sides, also sets the program's activation policy to prohibited, which keeps it from ever being activated while it still shows its windows and dialogs. A game you run gets none of this. Lövetest's love.mouse.setGrabbed test passes only for a window with keyboard focus, so it alone takes focus, for the moment it runs (test/native/focus_for_grab.rb), and package:install runs test/lovetest/game in the foreground. test/parity/probe.rb is the harness's side in Lota, which does what conf.lua does, loaded with ruby -r into lota run, and compares each frame with LÖVE's as it goes, reporting the first that differs and the pixel where, and keeping that frame, as each engine showed it, in tmp/parity-mismatches, which CI uploads when the gate fails. bundle exec rake test:parity runs it, after rake reference:love builds the reference into tmp/reference with LÖVE's own macOS CI commands (xcodebuild archive and export, read from vendor/love/.github/workflows/main.yml), compiling through ccache when it is on the PATH. That build needs Xcode itself, not only its command line tools. A clean build takes about two minutes, a build from ccache about 25 seconds, and a build of the same commits nothing. bundle exec rake runs both.

On Linux the harness does the same in Linux's terms. The reference love is the one LÖVE's own Linux CI builds, with love-appimage-source's Makefile, beside the dependencies Lota links (rake reference:love, tasks/linux_build.rb), and runs from its installdir, as the AppImage's own launcher runs it. A run's home is given to LÖVE's filesystem through XDG_DATA_HOME, under which it saves on Linux, so LÖVE saves in the run's .local/share/love and Lota in its .local/share/lota (test/test_home.rb). liblota_loopback goes in with LD_PRELOAD, whose own alcOpenDevice and alcCreateContext then come before OpenAL Soft's. Mesa's software renderer gives a window's OpenGL drawable a new size only when it shows a frame, so a game that resizes its window in load, as Gravity-Wars does, draws its first frame into a drawable of the old size, and captureScreenshot's glReadPixels leaves the rest of that frame as malloc gave it to LÖVE, which differs from one program and one run to the next; both runs set MALLOC_PERTURB_, so glibc fills that memory with the same byte in both. SDL shows a dialog on Linux with zenity, a program of its own, when zenity is installed, and otherwise in a window of its own X11 toolkit, on a connection of its own to the X server, so no code in the game's process can press a key on it: both runs load liblota_dialogs (ext/lota/native_test/answer_dialogs_linux.cpp) with LD_PRELOAD too, whose SDL_ShowMessageBox and SDL_ShowSimpleMessageBox come before SDL's and, once the harness asks, answer each dialog with the button SDL flags for Return, as Return would, without showing it, and count it, through LuaJIT's FFI on the Lua side and LotaNativeTest.press_return_on_dialogs on the Ruby side. X11 has no program to activate, only windows, and leaves whether one takes focus as it opens to the window manager, so SDL's hints change nothing there that a run on CI's display, where nobody types, would notice; the setGrabbed test raises its window with SDL's hint to activate it on for that raise, and afterward gives keyboard focus up to whatever window the pointer is over. The tests also share one display with the games they start, and SDL answers another program's request for the clipboard without guarding against that program's window being gone, which Xlib's default error handler would turn into the end of the test run, so the test extension lets that one error pass in the tests' own process (ext/lota/native_test/selections_linux.cpp).

test/lovetest holds LÖVE's own Lövetest tests (vendor/love/testing), translated to Ruby by ADR 001's rules and marked as Zlib snippets. They open a real window, as Lövetest does: SDL's offscreen driver cannot create a Metal or OpenGL context on macOS, so run them in a logged-in session, and on Linux on a display, where LÖVE draws with OpenGL, through Mesa's software renderer on CI's Xvfb, as LÖVE's Linux CI runs Lövetest. Each compares what Lota draws with LÖVE's expected image, by Lövetest's own rule.

Copyright & License

Lota is copyright 2026, Kerrick Long. The code is AGPL-3.0-or-later, and the documentation is CC-BY-SA-4.0. LÖVE is Zlib.

Some parts of this program, and of its precompiled gems, come from other sources under their own licenses, and their copyright belongs to their owners. Each file says which, by the REUSE Specification – Version 3.3. Each precompiled gem's REUSE.toml records them for the compiled extension, which holds LÖVE's engine and the libraries LÖVE bundles, and for each framework, or on Linux each shared library, the gem carries, and its LICENSES folder holds their texts.

The precompiled gem carries FreeType, which it uses under the FreeType License. As that license asks of a binary distribution: the software is based in part of the work of the FreeType Team.

Lota is an independent project. It is not made, endorsed, or supported by the LÖVE Development Team. It builds a modified version of LÖVE's engine (Zlib, © LÖVE Development Team), as described in ADR 001.

About

Lota is currently in a pre-alpha, technical preview stage.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages