Compatibility mode fixes
Compatibility mode runs an old game on a current engine. Coilbox writes a generated game that depends on the old one and adds fixes on top, and launches that instead. This page is for a game maker who wants to know what that generated game does to theirs, and for anyone adding a fix to coilbox.
For what a player sees and does, see Compatibility mode. That page also lists every fix in the newest version.
What coilbox writes
The generated game is a folder, coilbox-compat-<stem>-<n>.sdd, in the content folder's games/. <stem> is the old game's archive name in lower case without its extension, and <n> is the compatibility version, which is the number of the manifest it was built from. The old game's archive is never edited.
The folder holds:
modinfo.lua. The game is named<game> (compat <n>), its version is<n>, and it depends on the old game. It carries the old game'sshortname, so Lua that checksGame.gameShortNamesees the old game.compat/prelude-synced.luaandcompat/prelude-unsynced.lua, the Lua prelude for each Lua state.- The old game's Lua entry files, each with the prelude loaded first:
LuaRules/main.lua,LuaRules/draw.lua,LuaGaia/main.lua,LuaGaia/draw.lua,luaui.luaor elseLuaUI/main.lua, andLuaIntro/main.lua. Only the files the game has are written. TheVFS.Includeof the prelude goes on the original's first line, so every line keeps its number in an error. LuaRules/Gadgets/coilbox_compat_<name>.lua, one per gadget fix.gamedata/defs_post.lua, which points units, features and weapons at the converted models, runs the def fixes for that file, and then runs the engine's own copy.gamedata/weapondefs_post.lua, which runs the weapon def fixes and then the engine's own copy.- The converted models under
objects3d/, with their textures underunittextures/3do/. .coilbox-compat.json, which records the manifest version, the writer, the old game's checksum and the coilbox version that built it.
A game that ships its own gamedata/defs_post.lua or gamedata/weapondefs_post.lua would have it covered up by the generated one. So coilbox writes neither file for such a game, and the player's build result says so. The same happens when the game's own gamedata/defs.lua or gamedata/weapondefs.lua never loads the file.
The same bytes on every computer
Players in a multiplayer battle compare archive checksums. Coilbox writes the folder the same way on every computer from the same game and manifest: files sorted by path, LF line endings, and no timestamps or paths. A player on a different compatibility version then has a missing archive rather than a game that desyncs.
The record file is the one file that differs, because it names the coilbox version. Its name starts with a dot, and the engine leaves such files out of an archive's checksum (CreateIgnoreFilter in rts/System/FileSystem/ArchiveScanner.cpp).
Nobody downloads a generated game. A player who joins a battle on <game> (compat <n>) has coilbox build manifest <n> from their own copy of the old game.
How the fixes run in a game
Each prelude fix and def fix runs as a function inside pcall. A fix that fails logs coilbox compat fix <id> failed: <error> to the coilbox-compat log section, and the game's own Lua still loads.
The prelude's first fix, compat-callin-hooks, gives the others compat.define. That puts a function in place only when nothing is there already, so a game that defines its own keeps it.
Each gadget decides when the game loads whether the game needs it, from the game's own files. Every game built from one manifest gets the same gadget files. A gadget's name starts with coilbox_compat_, so it can be told apart from the game's own in the log.
Each def fix looks at every definition as the game left it, and changes only those that set the tag it is for.
The registry
The fixes are Lua files in lua/compat/:
- A prelude fix is a file directly in
lua/compat/. - A gadget fix is in
lua/compat/gadgets/. - A def fix is in
lua/compat/defs/. lua/compat/manifests/<n>.jsonis the manifest for compatibility version<n>.lua/compat/manifests/hashes.txtholds the SHA-256 of every fix a manifest lists.lua/compat/tests/holds the Lua tests.
crates/coilbox-compat/src/lib.rs embeds every one of these files with include_str!, in the MANIFESTS and FIXES lists, so a build reads nothing at run time. crates/tauri-plugin-coilbox-compat writes the folder.
A fix file starts with a header of -- key: value lines:
-- id: spring-unit-travel
-- catalogue: removed:Spring.GetUnitTravel, removed:Spring.SetUnitTravel
-- states: synced
-- license: MIT or GNU GPL, v2 or lateridis the file name without.lua.cataloguelists the ids of the entries insrc/data/engineLuaCompat.jsonthat the fix addresses. It can be empty.licenseis alwaysMIT or GNU GPL, v2 or later, as inlua/LICENSE.md.statesmakes it a prelude fix and names the Lua states it runs in,synced,unsyncedor both.gadget: coilbox_compat_<name>makes it a gadget fix and names the gadget and its file.defsmakes it a def fix and names the files it runs in,defs_post,weapondefs_postor both.
A manifest lists its fixes in order, and names the 3do converter and the writer it was built with:
{
"version": 5,
"fixes": [
"compat-callin-hooks",
"spring-unit-travel",
"gadget-aircraft-crash-83-v2",
"defs-helpers",
"defs-commander-85"
],
"converter": {
"crate": "coilbox-3do-convert",
"version": "0.1.0"
},
"writer": 1
}That is a shortened 5.json. The real one lists 18 fixes.
A fix never changes once it ships
Two players on the same manifest have to write the same bytes. If a fix changed after a manifest listed it, two coilbox versions would build different games under one name. So a fix is fixed for good once a manifest lists it.
To change a fix, add a new fix with a new id, and a new manifest that lists it in place of the old one. Keep the old file, because the older manifests still list it. gadget-aircraft-crash-83-v2 is an example. Manifest 3 lists gadget-aircraft-crash-83, and manifest 4 replaced it with the v2 file. Both files are still in lua/compat/gadgets/.
hashes.txt is what holds this in place. The test recorded_hashes_match_the_embedded_fixes in coilbox-compat fails for a listed fix whose file no longer matches its recorded hash, with "changed after it was listed. Add a new fix with a new id instead".
The call-in adapters in ADAPTERS in crates/coilbox-compat/src/lib.rs follow the same rule. They decide which of the UnitDamaged and AllowCommand fixes a game gets, so changing one needs a new manifest too.
Add a fix
- Write the fix as
lua/compat/<id>.lua,lua/compat/gadgets/<id>.luaorlua/compat/defs/<id>.lua, with the header above. The file must end in a newline and have no carriage returns. All of it must be your own code. Copy nothing from the engine'sexamples/Widgetsorcont/LuaUI, or from any game. - Add
(id, include_str!(...))toFIXESincrates/coilbox-compat/src/lib.rs, which is in alphabetical order by id. - Copy the newest manifest to
lua/compat/manifests/<n>.json, with<n>one higher. Setversionto<n>and add the new id tofixes.compat-callin-hooksmust stay first among the prelude fixes, anddefs-helpersfirst among the def fixes. - Add
(<n>, include_str!("../../../lua/compat/manifests/<n>.json"))toMANIFESTSin the same file. - Record the hash. Run
shasum -a 256on the fix file and add a line of the hash, two spaces and the id tolua/compat/manifests/hashes.txt. - For a def fix, add its id to
COUNTEDincrates/tauri-plugin-coilbox-compat/src/defs.rsand count the definitions it changes inchangedthere. The testevery_def_fix_but_the_helpers_is_countedfails until you do. - For a gadget fix, add what it would act on to
gadgetFindinginsrc/content/compatReport.ts, so the report's "Compatibility gadgets a build turns on" row lists it. - Add tests to
lua/compat/tests/, inprelude_test.lua,gadgets_test.luaordefs_test.lua, and runscripts/mission-tests.sh. - Add the golden line. Run
cargo test -p tauri-plugin-coilbox-compat the_fixed_games_build_the_same_bytes_from_every_manifest. It fails and prints the hash it built for each manifest. Add the new manifest's line toGOLDENincrates/tauri-plugin-coilbox-compat/src/lib.rs. Every older line must come out the same as before. If one has moved, read the next section first.
Players get the new manifest when they update coilbox. Their switches stay on, and each game shows Rebuild until they rebuild it.
When the writer changes
The fixes are held by their hashes, but the code around them decides the bytes too. That covers how the prelude wraps the fixes and the entry files, which adapters a game's handlers pick, the modinfo.lua and defs_post.lua text, and how the worker converts models. WRITER in crates/coilbox-compat/src/lib.rs is the newest version of that code.
Coilbox keeps every writer's behaviour and builds a manifest with the writer it names. A player on a newer coilbox who joins a host on an older manifest builds that manifest with its old writer, and gets the host's bytes. A coilbox refuses a manifest that names a writer newer than its own WRITER, which only a newer coilbox can have.
Three golden tests hash a fixed build and fail when the bytes change:
the_fixed_games_build_the_same_bytes_from_every_manifestincrates/tauri-plugin-coilbox-compat/src/lib.rs, one line per manifest.a_fixed_game_converts_to_the_same_bytesincrates/coilbox-unitsync-worker/src/convert3do.rs.a_fixed_game_scans_to_the_same_handlersincrates/coilbox-unitsync-worker/src/luacompat.rs.
A line that moves for a manifest that already shipped is a bug, never a hash to record again. To change the bytes on purpose:
- Move
WRITERup by one. - Keep the old behaviour for the older writers, and put the new one behind a check of the manifest's writer. The checks are functions beside
WRITER, one per change, so each writer's behaviour is listed in one place. - Add a manifest that names the new writer.
- Add the new manifest's golden line, and the new writer's hash where a worker test keeps one per writer. Every older line stays the same.
A coilbox also refuses a manifest whose converter does not match its own coilbox-3do-convert version. That pin has one version per coilbox, so moving it makes every older manifest one the new coilbox cannot build.
The test the_lua_tests_load_the_preludes_this_crate_builds checks the preludes for manifest 2 that the Lua tests load from lua/compat/tests/fixtures/. After a deliberate change to how a prelude is built, run it with COILBOX_WRITE_FIXTURE=1 to write them again.
Run the tests
cargo test -p coilbox-compatchecks the registry: every file embedded, every header, the hashes and the manifests.cargo test -p tauri-plugin-coilbox-compatchecks the folder, the def fixes and the golden build.cargo test -p coilbox-unitsync-workerruns the worker's two golden tests.scripts/mission-tests.shruns the Lua tests, withluajitonPATH.
Some tests need a real game and are skipped in a normal run. cargo nextest run -p tauri-plugin-coilbox-compat --run-ignored only needs xta-9.65.sd7 in ~/.spring/games or COILBOX_COMPAT_GAME naming another game, an installed engine with libunitsync, and a built coilbox-unitsync-worker in target/debug. cargo nextest run -p coilbox-compat --run-ignored only needs Expand and Exterminate 0.46 in ~/.spring/games, or COILBOX_COMPAT_EE naming its .sdz.