Skip to content

Custom level generation#750

Open
thicco-catto wants to merge 48 commits into
TeamREPENTOGON:mainfrom
thicco-catto:level-gen
Open

Custom level generation#750
thicco-catto wants to merge 48 commits into
TeamREPENTOGON:mainfrom
thicco-catto:level-gen

Conversation

@thicco-catto

Copy link
Copy Markdown
Collaborator

Added a new callback and a couple functions that allow for mods to generate custom floors.

MC_PRE_GENERATE_DUNGEON
Called before level::generate_dungeon is called, which places all the rooms in a floor. Passes an RNG.
Return true to cancel vanilla floor generation.

Level:ResetRoomList(bool)
Needs to be called before placing any rooms in the custom level.
The bool is the same as the one in level::init. Testing it so far, setting it to true makes it work all the time.

Level:SetLastBossRoomListIndex(int)
Also needs to be called after creating the custom floor, since not setting it crashes the game when continuing.
Doesn't actually need to be set to a boss room for the game to not crash, but I haven't tested any other side effects apart from boss rooms not spawning the trapdoor if they are not the last.

Sample
image

local TestMod = RegisterMod("Test Mod", 1)

local function place_at(room, col, row, seed)
    local level = Game():GetLevel()

    local entry = Isaac.LevelGeneratorEntry()
    entry:SetAllowedDoors(15)
    entry:SetColIdx(col)
    entry:SetLineIdx(row)

    level:PlaceRoom(entry, room, seed)
end

local function get_normal_room(seed)
    return RoomConfigHolder.GetRandomRoom(seed, false, StbType.BASEMENT, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_1x1, -1, -1, 0, 10, 15)
end

TestMod:AddCallback(ModCallbacks.MC_PRE_GENERATE_DUNGEON, function (_, rng)
    local level = Game():GetLevel()
    level:ResetRoomList(true)

    print(rng:GetSeed())

    local start_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, StbType.SPECIAL_ROOMS, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_1x1, 2, 2)
    local big_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, StbType.BASEMENT, RoomType.ROOM_DEFAULT, RoomShape.ROOMSHAPE_2x2, -1, -1, 0, 10, 255)
    local boss_room = RoomConfigHolder.GetRandomRoom(rng:Next(), false, 0, RoomType.ROOM_BOSS, RoomShape.ROOMSHAPE_1x1)

    place_at(start_room, 6, 6, rng:Next())
    place_at(get_normal_room(rng:Next()), 7, 6, rng:Next())
    place_at(boss_room, 8, 6, rng:Next())
    place_at(get_normal_room(rng:Next()), 6, 7, rng:Next())
    place_at(get_normal_room(rng:Next()), 6, 8, rng:Next())
    place_at(get_normal_room(rng:Next()), 7, 8, rng:Next())
    place_at(get_normal_room(rng:Next()), 8, 7, rng:Next())
    place_at(big_room, 8, 8, rng:Next())

    level:SetLastBossRoomListIndex(2)

    return true
end)

@epfly6 epfly6 requested a review from ConnorForan November 3, 2025 11:57
@EliteMasterEric

Copy link
Copy Markdown

Was going to test this out but it appears to be under heavy development still (I downloaded d313242 and after figuring out the dungeon generator controller, invoking it caused a 0xc0000005 (access violation) error).

Very excited for it to be stable enough to do more stress testing with.

Comment thread repentogon/Patches/ASMPatches/ASMLevel.cpp
Comment thread repentogon/Patches/ASMPatches/ASMLevel.cpp Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
#include "Log.h"
#include "LuaDungeonGenerator.h"

DungeonGenerator* GetDungeonGenerator(lua_State* L) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LUALIB_API DungeonGenerator* GetDungeonGenerator(lua_State* L)

I don't know exactly how important the LUALIB_API bit is, but its typically used for such functions. Might impact properly surfacing lua errors.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump.

Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.h Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.h Outdated
Comment thread repentogon/Patches/ASMPatches/ASMLevel.cpp Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated

std::vector<RoomCoords> forbidden_coords = GetForbiddenNeighbors(base_coords, room_shape, doors);

for (int i = 0; i < this->num_rooms; i++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest having the generator hold a map that ties gridIdx to roomIdx, in order to quickly find the neighboring rooms, and see if a room already occupies this room's slot, as going through the whole room list is not really efficient.

Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
LUA_FUNCTION(Lua_PlaceDefaultStartingRoom) {
DungeonGenerator* generator = GetDungeonGenerator(L);

int doors = (int)luaL_optinteger(L, 2, 15);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the start room has essentially a predefined config, and GetRandomRoom can only ever pick one room, due to the variant range being set to just the value 2, there is no need to pass the doors parameter.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doors parameter limits the allowed doors for the room. It could be useful to mimick the behaviour of the mega satan door/polaroid door

@Guantol-Lemat Guantol-Lemat Nov 8, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be better to use a system similar to the blocked grid indices, rather than having the user having to deal with doors directly. In the original level gen the doors parameter is meant as a way to specify which doors are necessary, not those that are allowed. Of course, we should still allow control over the doors parameter, when doing very specific things, like how secret rooms and ultra secret room generation has to mark certain doors as allowed, post layout generation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was basing the doors parameter on the LevelGeneratorEntry.doors parameter, since I'm later setting the LevelGeneratorEntry doors field to that. There it does specify the doors argument refers to the allowed doors.
https://repentogon.com/LevelGeneratorEntry.html

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LevelGeneratorEntry is a wrapper for a LevelGenerator_Room and LevelGenerator_Room only specifies the necessary rooms, not the allowed ones. Most likely the parameter was named like that because the necessary rooms end up being used as the allowed rooms when placing the room.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump. IDK if I have input here but need to decide how we want to handle non-standard door layouts for the starting room.

Maybe DM guantol.

Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated
Comment thread libzhl/functions/Level.zhl Outdated
Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated
Comment thread repentogon/Patches/ASMPatches/ASMLevel.cpp Outdated
this->shape = room->Shape;
}

RoomConfig_Room* DungeonGeneratorRoom::GetRoomConfig(uint32_t seed, int required_doors) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this function was originally meant to do something else due to the unused parameters and unnecessary check for nullptr, what was this supposed to do originally?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump.

Remove unused parameter, and simply return this->room;

Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated
this->shape = room->Shape;
}

RoomConfig_Room* DungeonGeneratorRoom::GetRoomConfig(uint32_t seed, int required_doors) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump.

Remove unused parameter, and simply return this->room;

Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp
#include "Log.h"
#include "LuaDungeonGenerator.h"

DungeonGenerator* GetDungeonGenerator(lua_State* L) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bump.

Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp
Comment thread repentogon/LuaInterfaces/LuaDungeonGenerator.cpp Outdated
Comment thread repentogon/resources/scripts/main_ex.lua Outdated
}

g_Game->_lastBossRoomListIdx = this->final_boss_index;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DungeonGenerator should support off-grid rooms.

Most likely by adding DungeonGeneratorRoom offGridRooms[21]; (just flip the index)

It should maybe be possible for lua to place RoomConfig_Rooms into these slots (but only by placing RoomConfig_Rooms).

Now, if they were not populated by the mod, we must initialize the following off-grid rooms for (mostly) ALL dungeons:

  • Error room (-2)
  • Crawlspace dungeon (-4)
  • Black market (-6)
  • Members card secret shop (-13) (except home & ascent)
  • Stairway to heaven angel shop (-18) (except home)
  • The Rep+ lil portal room (-20)
  • The Beast's room in Home?? (-10)

For generate_dungeon specifically we need these as well, regardless of floor with some exceptions (yes, the game always populates boss rush / mega satan / etc):

  • Boss Rush (-5)
  • Mega Satan (-7)
  • Blue Womb trapdoor (-8) (for all stages except blue womb)
  • Void trapdoor (-9) (for blue womb only)
  • The "secret exit" room SPECIFICALLY for returning to the main path from Repentance alt path floors (-10)

These will (mostly) boil down to GetRandomRoom calls, but please refer to the 1.7.9b logic (easier to read, tho doesnt help for lil portal) to make sure we do this correctly. Ask for help in the discord thread if needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added support for all of these except for the last one "The "secret exit" room SPECIFICALLY for returning to the main path from Repentance alt path floors (-10)", I couldn't really see when this room spawns exactly (or at all). Could you specify further?

Comment thread repentogon/LuaInterfaces/CustomCallbacks.cpp Outdated
{
bool skip = ProcessGenerateDungeonCallback(this, *rng, DEFAULT);
if (skip) {
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to confirm if there's anything else we need to do here since we're skipping the vanilla code, like anything the game changes or sets that isnt directly tied to the LevelGenerator portion.

I'll need to defer to Guantol for that, though.


bool CanRoomBePlaced(XY& base_coords, int shape, int allowed_doors, bool allow_unconnected);

void BlockPositionsFromAllowedDoords(XY& base_coords, int shape, int allowed_doors);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo.
Also this is unused.


#pragma region Helpers

void PushCoordsIfValid(std::vector<XY>& list, XY& coords) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should be static, as it is not exposed in the LevelGenUtils API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All methods here should be in their a "LevelGenUtils" namespace.

}

std::vector<XY> GetOccupiedCoords(XY& base_coords, int shape) {
std::vector<XY> occupied_coords = {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor optimization: since we know the max amount of occupied coords, we can safely reserve enough space for the vector and avoid reallocations.

}

std::vector<XY> GetForbiddenNeighbors(XY& base_coords, int shape, int doors) {
std::vector<XY> forbidden_neighbors = {};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can preallocate enough memory for this vector as well.

bool has_final_room = this->final_boss_index >= 0;

// Check if all rooms can fetch a room config
int initial_seed = this->rng->_seed;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to just create a copy of the RNG, that way it's guaranteed there are no side effects.

}

int maxVariant = (int)luaL_optinteger(L, 8, -1);
if (maxVariant < minVariant && maxVariant >= 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really a problem with this implementation, since this is a copy of the GetRandomRoom checks, but using int instead of unsigned int complicates the logic a bit. -1 is used as a way to quickly reach the maximum unsigned integer, making it "uncapped", rather than being a special value.

this->ResetLevelGenerator();

this->final_boss_index = -1;
for (size_t i = 0; i < 169; i++)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Given that rooms need to be synchronized with the LevelGenerator rooms I would place this reset in ResetLevelGenerator.

this->level_generator._rooms.clear();
}

void DungeonGenerator::Reset() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Offgrid rooms are not reset.

DungeonGeneratorRoom rooms[169];
DungeonGeneratorRoom off_grid_rooms[20];
RNG* rng;
Level* level;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary?

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.

4 participants