NIMBY Rails devblog 2020-03
Internal development notes, very slightly cleaned up and commented a month later.
TL;DR: async search engine, saner UI API, new forward compatible save format, short codes, station sanity, better track fusion
Async search engine
- async search engine queries
to not block ui
map API returns result or a busy flag
internally it keeps the result of the last query, and the query parameters
if requested search matches last query, returns it
if not
if one connection is searching something already
do nothing! since UI is immediate mode, it will ask again on next frame
if no search is active, fire up a search for the request. keep query params for the cache
show loading... in ui while busy
use async and futures at gis::Map level, gis::DB is already built with auto queuing for connections
check if local gets stuck or is normal behavior due to slowness in debug mode
Given the hundreds of thousands of region, city, town and area names available in the game database a search engine is vital when exploring the map. The game features a search engine, but even with a fully indexed database and optimized queries it’s still possible for some searches to take seconds, especially in development builds.
Now search queries are performed asynchronously from the UI. A search task is launched in a dedicated thread with a dedicated private SQLite connection just to perform the search, keeping the UI at 60FPS even when the search itself is pegging a core at 100%.
Saner UI API
- move shell::ui_/UI stuff to proper shell::ui namespace
- kill UI css-like selector+style system, replace with explicit ui::style variables
user code copy() from other values in ui level variables, or shell level, or local the func/scope
ui(x, ...) takes ui::Style now
capability to take local scope UIStyle solves massive PITA about styles being far away from the widget code
and favors local usage which was the pain point in prev system
test local style values in main menu
implement child styles and tag styles with a simple matcher
for compat to not redo the entire ui...
use neater child()/clone() API which returns emplaced and by-value copy
fix child matching by parent rules to be non-overriding
accumulate all matching parent modifications into an empty style with overriding, in order, then apply result to child, non-overriding
fix child matching to consider number parent tags, not child tags
- fix new style system breakage
wrong w for trash button
slim form labels are wrong in line editor
- move style values and constants into ui::style namespace
- cleanup (some) panel creation
add panel helper to create the various modal panels
also creates the initial seq to save a nesting
The UI system in NIMBY Rails uses a layered approach to its API. At the lower level there is a forked version of nuklear. Then on top of it the UI system does its own layout based on randrew/layout. And at the very top a custom, immediate mode -like API wraps and hides both libraries. I really like the immediate model, but style and layout wise, I found the existing solutions lacking. The layout part I was able to solve with the awesome randrew/layout (although it involves a very strange 2-pass solution since the UI code is written in immediate mode style). But for the styling I rolled my own system. An earlier iteration (missguidely in hindsight) tried to copy some concepts of CSS. The system is now based on “style values”, where simple copying and composition, as native to C++, is used instead, for much cleaner semantics.
Forward compatible saves and flexible serialization
- serde v2: forward compatible, versioned visitor for model, without using libnop
like https://yave.handmade.network/blogs/p/2723-how_media_molecule_does_serialization
base types and lib collections are not versioned
base model objects like RectI aren't either
other model is
- while doing saves v2, temporary support of both v1/v2 saves in both save code and UI to convert the trailer saves
- saves v2: based on new serde.h, new save data wrapper, move code to sync from mainmenu
header: NOT versioned, fixed as file save id, flags, version, hash of preview data, hash of game data
preview: data for preview display in save picker
game: a game serialization
move save/load code to sync
move state setup to shell/state.cpp
cleanup main menu logic on overwritting saves
keep v1 compat to migrate trailer saves
- io coder for serde
std::iostream based
use varint.h based on the protozero stuff
- hook up new load/save code in sync/saves.cpp
- convert trailer saves to v2 format and delete old saves
- remove v1 saves load code
- keep old saves with old build in incoming
- transition sync to new serde
sync will be redone in the future, do minimal effort for compilation of libnop removal
- remove libnop from VS proj
not from repo, req by wrangler
check paths in proj files too
- divide serde into ser and de, preserving constness for ser
cannot be fully done in a template way, needs preprocessor kludges for model objects
- serde: handle any sized enum with internal int64_t casting
remains bin compatible since it was fixed at 4 bytes before, which encodes into varint
The original save system was based on libnop, a very easy to use C++ serialization library. This simplicity allowed me to bring up save support fast, but it was time to look into a more serious effort for saved games. The key is to support versioning, so a save game can advertise its game version and the loader code and make decisions on how to interpret the data in the context of a newer one. Ideally this process should be as easy and as automated as possible. I decided to go with the approach detailed here, with my own interpetation of the API using some light C++ template features (I save the heavy C++ template features for a later blog post :).
// ...
inline void visit(int32_t const* obj, Serializer* io) { io->num_integer(obj, 4, false); }
// ...
void visit(gis::Coordinates* obj, IO* io) {
visit(&obj->x, io);
visit(&obj->y, io);
}
In the new system there is a visit() function for every type in the game data model, from the humble int to the structs that hold all the game data. Only the most primitve data types declare any IO to perform, and it is the responsibility of the IO object (itself an specialization of an abstract IO interface) to decide what to do with the memory address, to read or write to it. Simple structs can directly call visit() on its members.
#define _serde_vector_like_serialize \
size_t s = obj->size();\
visit(&s, io);\
for (auto const& elem : *obj) {\
visit(&elem, io);\
}
#define _serde_vector_like_deserialize \
size_t s = obj->size();\
visit(&s, io);\
if (obj->size() != s) {\
obj->resize(s);\
}\
for (auto& elem : *obj) {\
visit(&elem, io);\
}
template <typename T> inline void visit(std::vector<T> const* obj, Serializer* io) { _serde_vector_like_serialize }
template <typename T> inline void visit(eastl::vector<T> const* obj, Serializer* io) { _serde_vector_like_serialize }
inline void visit(std::string const* obj, Serializer* io) { _serde_vector_like_serialize }
inline void visit(eastl::string const* obj, Serializer* io) { _serde_vector_like_serialize }
With some care even standard C++ containers can easily be handled, sometimes using local variables in the visitor function to encode the length. And thanks to templating it’s easy to support containers with any contained type, with full serialization available for their contents. Note that this example is the only one showing the real style of the code, using const to distinguish between serialization and deserialization. The other examples are simplified, and the hack to not repeat code in those structs that do not require it is not shown.
// ...
template <int32_t added, typename T>
inline void live(T obj, IO* io) {
if (io->version >= added) {
visit(obj, io);
}
}
template <int32_t added, int32_t removed, typename T>
inline T dead(IO* io) {
T value;
if (io->version >= added && io->version < removed) {
visit(&value, io);
}
return value;
}
// ...
void visit(model::Track* obj, IO* io) {
live<v_genesis>(&obj->id, io);
live<v_genesis>(&obj->serial, io);
live<v_genesis>(&obj->build_status, io);
live<v_genesis>(&obj->kind, io);
live<v_genesis>(&obj->prev_id, io);
live<v_genesis>(&obj->next_id, io);
live<v_genesis>(&obj->point, io);
dead<v_genesis, v_cleanuptrack, double>(io); // radius, not deleted, just undesired in saves
dead<v_genesis, v_cleanuptrack, bool>(io); // station_plaftorm, made redundant by station_group_id
dead<v_genesis, v_cleanuptrack, double>(io); // station_platform_footprint_margin_start_t as data
dead<v_genesis, v_cleanuptrack, double>(io); // station_platform_footprint_margin_end_t as data
dead<v_genesis, v_cleanuptrack, double>(io); // station_platform_footprint_margin_half_thick_meters, lookup in kind
live<v_genesis>(&obj->station_group_id, io);
live<v_genesis>(&obj->attached_to_id, io);
live<v_genesis>(&obj->attached_to_t, io);
live<v_genesis>(&obj->attached_linkable, io);
live<v_genesis>(&obj->attached_by, io);
}
The previous snippet is the (de)serialization method for the track data in the game, as it is right now. The visit/dead calls themselves are actually wrapping a call to the (very) overloaded visit() function, which perform the version checks as described in the linked article, reading and then skipping data. It is also possible to use the dead data to improve the loading of old saves.
Short codes for trains and lines
- introduce short codes
for trains and lines
model/saves/editor
add a little bit of string gen for short codes
- vis short codes
inside train icon, both train and line code
- optionally allow train decals to inherit line color
Trains and lines alread had names, but those fields are meant to be as long and descriptive as the user wants. There is now an extra field to name trains and lines, intended to be very short and displayed in more places in the UI:
These new short codes also helped provide a better listing in the train editor, with more empty background space allowing for a better visibility of the selected train. A small but very sensible feature was also added to this editor and to the train data model, to automatically use the line color as the decal color.
Rewrite automatic station fusion code
- stations sanity fix: keep the current maximalist flexible idea, but only process on update()
so no processing just after modification
introduce queued pending stations
station groups are only optimistically updated to not contain invalid ids
any edited station tracks dirties its station group, to be processed
update alogrithm collects all dirties, and all tracks thant belong to them even if not diry
and also all stations that overlap the tracks even if not diry themselves, and all their tracks too
then finds overlapping clusters of tracks with an exhaustive search
since this considers even non-dirty tracks, and always considers all tracks of all stations, the clusters become the truth
each cluster is then assigned to an existing station, replacing its tracks, or a fresh one is introduced, if an existing station was already fixed
- test modifcation by deletion with new station code
- fix: erase empty stations after fusion
- fix: restore priority when deciding which station to keep and which one to create
sort the clusters and keep using the naive iteration
then select the relevant station id for each cluster, not just *begin()
station_group_importance is still there, use it
- fix: restore platform numbering after new station code broke it
old station updater code, out of tree now, has it
When designing the station system in the game I went for the most flexible idea, while also being the easiest possible idea. Any combination of track platforms can form a station as long as they respect the overlapping rules (so no two unbranched ground tracks can overlap, but any amount of metro tracks can overlap them, for example). Grouping platforms into a single station is as simple as making sure their perimeters touch at some point. De-grouping them is as easy as moving one away from the other, splitting them automatically without explicit user action to do so. This has to support any ideas the user may come up with, including setting up platforms in a ring, or in a “U” shape, and any possible addition and removal to those.
The original code to allow all of this was a mess and operated in a eager way the moment a platform was edited in some way. This was very brittle and was not going to survive the future cleanups and refactors required for robust multiplayer or editor undo (yep, those are coming, and in fact already present in prototype form). The new code instead uses the same pattern the non-station track system uses, by having a separate “update” logic pass after the user is done editing for a frame.
Do not introduce extra track segments when linking tracks
- remove the need to create 2 tracks when linking tracks in the editor
the original codes was deleting the stub tracks from stations, triggering station deletion
so the intermediate extra track was added to "receive" the deletion
but this is not needed anymore since track addition mode always creates a track when clicking the + control on a track end
so there's always a temp track to delete, never deleting the sibling or the hit
deletion is only ever made on the original creating_track_id
sibling and hit tracks only get linked or unlinked, never deleted
Another early artifact from stations was having the station “track tip” be part of the platform itself. While this is correct in principle, it lead to problems related to the logic the game uses to keep the track segments consistent on the face of deletions. Eventually this was improved by adding a non-removable short track segment in front of that tip, and having this segment be the only linkable part of a platform.
The relevance of this change was to enable general track linking to be exactly that, just linking two track segments into a single one. Since the in-progress track blueprint of a track being linked is temporally deleted, such a deletion could cause a cascaded deletion of the station. To fix this track linking always inserted an extra track link. But with the new platform tips it was now possible to restore the former, correct track linking behavior: