Skip to content

Crash fixes, editor improvements, cross-platform fixes and 27 new levels - #16

Open
Upabjojr wants to merge 26 commits into
tomluchowski:shaders-improvementfrom
Upabjojr:shaders-improvement
Open

Crash fixes, editor improvements, cross-platform fixes and 27 new levels#16
Upabjojr wants to merge 26 commits into
tomluchowski:shaders-improvementfrom
Upabjojr:shaders-improvement

Conversation

@Upabjojr

Copy link
Copy Markdown

This branch collects the work done over the last sessions: crash fixes, gameplay/UI fixes, editor quality-of-life features, cross-platform (Windows/macOS) portability work, and 27 new skirmish/multiplayer/survival levels.

Crash fixes

  • Fix dangling and null creature pointers on the client (0302da9)
  • Fix null tile dereference when a missile starts against a wall (d908986)
  • Fix the editor re-entry crash pair: CEGUI AlreadyExistsException from menus being filled twice (8ec6c20) and a segfault from EditorMode deleting the render manager's DebugDrawer singleton (893df06)
  • Fix undefined behaviour in Seat's per-tile state lookups: two end-iterator dereferences, a dead fallback and four accidental map inserts, replaced by a single checked helper (3804e4a)

Gameplay and UI fixes

  • Stop the camera flying at (and bouncing off) a minimap point outside the map (0474176); clamp camera scrolling by view target rather than camera position (4d81fe4)
  • Keep the spell cooldown label next to the pointer counting down instead of freezing while hovering an entity (8a83239)
  • Re-path creatures whose walk path crosses a bridge that was sold, and teleport ones already stranded on bare water/lava to the closest walkable tile (ac9f193)
  • Split a room in two independent rooms when selling tiles disconnects it — previously an enemy claiming one half converted both. Per-room-type state is handed over correctly (bridge claimed value, treasury gold), covered by a new integration test (4195cba)
  • Keep name/health labels over the creatures they belong to: reposition while paused, derive the current bounding box instead of a one-frame-stale cache, and hide labels of dead or off-map creatures (1319ed0)
  • Lay the spell buttons out in two rows so Eye of Evil and Weakness no longer fall off the tab pane at the minimum window size; same fix for the rooms tab's Temple/Portal buttons (10db1cb)
  • Fix window event pumping, mouse cursor sync and window sizing (eac4b7d)

Editor improvements

  • Choose the level of placed creatures with L / Shift+L (efd537c)
  • Walk creature classes backwards with Shift+C and seats backwards with Shift+Y (4202bd7)
  • A help window documenting the editor's keys, and a label showing which level is being edited (9b79128)

Cross-platform work

  • Read config and levels from the per-user data folder, seeded from data embedded in the binary on first run, with outdated extracted files detected by content (8ce9ff4, 7fdc135)
  • Fix macOS resource path construction, accept backslash separators and absolute paths on Windows, set the window icon via SetClassLongPtr, open URLs with the native mechanism per platform, and strip CRLF line endings in the single file-reading funnel so Windows-edited level/config files load everywhere (db2e230, f45b427)

New levels

  • 27 new levels across skirmish, multiplayer and survival, generated with rotational/mirror symmetry, sizes from 89x89 up to 145x145 and up to eight players (9a43fbe, 222e0da, 0ebc0e8)

Tests

  • The console test builds and passes again after API drift (10ccb9f)
  • New integration test for the room-split change; the existing server-backed boost tests pass, each against a fresh server

Note: the GUI changes (two-row spells tab, editor help window) were verified by XML validity and code-path inspection but not rendered, as this was developed on a headless setup. The Windows/macOS fixes are compile-checked where reachable from Linux.

🤖 Generated with Claude Code

Upabjojr and others added 24 commits July 26, 2026 01:23
Five related defects surfaced while getting the game running against
OGRE 13.6.5 on X11.

Pump window events. Ogre::Root::startRendering() no longer pumps them
(Ogre::Bites does that for applications built on its context, which we
are not), so ConfigureNotify never arrived: windowResized() ran only
once at startup and CEGUI's display size and OIS' clipping rectangle
went stale as soon as the window was resized. Drive the render loop
directly and call messagePump() every frame.

Exit cleanly when the window is closed. windowClosed() destroys the
mode manager, but frameStarted() dereferences it before testing the
exit flag, so the next frame segfaulted in _fireFrameStarted(). This
was unreachable before, as close events were never delivered. Request
exit from windowClosed() and check it before rendering.

Hide the system cursor while grabbing. When grabbing, OIS tracks the
pointer by accumulating relative motion and warps the real pointer back
to the window centre near the edges; leaving that cursor visible showed
it drifting away from the one CEGUI draws.

Keep the configured video mode. initVideoConfig() dropped any video
option whose value was not among the render system's possible values,
but the video mode also sizes the window when running windowed, where
it need not be one of the fullscreen modes. It was silently falling
back to the minimum size.

Raise the minimum window height to 660. MenuMain.layout stacks its
seven buttons around the vertical centre with fixed offsets and needs
660px; below that the game clipped the "Quit" button off its own menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrolling ran past the map edge on one side while stopping short of it
on the opposite side, in gameplay and in the map editor alike.

updateCameraFrameTime() clamped the camera node's own position to
[0, mapSize], but the camera is pitched DEFAULT_X_AXIS_VIEW (25 degrees)
off vertical, so the ground point it looks at lies z * tan(pitch) ahead
of it: between 1.4 and 7.5 tiles for z within [MIN_CAMERA_Z, MAX_CAMERA_Z].
Clamping the position therefore shifted the whole reachable view window
by that offset, overshooting the edge the camera faces and falling short
of the one behind it, for a total asymmetry of twice the offset.

Clamp so the view target stays inside the map instead, deriving the
offset from the camera's derived direction and the height it is about to
end up at, the same way getCameraViewTarget() does. Keeping the offset
signed leaves the limits correct under camera yaw, not just at the
default orientation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playing for a few minutes reliably ended in a segfault inside
GameMode::mouseMoved(), dereferencing a freed Creature from
RenderManager::rrNormalizeAmbient().

InputManager::mHighlightedCreature is a bare pointer to the creature the
mouse hovers, kept so its ambient can be restored once the mouse leaves
it. Only GameMode::mouseMoved() and Creature::setPosition() ever cleared
it, and neither runs when the creature dies: client-side deletion goes
through deleteYourself() and GameMap::processDeletionQueues(), which
touch nothing in InputManager. Hovering a creature that then died left
the pointer dangling, and the next mouse move read freed memory. Clear
it from ~Creature().

Three null dereferences in the same area, none of them observed firing:

Creature::setPosition() dereferenced getPositionTile() twice without
checking it, but that returns null whenever the creature sits outside
the map, as it does while held in the keeper hand or carried.

ODFrameListener::frameStarted() dereferenced getOverlayStatus() for
every creature, which is null while a creature's mesh is not created --
Creature::update() checks it for exactly that reason. The two loops
around the render target update also walked the creature list twice and
paired the results by index, which only holds while no creature gains or
loses its overlay in between; remember the overlays actually hidden
instead.

Tile::setEverVisible() was declared to return bool and returned nothing,
which is undefined behaviour. Its only caller ignores the result, so
this was harmless in practice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The game read its configuration and the shipped levels from the data
folder it was configured with at build time. When that folder was never
installed, there was nothing to fall back on but the current working
directory, which is why running from a source checkout was the only way
to start it.

Compile config/ and levels/ into the executable and extract them into
the user data folder instead, under a gamedata/ subfolder:

    ~/.local/share/opendungeons/gamedata/config/*.cfg
    ~/.local/share/opendungeons/gamedata/levels/{skirmish,multiplayer}/

cmake/GenerateBuiltinData.cmake turns the two folders into a generated
translation unit at build time, declared by source/utils/BuiltinData.h.
ResourceManager writes out every file that is not already there and then
reads the configuration and the shipped levels from that folder, so the
game no longer depends on a system wide folder existing or being
readable. Existing files are never overwritten, since the folder is the
player's and may have been edited on purpose; a digest of the embedded
data is stamped alongside them so that a build carrying different
defaults is reported rather than silently ignored. --gamedata reads them
from somewhere else, which is the convenient way to run against an
edited source tree.

Saving a level in the editor now writes to the user levels folder unless
the level already lives there, rather than over the file it was loaded
from. Editing one of the shipped levels used to modify it in place, so
the original could not be played again. This restores the intent of the
code that was left commented out in askSaveMap, comparing normalised
absolute directories rather than searching for substrings in paths.

Two supporting changes: the server mode options name a level, so they
moved to their own step that runs once the default data path is known;
and ODApplication now logs the resolved paths, which ResourceManager
cannot do itself because the log file sink does not exist yet while it
runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comparing the stamp written into the user data folder against the one this
build carries reports any difference between the two builds, including one
that has no effect: a build that only adds files writes them out normally,
since extractBuiltinData() skips existing files rather than all of them.
Shipping a new level would have warned that the folder was kept as it is,
right after correctly putting the new level in it.

Count the files that exist but hold something other than what this build
would have written, and warn only about those. The stamp is still written,
as a record of which build populated the folder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multiplayer, every player given exactly the same start:

  Twin Fangs      65x65, 2 players, half turn rotation. A gem seam in the
                  middle, ringed in lava with a breach on either side.
  Four Winds      81x81, 4 player free for all, quarter turn rotation, so
                  no corner is better than another. Gold spokes reach out
                  from a crossroads nobody can hold quietly.
  The Broken Seal 73x73, 2 players, half turn rotation. A vault walled in
                  rock holds two hero portals that send waves at a keeper
                  picked at random, so neither player can rely on the
                  other soaking them.

Skirmish, each with something to achieve:

  The Golden Vein     65x65, a mining race against one AI keeper. Mine
                      2000, then 6000, then 12000 gold, and destroy him.
  Heroes at the Gate  65x65, no rival keeper, only escalating hero waves
                      from two portals down two defensible approaches.
                      Hold the temple and kill every last hero.
  The Deep Catacombs  73x73, walled crypts on a grid. Claim 250 tiles,
                      then 600, against an AI keeper, while hero waves
                      arrive from both ends.

Equality on the multiplayer maps is structural rather than eyeballed: each
map is authored once and stamped through the rotations that generate it,
so a player's surroundings are the image of every other player's. The maps
are odd sized on purpose, so the rotation has a centre tile to fix; on an
even sized map there is no such tile and the middle cannot be symmetric.

Checked before committing: the whole tile grid maps onto itself under the
rotation with seat ids following it, early reachable gold is equal for
every player, every room tile is claimed floor owned by the room's seat,
each seat starts inside its own temple, and every temple can dig its way
to every portal and to every other temple without crossing water or lava.
All six load in the engine with no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multiplayer:

  Sunken Halls        97x97,   2 players, half turn
  The Iron Cross      97x97,   4 players, quarter turn
  Ember Basin         113x113, 4 players, quarter turn
  The Shattered Ring  113x113, 2 players, half turn, hero vault
  Heroes of the Deep  113x113, 4 players, quarter turn, hero vault
  Long Winter         129x129, 2 players, half turn
  The Eight Thrones   145x145, 8 players, every symmetry of the square

Skirmish:

  Gold and Ashes      97x97,   1 rival keeper, mine 3000/9000/18000 gold
  The Lost Legion     97x97,   hero survival, three portals
  The Great Warren    113x113, 3 rival keepers, claim 400 then 900 tiles
  Siege of the Deep   129x129, 3 rival keepers plus a hero vault
  The Endless Tide    129x129, hero survival, five portals, faster waves
  Kingdoms Fall       145x145, 7 rival keepers, claim 400 then 1000 tiles

The first six maps were authored feature by feature, which does not scale to a
145x145 map without it turning into a field of undug dirt. These are built from
three templates instead: the symmetry group decides how many players there are,
and a seeded generator fills the authored wedge with rock, water, lava, caverns
and gold seams. The seed is part of the map, so each one regenerates identically.

Eight players come from using all eight symmetries of the square rather than
just the rotations, so the eight starts are the same ground seen eight ways. The
wedge is then an octant, which is why that map is the largest: the keeper's
ground has to fit inside it without touching its own reflection.

Filling a wedge automatically needs the wedge boundary respected, so scatter()
places a blob only when the whole blob is inside it, not merely its centre.
stamp() already refuses to let two stamps write different terrain to one tile,
which is what keeps a generated map as symmetric as a hand authored one.

All nineteen levels were checked the same way as before: the tile grid maps onto
itself under the map's rotation, with the seat permutation derived from where the
temples land rather than declared; early reachable gold is equal for every player,
including all eight on the largest maps; rooms are claimed floor owned by their
seat; and every temple can dig to every portal and every other temple without
crossing water or lava. All nineteen load in the engine with no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playing on eventually ended in a segfault on the server thread, inside
MissileObject::doUpkeep(), reading address 0xf8. That is offset 248,
which is where Tile::mX sits.

doUpkeep() walks the tiles along the missile's path, remembering the
previous one in lastTile so that a missile stopped by a wall can be
placed on the tile it came from. lastTile starts null and is only
assigned at the bottom of the loop, so when the first tile of the path is
already a wall both branches of the wall case read through it. The loop
below that case already checks lastTile for null, which is what the wall
case should have done too.

That happens when a missile is launched straight at a wall, or when the
tile it occupies is filled in under it, both of which take a while to come
up: the log for this one shows it on turn 8858, right after

    missile name=RenderedMovableEntity_Cannon_10_6102, hit wall on tile=[23,32]

which doUpkeep() logs two statements before the dereference.

There is no previous tile to fall back on in that case, so end the
missile where it already is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking near the border of the minimap moves the camera there and leaves it
shuddering against the map edge for the rest of the game.

The minimap is a view of the ground around the camera, not of the map, so when
the camera is near an edge the minimap shows ground past it, and a click there
asks the camera to fly somewhere it cannot go: updateCameraFrameTime() keeps
the point the camera looks at inside the map. The flight is never within the
0.25 stopping distance of its destination, so it never ends, and every frame it
pushes the camera past the edge for the clamp to pull it back on the next one.
Hence the bouncing, and hence it outlasting the click.

flyTo() now aims at the nearest point on the map, which is where the camera was
going to stop anyway. Two things back that up. The clamp moved into
clampToMap() and is applied a second time just before the camera is placed, so
the flight, the circle and the spline modes, which all add to the position
after the first clamp, can no longer put the camera off the map even for the
single frame it took to be pulled back. And a flight that brings the view
target no closer than the frame before now gives up rather than press on, so no
destination the clamp does not predict can hang the camera again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting a spell that is still cooling down puts a countdown next to the mouse
pointer, "CreatureExplosion (2.34 s)". It only moves while the mouse does, so
hovering an enemy waiting to explode it leaves the countdown reading whatever it
did when the mouse last stopped, and still reading it once the spell is castable
again.

The text is written by checkInputCommand(), which runs on mouse move and on
click. Everything else it displays depends on where the pointer is, so that was
enough; the cooldown is the one thing that changes while the player does
nothing. The value behind it is fine, Player::frameStarted() smooths it every
frame, and the progress bar over the spell icon follows it correctly.

Refresh the countdown every frame as well. The cooldown branch of
checkSpellCast() moves into checkSpellCooldown() so that both callers share it,
and because it is worth stating separately that this part displays and never
casts, whatever the input state is. Once the cooldown ends, the countdown has to
give way to whatever the spell puts there instead, which only the spell knows,
so checkSpellCast() is asked once. Not when the input state is validated, since
that is the state in which it casts the spell rather than describing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A creature walking towards a bridge when it is sold walks to the last tile
before the water or the lava and stops there for the rest of the game. One
standing on the bridge when it goes stops just as permanently, in the middle of
the lava.

Nothing tells a creature that the ground it planned to walk on has gone. It
keeps the path it computed, steps onto the tile the bridge used to cover, and
Creature::getMoveSpeed() returns the creature's lava speed there, which is zero
for everything that does not live in it. The distance it may move in a frame is
that speed times the frame time, so it stops where it is, its walk queue never
empties, CreatureActionWalkToTile never completes, and the action that sent it
walking never gets to choose again. It is a statue that can still be attacked.

So look, once a turn, at whether the ground still holds:

checkWalkPathIsStillValid() walks the remaining path and, if any tile of it has
stopped being walkable, stops the creature and looks for another way to the same
destination. If there is none, it stops there and the action that started the
walk decides what to do instead, exactly as it would have done had the creature
arrived. A tile that refuses passage because of the building on it, a closed
door or a prison, does not count: those are meant to stop the creature, and it
was already walking towards one knowing what it would find.

checkStandsOnWalkableTile() puts a creature that is standing where it cannot
stand back on the closest tile where it can. That is the only way out for it,
since the null speed applies to its own movement as well. It only fires for
water and lava with nothing built on them, which is exactly what a bridge
leaves behind, rather than for anything that happens to give a null speed.

Also stop the client dereferencing the entity named by an entityTeleported
message without checking it knows it, since teleports are no longer limited to
the arena gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The level file format has carried a creature's level since forever, and the game
reads it back, but the editor always wrote 1: it spawned every creature straight
from its definition and offered no way to say otherwise. Levelling a creature in
a map meant editing the file by hand.

L now raises the level the editor gives to creatures, shift L lowers it, both
wrapping around the thirty levels a creature can have, and the status bar shows
it next to the class the way it already shows the fullness, the seat and the
class themselves.

The level applies to the creatures spawned from then on, and also to whatever
the hand is holding when it changes, which is what makes it possible to change a
creature that is already placed: pick it up, set the level, drop it. Only the
server knows what a hand holds, so the client sends the level and lets the server
decide what it lands on. An empty hand is not a mistake, it just means the editor
is setting the level of the creatures to come.

Placing a creature also heals it. Levelling raises the maximum HP without
healing, which is what levelling up in a game should do but not what an author
placing a level 20 hero means, and the difference would have been saved into the
map as a wounded creature.

The status bar field is 20 pixels wider so that the longest class name and a two
digit level still fit within it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
C cycles forward through the creature definitions and wraps around, so reaching
the class just before the current one means pressing it as many times as there
are classes. Shift C now steps back the same way shift L lowers the level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…iting

Shift Y walks the seats backwards, the way shift C and shift L already walk the
creature classes and the creature levels, so GameMap gains previousSeatId next
to nextSeatId.

None of that is discoverable. The status bar names the key next to each thing it
shows, which is how anyone finds T, Y, C and L in the first place, but there is
nowhere it could say that holding shift reverses them, and nothing at all
mentions the camera keys or the point of view hotkeys. Help then Controls now
opens a window listing all of them, grouped by what they act on. The Help menu
had one entry, About, which is not connected to anything.

The editor also never said which level it was editing. A new level is given a
name in the dialog that creates it and then never shown again, and loading one
from the recently used list gives no confirmation of what was opened. The name
now sits under the menu bar. It is read every frame rather than once, because
the client only learns it when the server sends the level over, which happens
after the editor is on screen, and it changes again on saving under a new name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The macOS branch of ResourceManager::setupDataPath() does not compile: it builds
the bundle path with applePath + "/", where applePath is a char array and "/" is
another array, so it is adding a pointer to a pointer. Whatever else is true of
the macOS build, it has not been attempted for a long time.

The editor's load and save dialogs start in $HOME, which on Windows is only set
if somebody has set it, so they opened on nothing. They now start in the folder
the player's own levels are saved to, which ResourceManager already resolves per
platform, and which is where a level being loaded or saved from the editor
belongs anyway. That was getEnv()'s only caller, and its comment already said it
should be doing something else on Windows.

Neither of these is verified on the platform it concerns. There is no macOS in
the build system beyond a single if(WIN32 OR APPLE), and the Windows CI is an
AppVeyor file pinned to a branch this fork does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A level file written on Windows does not load anywhere else. Its lines end with
a carriage return before the line feed, and only Windows takes that back off
when reading, so everywhere else the carriage return stays at the end of the
line. There it makes the last value on the line, or a section marker standing
alone, into something the parser does not recognise, and loading fails outright:
verified here by converting a level to CRLF, which turns into "The level file
can't be loaded". Helper::readFile now strips it, which covers every level and
every config file since they all come through there. The same level loads
cleanly afterwards, as do all twelve config files converted the same way.

Then the Windows specific parts:

The window icon is installed with SetClassLong and a handle cast to LONG. A
handle is 64 bits wide in a 64 bit build and LONG stays 32, so that does not
compile there. SetClassLongPtr is the same call in a 32 bit build.

The Discord link ran xdg-open, which is the freedesktop way and exists on
neither of the other two. It now uses ShellExecute on Windows and open on macOS.

The editor's file lists asked whether a name starts with a dot to decide whether
a file is hidden. On Windows that is an attribute of the file rather than
anything in its name, and the code that would have read it was commented out, so
the "show hidden files" checkbox did nothing. It now reads the attribute, which
needs the whole path rather than the file name.

setupOgreResources treated a path as absolute if it starts with '/', which no
absolute Windows path does. And the two places that make sure a folder ends with
a separator only accepted '/', so a path ending in a backslash got a slash added
after it.

Only the level file fix is verified: it is the only one of these that can be
reached from Linux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entering the editor a second time in one session kills the game:

    CEGUI::AlreadyExistsException ... Failed to add Element named: Adventurer
    to element at: EDITORGUI/Menubar/Creatures/PopupMenu4 since an Element with
    that name is already attached.

EditorMode is built anew every time the editor is entered, and its constructor
fills the Creatures menu with one item per creature definition, the recently
used files menu and the Seats menu. The window it fills them into belongs to the
Gui and lives as long as the game does, so the second visit finds all of them
already there. CEGUI refuses a second child of the same name by throwing, and
nothing catches that before it reaches main().

Two of those three menus already had a function to empty them, used when their
contents change while the editor is running, but it was never called on the way
in. The creature list had none, so it moves out of the constructor into the same
install and uninstall pair as the others, and all three are now emptied before
being filled. GameMode has the same kind of list, of the seats in the game, and
already removes it in its destructor, which is why only the editor is affected.

Emptying detaches the old items without destroying them, which is what the
existing functions do: one of them is called from the click handler of an item
it is removing, and destroying a window in the middle of dispatching its own
event would not end well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entering the editor a second time now segfaults on its first frame, reading
address 0x10 inside DebugDrawer::build() called from EditorMode::onFrameStarted.

RenderManager makes the debug drawer along with the scene, in createScene(),
which the ODFrameListener constructor calls once for the whole run of the game,
and destroys it in its own destructor. EditorMode's destructor deleted it too,
every time the editor was left. Ogre::Singleton clears its instance pointer as
it goes, so the second editor session found nothing there and drew through it
anyway: getSingleton() returned a null reference and build() read its manual
object, which sits at offset 16 of a DebugDrawer, from address 0x10.

The editor still clears the drawer of what it left behind, which is its own, and
checks first, since at shutdown the render manager may already have taken it.

This was hiding behind the exception fixed in the previous commit, which killed
the game earlier in the same sequence, while EditorMode was still being built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selling the middle of a bridge leaves two stretches of bridge that are still one
room. Everything done to a room is then done to both, and claiming is the one
that shows: an enemy worker dancing on one stretch takes the other one too,
across the lava it cannot reach.

Rooms merge but never split. checkForRoomAbsorbtion() makes one room out of two
that come to touch, and nothing does the reverse when tiles are lost, whether
sold or destroyed. So Room gains checkForSplit(), which gathers the covered
tiles into the groups that hold together, by the same neighbouring rule that
decides two rooms are really one, and hands every group but the biggest to a
room of its own. It runs after tiles are sold and after Building::doUpkeep()
removes the ones whose hit points reached zero, which is the enemy digging
through the middle of a room.

Handing the tiles over follows absorbRoom(), which does the same thing the other
way round. The new room gets a copy of what this one knew about each tile, and
what was built on it. This one keeps the original marked destroyed rather than
dropping it: a seat remembers which building covers each tile it can see and
asks that building about it until told otherwise, so a room that forgets a tile
it has handed over is asked about it anyway and reads through a null. Those
tiles are not offered for repair, since a tile the other room covers cannot be
built upon.

What a room keeps for itself rather than per tile is its own business, so
splitRoom() lets it give the new room its share. A bridge splits the value an
enemy has to dance away, in proportion to the tiles. A treasury has the opposite
problem: it counts the gold of every tile it has data for, not only the ones it
covers, so the copy left behind has to be emptied or selling the middle tile of
a treasury makes gold instead of costing it.

It is not only bridges. Any room can be cut in two, and a room in pieces gets
its active spots, its creature places and its centre wrong. Traps are claimable
and tile based as well and have the same bug, but they build their tiles
differently and are left alone here: Building::checkForSplit() does nothing
unless a building knows how to split.

source/tests/test_RoomSplit.cpp builds a treasury five tiles wide, fills it with
5000 gold and sells the tile in the middle. 4000 is left, the gold of the four
tiles that remain. Losing what the moved tiles held would leave 2000, and
copying it would make 6000. The test found both of those, and a crash, before
this commit was worth making.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The label over a creature is not attached to it. It is placed on the screen,
worked out each frame from where the camera sees the creature, so it is only
over it for as long as something keeps putting it back. Three things stopped
that from happening.

GameMap::updateAnimations() returns without doing anything while the game is
paused, and the labels are moved from there. The camera is not paused: it is
updated in frameRenderingQueued() before the check that stops the frame when the
game is. So pausing and then moving the camera left every label behind, over
whatever ground its creature had been standing on. The same holds before the
first turn. The labels are now put back in both cases, given no time so that
nothing they say changes and no creature with more than one mood to show takes
its turn while the game is not running.

The label is placed from the bounding box of the creature's mesh, taken as Ogre
holds it rather than derived. That box is from the last time the scene was
updated, and the creatures are moved after that, during the frame, so every
label was a frame behind whatever was moving.

And nothing hid the label of a creature that had died. A dead creature lies
where it fell for a few turns before it is taken away, wearing its level and its
mood the whole time. The client can tell: an overlay health value of the last
step means dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building with OD_BUILD_TESTING=ON has been broken for a while: the console
interface test no longer compiled, and once it compiled it did not link, and
once it linked it crashed. One layer at a time:

addCommand() lost its description parameter at some point, with the form that
takes one renamed to addCommandAux(), and the test still called the old one.

ConsoleInterface.cpp includes pybind11/embed.h without using anything from it.
The include drags Python symbols into the object file, which the game only
carries because it links pybind11::embed; the test target does not, and has no
reason to. The include goes away, which unbreaks the link and drops a spurious
dependency.

Executing a command no longer records it in the history: the history belongs to
whoever owns the prompt, and the game console, which now runs its commands
through Python, records what was typed itself. The test still assumed recording
was implicit, so its history was empty and it dereferenced the boost::none that
scrolling an empty history correctly returns. It now records each command the
way the game does, and the scrolling expectations hold as written: nine
commands in, the prompt round trip, nine commands back out.

The whole suite passes, each test against its own server the way
run_unit_tests.sh starts them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A seat remembers, for every tile of the game map and of the editor's copy paste
container, what it last told its player about it. The two containers keep their
states in two separate maps, filled once when each container is sized, and most
of the code looks a tile up in the first map and falls back to the second. Four
kinds of mistake had crept into that pattern, all in Seat.cpp:

hasVisionOnTile() and notifyTileClaimedByEnemy() find the tile in the fallback
map through one iterator and then read or write through the other, which is the
end of the first map. That is a read past the end of a std::map whenever the
server asks about a copy paste container tile.

updateTileStateForSeat() looked the tile up with operator[] before doing the
find with fallback. The lookup creates a default state for the tile in the game
map's map even when the tile belongs to the container, so the find always
succeeded, the fallback was dead code, and the update went to the stray entry
rather than to the container's own. The stray entries also outlive the
container whose tiles they are keyed by.

setVisibleBuildingOnTile(), notifyBuildingRemovedFromGameMap(),
tileMarkedDiggingNotifiedToPlayer() and exportTileToPacket() had no fallback at
all, only the inserting operator[]. They now share one lookup helper that
searches both maps and never inserts; the export keeps sending a default state
for a tile it does not know, since that is what the insertion amounted to.

Also two smaller ones found on the way, both in the render manager:
initGameRenderer() hides the creature overlays around its first render target
update with the same lockstep double loop, without null checks, that used to
crash ODFrameListener::frameStarted(); it gets the same fix. And a range for
in Building.cpp iterated the building objects through a reference of the wrong
pointer type, converting every element to a temporary pair on the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Multiplayer:

  Mirror Pass       89x89,   2 players, mirror symmetric
  The Salt Lake     105x105, 2 players, gold island behind water
  Crown of Embers   121x121, 4 players, hero vault
  The Four Gates    137x137, 4 players, the largest four player map

Skirmish:

  The Breach        89x89,   hero survival, two fast portals
  Thieves Gold      105x105, 1 rival keeper, race for the island gold
  Old Enemies       121x121, 1 rival keeper plus a hero vault
  The Last Gate     137x137, hero survival, six slow, heavy portals

Two things are new in the generator rather than merely new numbers. Mirror Pass
and Thieves Gold are built on a reflection instead of a rotation: the halves
face each other rather than being turned around the middle, so anything on the
axis, the gem pit, the lake, serves both players at the same distance instead of
coming in pairs. And the lake itself is a new centre: a gold island ringed by
water, which stops a digging worker in a way no wall of dirt does, so the only
ways in are the authored causeways. Every causeway is authored in the full
orbit, since each one crosses water the other stamps would re-lay.

Old Enemies is the first one on one skirmish with a hero vault, and The Last
Gate spreads six portals across the widest survival front so far. Sizes 89 to
137 fill the gaps between the existing maps; all sizes stay odd so the symmetry
has a centre tile to fix.

Checked the same way as the other nineteen: the grid maps onto itself under the
declared transform with the seat permutation derived from where the temples
land, early reachable gold is equal for every player, and every temple digs to
every portal and temple without crossing water or lava. The nineteen existing
maps regenerate byte for byte with the new code. All eight load in the engine
and hold a listening server with no warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spells tab put its ten buttons in a single row of 60 pixel squares running
out to x=700. The pane they live in is the window width minus the 200 pixels of
minimap, so at the 800 wide minimum window it is 600 pixels across, and the last
two spells, Weakness and the Eye of Evil, hung past its right edge where they
could be neither seen nor clicked. Only a window at least 900 wide showed the
whole row, and nothing said so.

The rooms tab solved the same problem long ago: two rows of 40 pixel buttons.
The spells tab now uses the same shape, five spells to a row, ending at x=300
with room to spare at any size the game accepts. The cooldown bar inside each
button now covers exactly its button too, instead of overhanging 20 pixels on
both sides, which with narrower buttons would have bled half way across the
neighbours.

The rooms tab had the tail of the same bug: the temple and portal buttons the
editor uses sat at x=570 to 690, past the same edge. They join the end of the
second row instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Upabjojr

Copy link
Copy Markdown
Author

sorry for this huge PR... I made a test with Claude Opus to see if it can fix and expand this game.

Each commit has an extensive description.

Upabjojr and others added 2 commits July 29, 2026 21:29
The waves of a wave portal could only be written by hand in the level file.
They are now edited from the editor: point at a portal, press P, and a window
shows the turns between waves, the strategy, the attack range and the target
teams, along with the list of waves and the creatures of each of them.

A client is never told about the rooms of a map, only about what its tiles look
like, and the waves are not part of what a room sends anyway. So the editor
names the portal to the server by one of its tiles and asks for the waves; the
server answers with them, and sends back what the player applied. The level is
saved from the server side, so that is where the change has to land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every seat writes the tiles it has ever seen, and the loop over the columns of
the map was bounded by the height of the map instead of its width. On a map
taller than it is wide it read past the end of the array: DuelToDeath is 120 by
151, and saving a game there left a level file cut off in the middle of that
list and took the game down with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomluchowski

Copy link
Copy Markdown
Owner

@Upabjojr hello, great to have a new contributor, do you mind joining our Discord channel and introduce yourself ? The link is the game when you close the application...

@tomluchowski

Copy link
Copy Markdown
Owner

BTW: can you help in similar manner with this one : #15 it almost works , except the CEGUI , go to OPTIONS-> AUDIO or GAMEPLAY. The percentage slider would be gone ( that's a tiny bitmap resembling an X sign -- at it gets lost in transition... donno why )

@Upabjojr

Copy link
Copy Markdown
Author

BTW: can you help in similar manner with this one : #15 it almost works , except the CEGUI , go to OPTIONS-> AUDIO or GAMEPLAY. The percentage slider would be gone ( that's a tiny bitmap resembling an X sign -- at it gets lost in transition... donno why )

I tried it... it's generated a lot of stuff in the other PR. I tried Claude Fable 5 this time instead of Claude Opus.

@tomluchowski

Copy link
Copy Markdown
Owner

IT might take time before I read everything ... so far I have checked out what is here marked as "Crash fixes " .... do you mind me I cherrypick the commits from your branch one at a time ?
I know git/github too little ,would love those commits be stucked into "packages" like : Crash fixes, Editor improvements, Gameplay and UI fixes etc.... donno if it's doable ..

@Upabjojr

Upabjojr commented Aug 1, 2026

Copy link
Copy Markdown
Author

Yes, no problem with cherry picking.

@Upabjojr

Upabjojr commented Aug 1, 2026

Copy link
Copy Markdown
Author

This PR has been split into 14 focused PRs, one per separable topic, to make review manageable:

Each branch starts from this repo's shaders-improvement and builds on its own; merging all of them reproduces this PR's tree exactly, so this PR can be closed in favour of the split. The PRs are independent except for three pairs with trivial adjacent-line conflicts, noted in their descriptions: #20#22 (ODServer.cpp), #20#21 (ResourceManager.cpp), #21#22 (EditorMode.cpp) — whichever of a pair merges second needs a one-spot resolution, and I'm happy to rebase the remaining PRs after each merge.

🤖 Generated with Claude Code

@tomluchowski

tomluchowski commented Aug 1, 2026

Copy link
Copy Markdown
Owner

great , but ... I have a feeling that not all commits were preserved :\
Where are for that example :

Fix the editor re-entry crash pair: CEGUI AlreadyExistsException from menus being filled twice (8ec6c20) and a segfault from EditorMode deleting the render manager's DebugDrawer singleton (893df06)
Fix undefined behaviour in Seat's per-tile state lookups: two end-iterator dereferences, a dead fallback and four accidental map inserts, replaced by a single checked helper (3804e4a)
<<<<<<<<<<<<<<<<<<<<<<<<<<<<

I cannot find it in any of the new PR's !

@Upabjojr

Upabjojr commented Aug 1, 2026

Copy link
Copy Markdown
Author

I cannot find it in any of the new PR's !

Probably Claude was either unable to separate them or it made a mistake.

@tomluchowski

Copy link
Copy Markdown
Owner

Can you introduce yourself to other developers here : https://discord.gg/K2JPXuchZV please please :)

@Upabjojr

Upabjojr commented Aug 2, 2026

Copy link
Copy Markdown
Author

All 26 commits of this PR were carried over into the split PRs — none were dropped. What changed is their hashes: the commits were cherry-picked onto this repo's shaders-improvement, so searching for the old short SHAs here finds nothing in the new PRs. Each carried commit records its origin in a (cherry picked from commit …) line in its message.

The two named in the review feedback:

Full mapping, old → new:

Original Now PR Subject
eac4b7d7 4b37b75c #17 Fix window event pumping, mouse cursor sync and window sizing
4d81fe4e 25f4c3d3 #18 Clamp camera scrolling by view target, not camera position
0474176e aa1eaf9d #18 Stop the camera flying at a minimap point outside the map
0302da98 633f507f #19 Fix dangling and null creature pointers on the client
d9089861 6a7a66b1 #19 Fix null tile dereference when a missile starts against a wall
8ce9ff49 26168a3a #20 Read config and levels from the user folder, seeded from the binary
7fdc1351 f49e2e63 #20 Report outdated extracted data by content, not by stamp
db2e2300 9ee783e8 #21 Fix two things that only ever worked on Linux
f45b4275 d20d9465 #21 Fix the rest of what only worked on Linux, and one level file bug it hid
efd537ca 78912d36 #22 Let the editor choose the level of the creatures it places
4202bd71 0f4efcc5 #22 Let shift C walk the editor's creature classes backwards
9b791287 3cd1c141 #22 Tell the editor's users what its keys do, and which level they are editing
8ec6c20d bcae4b68 #22 Empty the editor's data driven menus before filling them
893df060 e82edaf7 #22 Leave the debug drawer to the render manager that owns it
a5487071 2e54b135 #22 Let the editor change what a wave portal sends
4195cba3 b77993cf #23 Split a room in two when it stops holding together
3804e4ab 40ec889d #24 Fix how a seat looks up the state it keeps per tile
ab2858da ffc900e9 #24 Stop saving a game from walking off the side of the map
1319ed0b 3dba8452 #25 Keep the labels over the creatures they belong to
10ccb9f6 9c7adbef #26 Make the console test build and pass again
8a83239e ff50410c #27 Keep the spell cooldown next to the pointer counting down
ac9f1934 4c78d546 #28 Send creatures another way when the bridge they were using is sold
10db1cb8 bab847a6 #29 Lay the spell buttons out in two rows so the last ones fit on screen
9a43fbed db101d0b #30 Add six levels: three multiplayer, three skirmish
222e0da3 488aa5ad #30 Add thirteen more levels, up to 145x145 and eight players
0ebc0e88 26866a3e #30 Add eight more levels, on ground the first nineteen left untouched

(#30's three commits were additionally reworked afterwards — richer terrain, wildlife and hero dungeons — as described in that PR; the other PRs carry the original changes unmodified apart from the three trivial context-conflict resolutions noted in the PR descriptions.)

🤖 Generated with Claude Code

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.

2 participants