Tutorial: A Block or Object Behaviour

Per-type facts live in two registries. This walk-through follows one object capability and one block change from the table to the code that reads it and the tests that guard it.

Fidelity rule A behaviour must trace to Mobile Eggbert. Before changing a registry entry, find the original's code (Decor.cpp in the sibling repository mobile-eggbert) or an owner decision recorded in plans/audit/, and cite it in the comment and the commit. Never renumber a block or object type.

The two registries

RegistryKeyed byFiles
BlockDefinitionRegistryblock type = icon in object-m.png, 0-440include/GalaxyEggbert/BlockDefinitionRegistry.hpp, src/GalaxyEggbert/BlockDefinitionRegistry.cpp
ObjectDefinitionRegistryDef::ObjectType, 0-203src/GalaxyEggbert/Game/ObjectDefinitionRegistry.hpp, .cpp

Both are built once in their constructor, are free of CNA types, and are read through GetBlockDefinition(type) and GetObjectDefinition(type). Consumers must not keep parallel lists of type numbers; that was the point of centralising them (July 2026) and of the final object audit, which left only distinct state machines and explicitly scoped cheat commands as code branches.

Part 1: an object capability flag

The hanging mine (ObjectType 3) kills on contact like the other hazards, except that a crouching Blupi passes under it: the original's MoveObjectDetect skips that type while Blupi's action is Down. In Galaxy that difference is one flag.

The table

In ObjectDefinitionRegistry.cpp the shared contact-hazard list and its exception:

ForTypes(definitions_, {2, 3, 4, 16, 17, 20, 96, 97},
         [](ObjectDefinition& definition)
{
    definition.genericContactHazard = true;
});
...
definitions_[3].genericContactHazardCrouchImmune = true;
ForTypes(definitions_, {3, 16, 96, 97}, [](ObjectDefinition& definition)
{
    definition.balloonPoppableHazard = true;
});

The consumer

One place reads it, the generic hazard contact in src/GalaxyEggbert/Game/InteractionSystem.cpp:

if (GetObjectDefinition(obj.type).genericContactHazard)
{
    if (blupiCrouching &&
        GetObjectDefinition(obj.type).genericContactHazardCrouchImmune)
    {
        continue;
    }
    ...
    if (!blupiInvincible && !contactGrace_ && touches)
    {
        if (blupiBallooned &&
            GetObjectDefinition(obj.type).balloonPoppableHazard)
        {
            events_.push_back(Event{EventKind::BalloonPopped});
            ...

Note the shape: InteractionSystem does not touch the controller, the camera or the HUD; it pushes an event and the host reacts. Keep new behaviour on that side of the boundary.

The guard

tools/VerifyDefinitionRegistries.cpp keeps an independent expectation for every flag and checks all 204 types against it:

bool ExpectedGenericContactHazardCrouchImmune(int raw)
{
    return raw == 3;
}
...
Check(definition.genericContactHazardCrouchImmune ==
          ExpectedGenericContactHazardCrouchImmune(raw),
      "generic contact hazard crouch immunity is centralized without parity loss");

So a registry change fails this test until you also change the expectation, which is deliberate: the two edits together are the reviewable statement "type X now behaves like this". Then add a behavioural check (in VerifyInteractionSystem, which runs InteractionSystem against world999) that fails without your change.

If the change touches animation

An object's sprite frames per phase come from GetObjIcon (src/GalaxyEggbert/Game/ObjectIcons.cpp). VerifyGetObjIcon locks every frame array and divisor, and VerifyObjIconAgainstReferenceDoc cross-checks them against the tables in mobile-eggbert-reference/08-animations.md. Where a 3D model replaces the sprite, the pose comes from that model's icons.txt (generated; see Extending the Game).

Part 2: a block becomes solid in 3D

A block's collision mask is derived from the original's quarter-cell table (table_decor_quart, in DecorQuartTable.hpp). Some tiles that the original's Blupi walks in front of in 2D (columns, girders, posts) stand in his way in 3D. The owner decided they are solid (owner report OR-0069; the commit of 2026-11-04 cites it by its earlier number OR-0042). That decision is a named list in the header:

inline constexpr std::array<std::uint16_t, 12> kSolidIn3DDecor{
    49, 50, 51, 76, 77, 199, 200, 201, 235, 236, 300, 398};

and one loop in the registry's constructor:

// Solid in 3D (OR-0069, kSolidIn3DDecor): a whole block each, as the fans (M3D-0004). Drawn
// as before (mobileTransparent is the original's). The secret passages stay passable.
for (const auto icon : kSolidIn3DDecor)
{
    definitions_[static_cast<std::size_t>(icon)].collisionMask = 0xFFFF;
}

Who picks it up

Refresh the CSV and run the guards

cmake --build build-cna-opengl3 --target DumpBlockDefinitions VerifyDefinitionRegistries
build-cna-opengl3/DumpBlockDefinitions > tools/data/blocks.csv      # from the repository root
ctest --test-dir build-cna-opengl3 -R "VerifyBlockDefinitionsCsv|VerifyDefinitionRegistries|VerifyBlupiMovement"

VerifyBlockDefinitionsCsv runs DumpBlockDefinitions --check tools/data/blocks.csv and fails while the committed CSV differs from the C++ table. VerifyDefinitionRegistries recomputes every mask from the quarter-cell table plus kSolidIn3DDecor.

Then the worlds

A tile that becomes solid can close a route a world relied on. The real commit (2d9cdaf7, feat(gameplay): the decor that stands in Blupi's way in 3D is solid, the secret passages stay passable) changed nine files together: the registry header and source, the ledger row, a new VerifyBlupiMovement case (Blupi stands on a floor of girders, still falls through a cobweb), the registry test's expectation, the regenerated blocks.csv, one world generator and its .vwr (world058), and one playthrough (world056.txt). Run the world checks and the playthroughs (Testing & QA, "Which checks for which change") to find yours.

Checklist

  1. Evidence first: the original's code, or a ledger row / owner decision for a 3D adaptation.
  2. Change the registry entry, not a consumer; update its comment with the citation.
  3. Update the expectation in VerifyDefinitionRegistries; add a behavioural test that fails without the change.
  4. Block change: rebuild and regenerate tools/data/blocks.csv.
  5. Run ctest, the golden trace, the affected missions' playthroughs and tools/verify_demos.sh; a world that no longer works is fixed in its generator.
  6. Record the change in its ledger row and commit everything together, the id in the subject.