User File #639056507409110095

Upload All User Files

#639056507409110095 - CV3 Map Viewer Lua Script

cv3-map-viewer-combined.lua
113 downloads
Uploaded 2/2/2026 5:39 PM by scrimpeh (see all 27)
- Shows on-screen tiles +stairs - Shows tile buffer - Works in OOB rooms (mostly)
Demosntration at
--|-------------------------------------|--
--|       CV3 On-Screen Map Viewer      |--
--|          For BizHawk 2.9.1          |--
--|            NesHawk Core             |--
--|-------------------------------------|--
--| Last modified: 2026-02-02 scrm      |--
--|-------------------------------------|--
--| ROMs supported:                     |--
--|-------------------------------------|--
--| Akumajou Densetsu (J)               |--
--| Castlevania 3 - Dracula's Curse (U) |--
--|-------------------------------------|--

-- Global Configuration -- Adjust these values to suit your needs --

show_tiles_onscreen = true
show_tile_buffer = true
show_tile_spawn_zone = true
show_replaced_tiles = true
show_stair_data = true
show_ghost_stairs = true

tile_w = 14
tile_h = 14
tile_origin_x = -16
tile_origin_y = 196

-- Functions

function get_game_type()
	local board = gameinfo.getboardtype()
	if board == "ExROM" then
		game_type = "us"
	elseif board == "VRC6" then
		game_type = "jp"
	else
		error("Cannot determine game type " .. board)
	end
	print("Game type is " .. game_type .. ".")
end

function val(us, jp)
	if game_type == "jp" then
		return jp
	else
		return us
	end
end

-- MODULE Lua Table Util --

function table_key_set(arg)
	local result = {}
	for _, v in pairs(arg) do
		result[v] = true
	end
	return result
end

-- MODULE Stairs

-- Stair entrance format
-- 
-- Pointed to by $69 (U) / $66 (J)
-- 
-- Address Translation ADR - ROM:
-- $B88F -> $1F88F
-- 
-- Horizontal
-- aa__ yyyy: direction (UR, UL, DL, DR), Y tile
-- xxxx xxxx: X px
-- XXXX XXXX: X high
--
-- Vertical
-- aa__ YYYY: direction (UR, UL, DL, DR), Y high
-- yyyy yyyy: Y px
-- xxxx xxxx: X px
-- 
-- $FF as the first byte indicates sequence end
-- 
-- -- The game uses the camera position to find stairs in both horizontal and vertical rooms

local stair_data = {}

UP_RIGHT = 0
UP_LEFT = 1
DOWN_LEFT = 2
DOWN_RIGHT = 3

STAIR_PROPERTIES = { 
	[UP_RIGHT]   = { dx =  1, dy = -1, x_offs =  0, y_offs =  0, marker = { { 1, 1 }, { 6, 1 }, { 6, 6 } } },
	[UP_LEFT]    = { dx = -1, dy = -1, x_offs = -8, y_offs =  0, marker = { { 2, 1 }, { 7, 1 }, { 2, 6 } } },
	[DOWN_LEFT]  = { dx = -1, dy =  1, x_offs = -8, y_offs =  8, marker = { { 1, 6 }, { 6, 6 }, { 1, 1 } } },
	[DOWN_RIGHT] = { dx =  1, dy =  1, x_offs =  0, y_offs =  8, marker = { { 1, 6 }, { 6, 6 }, { 6, 1 } } }
}

-- Stair Types

STAIR_NORMAL = 0
STAIR_INVALID = 1
STAIR_V_DUPLICATE = 2

function read_byte(adr) 
	if adr < 0x8000 or adr >= 0xC000 then
		return memory.readbyte(adr)
	else
		return memory.readbyte(0x1C000 | (adr & 0x3FFF), "PRG ROM")
	end
end

function stair_data.read(vertical)
	-- See https://github.com/vinheim3/castlevania3-disasm/blob/main/code/bank0f.s#L264
	local stairs = {}
	local stair_data_adr = memory.read_u16_le(val(0x69, 0x66))
	if stair_data_adr >= 0x2000 and stair_data_adr < 0x8000 then
		-- Stair Pointer points into open bus, no way to predict this
		return stairs
	end

	-- In invalid rooms, the stair pointer may point to invalid stair data, which can cause the game to keep reading
	-- the entire range accesssed by the pointer, and then wrap around, reading the whole range again, but offset by 1
	-- After three loops through the whole sequence, a failsafe in the game kicks in, keeping the game from locking up

	local offs = 0
	local stair_type = STAIR_NORMAL
	if offs >= 256 or stair_data_adr < 0x8000 then
		stair_type = STAIR_INVALID
	end

	while read_byte(stair_data_adr + (offs & 0xFF)) ~= 0xFF and offs < 768 do
		local byte_0 = read_byte(stair_data_adr + (offs & 0xFF))
		local byte_1 = read_byte(stair_data_adr + ((offs + 1) & 0xFF))
		local byte_2 = read_byte(stair_data_adr + ((offs + 2) & 0xFF))

		local staircase = {
			flags = (byte_0 & 0xC0) >> 6,
			byte_0 = byte_0,
			byte_1 = byte_1,
			byte_2 = byte_2,
			offs = offs & 0xFF,
			stair_type = stair_type
		}
		staircase.down = staircase.flags == DOWN_LEFT or staircase.flags == DOWN_RIGHT
		staircase.right = staircase.flags == UP_RIGHT or staircase.flags == DOWN_RIGHT
		staircase.up = not staircase.down
		staircase.left = not staircase.right

		if vertical then
			-- The screen is only 15 tiles tall, the camera skips pixels 240 - 256
			-- Because of this, subtract 16 pixels for every full byte
			staircase.y = ((byte_0 & 0x3F) * 240) + byte_1 + 51
			staircase.x = byte_2
		else
			staircase.y = (byte_0 & 0x0F) * 16
			staircase.x = byte_2 * 256 + byte_1
		end

		table.insert(stairs, staircase)

		offs = offs + 3
	end

	if vertical then
		-- A bug in the 16-bit comparison code in vertical rooms seems to cause duplicate
		-- stairs one screen below the intended entrance
		local duplicate_stairs = {}
		for _, staircase in pairs(stairs) do
			local duplicate_staircase = {
				flags = staircase.flags,
				byte_0 = staircase.byte_0,
				byte_1 = staircase.byte_1,
				byte_2 = staircase.byte_2,
				offs = staircase.offs,
				x = staircase.x,
				y = staircase.y + 240,
				stair_type = STAIR_V_DUPLICATE
			}
			table.insert(duplicate_stairs, duplicate_staircase)
		end
		for _, duplicate_staircase in pairs(duplicate_stairs) do
			table.insert(stairs, duplicate_staircase)
		end
	end

	return stairs
end

-- MODULE Tile val

local tile_val = {}

-- Get all relevant information from the game's RAM
function tile_val.get()
	tile_val.gamestate = memory.readbyte(0x18)
	tile_val.substate = memory.readbyte(val(0x2A, 0x2C))
	tile_val.scroll_mode = memory.readbyte(val(0x68, 0x65))
	tile_val.vertical = tile_val.scroll_mode ~= 0
	tile_val.horizontal = not tile_val.vertical
	tile_val.scroll_down = (tile_val.scroll_mode & 1) == 1
	tile_val.scroll_up = not tile_val.scroll_down
	tile_val.room_size = memory.read_u8(val(0x71, 0x6E))
	tile_val.camera = memory.read_u16_le(val(0x56, 0x53))

	tile_val.tile_buffer = {}
	if tile_val.vertical then
		tile_val.tile_buffer = { w = 16, h = 15, start_adr = 0x6E0, end_adr = 0x760 - 1 }
		tile_val.cam_x = 0
		-- Camera skips pixels 240 - 255 in vertical rooms, so we correct for this here
		tile_val.cam_y = ((tile_val.camera >> 8) * 240) + (tile_val.camera & 0xFF)
	else
		tile_val.tile_buffer = { w = 24, h = 12, start_adr = 0x6E0, end_adr = 0x770 - 1 }
		tile_val.cam_x = tile_val.camera
		tile_val.cam_y = 0
	end
	local size = tile_val.tile_buffer.w * tile_val.tile_buffer.h
	tile_val.tile_buffer.tiles = memory.read_bytes_as_dict(tile_val.tile_buffer.start_adr, size)

	-- Stair data
	tile_val.stair_ptr = memory.read_u16_le(val(0x69, 0x66))
	if show_stair_data then
		tile_val.stair_data = stair_data.read(tile_val.vertical)
	end
	tile_val.stair_v_offs = 0
	if tile_val.vertical then
		-- For some reason, the game adds 51 pixels to the Y position of a stair case in vertical room
		-- but then, stairs are also placed one tile lower than expected. Don't ask me.
		tile_val.stair_v_offs = -35
	end

	tile_val.spawn_l_col = memory.read_u8(val(0x59, 0x56))
	tile_val.spawn_l_row = memory.read_u8(val(0x5B, 0x58))
	tile_val.spawn_r_col = memory.read_u8(val(0x5A, 0x57))
	tile_val.spawn_r_row = memory.read_u8(val(0x5C, 0x59))
end

-- MODULE Coords --

function tile_buffer_xy_to_offs(tx, ty)
	if tile_val.vertical then
		return ty * 8 + (tx // 2)
	else
		return (tx // 2) * 12 + ty
	end
end

function tile_buffer_offs_to_xy(offs)
	if tile_val.vertical then
		return (offs % 8) * 2, offs // 8
	else
		return (offs // 12) * 2, offs % 12
	end
end

function tile_buffer_xy_to_world(tx, ty)
	if tile_val.vertical then
		local ty_cam = (tile_val.cam_y // 16) % 15
		local dty = ty - ty_cam
		if ty_cam > ty then
			dty = (15 - ty_cam) + ty
		end
		return tx * 16, (tile_val.cam_y & 0xFFF0) + dty * 16 + 42
	else
		local tx_cam = (tile_val.cam_x // 16) % 24
		local dtx = tx - tx_cam
		if tx_cam > tx then
			dtx = (24 - tx_cam) + tx
		end
		return (tile_val.cam_x & 0xFFF0) + dtx * 16, ty * 16 + 24
	end
end

function world_xy_to_tile_buffer_xy(wx, wy)
	if tile_val.vertical then
		if wy < tile_val.cam_y or wy >= tile_val.cam_y + 240 then
			return nil
		end
		return (wx // 16), (wy // 16) % 15
	else
		if wy < 24 or wy >= 216 then
			return nil
		elseif wx < tile_val.cam_x or wx >= tile_val.cam_x + 256 then
			return nil
		end
		return (wx // 16) % 24, (wy - 24) // 16
	end
end

function world_xy_to_screen(wx, wy)
	return wx - tile_val.cam_x, wy - tile_val.cam_y
end

-- MODULE map viewer 

local map_viewer = {}


-- Gets the x, y position to draw on the screen
-- Negative values indicate from the right / bottom edge
function draw_get_pos(x, y, w, h)
	if x < 0 then
		x = client.screenwidth() + x - (w or 0)
	end
	if y < 0 then
		y = client.screenheight() + y - (h or 0)
	end
	return x, y
end

-- Definitions

local tile_buffer = {}

local new_tiles = {}
local new_tiles_max_ttl = 4

local draw_x = 0
local draw_y = 0

-- Scans the current offset on the screen for tile collision, simulating the game's assembly code
-- We cannot rely on the tile buffer when the camera is out of bounds due to the LUT accesses
-- seen below
--
-- I am once again indebted to vinheim...
-- https://github.com/vinheim3/castlevania3-disasm/blob/main/code/bank1f.s#L2710
function tile_buffer.get_byte_offs_h(sx, sy)
	if sy < 0x20 or sy >= 0xE0 then
		return 0
	end
	local ty = (sy - 0x20) // 16
	local wx = tile_val.camera + sx
	-- The game wants to determine the row in the meta tile buffer, and does so by
	-- by dividing the current screen position by 32 and ORing it with a times-8 table for room
	-- If the camera is out of bounds, this can return garbage
	local mtx = (wx & 0xFF) // 32
	local room_mtx = mtx | memory.readbyte(val(0xFD61, 0xFD62) + (wx >> 8))
	-- now the game finds the actual row in the meta tile buffer
	-- mod 12 to stay in bounds of the tile buffer...
	-- and times 12 to get the actual row offset. the game uses a lookup table for this again...
	-- this shouldn't go out of bounds though
	local mtx_buf = room_mtx % 12
	local mt_buf_offs_row = memory.readbyte(val(0xFD4C, 0xFD4D) + mtx_buf)
	-- finally, we can return the actual offs
	return mt_buf_offs_row + ty
end

function tile_buffer.get_tile(tx, ty)
	local t_offs = tile_buffer_xy_to_offs(tx, ty)
	local tiles = tile_val.tile_buffer.tiles[tile_val.tile_buffer.start_adr + t_offs]
	if tx % 2 == 0 then
		return tiles >> 4
	else
		return tiles & 0xF
	end
end

function tile_buffer.show_all(draw_fun)
	-- Sample tile grid at regular intertile_val.
	for t = tile_val.tile_buffer.start_adr, tile_val.tile_buffer.end_adr do
		local tile = tile_val.tile_buffer.tiles[t]

		local tile_l = tile >> 4
		local tile_r = tile & 0x0F

		local tx, ty = tile_buffer_offs_to_xy(t - tile_val.tile_buffer.start_adr)

		draw_fun(tile_l, tx, ty)
		draw_fun(tile_r, tx + 1, ty)
	end
end

function tile_buffer._draw_tile(tile_type, x, y, fg, bg)
	if tile_type ~= 0 then
		local x_pos = draw_x + tile_w * x
		local y_pos = draw_y + tile_h * y
		if not fg and not bg then
			fg = TILE_COLORS[tile_type]
		end
		gui.drawRectangle(x_pos, y_pos, tile_w, tile_h, fg, bg)
	end
end

function tile_buffer._draw_grid_lines(x, y, w, h)
	gui.drawRectangle(x, y, w, h, 0xFF666666)
	if tile_val.vertical then
		for i = 0, tile_val.tile_buffer.h  / 2 do
			local cur_y = y + (tile_h * i * 2)
			gui.drawLine(x, cur_y, x + w, cur_y, 0xA0666666)
		end
	else
		for i = 0, tile_val.tile_buffer.w  / 2 do
			local cur_x = x + (tile_w * i * 4)
			gui.drawLine(cur_x, y, cur_x, y + h, 0xA0666666)
		end
	end
end

function tile_buffer._show_camera_area(x, y, w, h)
	local x_end = draw_x + w
	local y_end = draw_y + h
	if tile_val.vertical then
		local cam_start = (tile_val.cam_y % (tile_val.tile_buffer.h * 16)) / 16
		local cam_end = (cam_start + 12) % tile_val.tile_buffer.h
		if cam_start < cam_end then
			gui.drawBox(draw_x, draw_y + cam_start * tile_h, x_end, draw_y + cam_end * tile_h, 0xFF666666, 0x4000CCCC)
		else
			gui.drawBox(draw_x, draw_y + cam_start * tile_h, x_end, y_end, 0xFF666666, 0x4000CCCC)
			gui.drawBox(draw_x, draw_y, x_end, draw_y + cam_end * tile_h, 0xFF666666,  0x4000CCCC)
		end
	else
		local cam_start = (tile_val.cam_x % (tile_val.tile_buffer.w * 16)) / 16
		local cam_end = (cam_start + 16) % tile_val.tile_buffer.w
		if cam_start < cam_end then
			gui.drawBox(draw_x + cam_start * tile_w, draw_y, draw_x + cam_end * tile_w, y_end, 0xFF666666, 0x4000CCCC)
		else
			gui.drawBox(draw_x + cam_start * tile_w, draw_y, x_end, y_end, 0xFF666666, 0x4000CCCC)
			gui.drawBox(draw_x, draw_y, draw_x + cam_end * tile_w, y_end, 0xFF666666,  0x4000CCCC)
		end
	end
end

function tile_buffer._show_spawn_zone(draw_x, draw_y, w, h)
	function draw_block(col, row, color)
		col = col % (tile_val.tile_buffer.w / 2)
		tile_buffer._draw_tile(1, col * 2, row * 2, 0, color)
		tile_buffer._draw_tile(1, col * 2 + 1, row * 2, 0, color)
		if row > 0 and row < 6 then
			tile_buffer._draw_tile(1, col * 2, row * 2 + 1, 0, color)
			tile_buffer._draw_tile(1, col * 2 + 1, row * 2 + 1, 0, color)
		end
	end

	if not show_tile_spawn_zone or not tile_val.horizontal then
		return
	end

	draw_block(tile_val.spawn_l_col, tile_val.spawn_l_row, 0x40C0C0FF)
	draw_block(tile_val.spawn_r_col, tile_val.spawn_r_row, 0x40C0FFC0)
end

function tile_buffer.show()
	-- Set drawing coordinates
	local w = tile_w * tile_val.tile_buffer.w
	local h = tile_h * tile_val.tile_buffer.h
	draw_x, draw_y = draw_get_pos(tile_origin_x, tile_origin_y, w, h) 

	-- Draw a dark backdrop
	gui.drawRectangle(draw_x - 2, draw_y -2, w + 4, h + 4, nil, 0xC0000000)

	tile_buffer._draw_grid_lines(draw_x, draw_y, w, h)
	tile_buffer._show_camera_area(draw_x, draw_y, w, h)
	tile_buffer._show_spawn_zone(draw_x, draw_y, w, h)

	-- Draw stair entrances
	if show_stair_data then
		for _, staircase in pairs(tile_val.stair_data) do
			local is_ghost_staircase = staircase.stair_type == STAIR_V_DUPLICATE
			if not is_ghost_staircase or show_ghost_stairs then
				local x = staircase.x + STAIR_PROPERTIES[staircase.flags].x_offs
				local y = staircase.y + STAIR_PROPERTIES[staircase.flags].y_offs
				local tx, ty = world_xy_to_tile_buffer_xy(x, y + tile_val.stair_v_offs)
				if tx ~= nil and ty ~= nil then
					if staircase.down or tile_val.vertical then
						ty = (ty - 1) % tile_val.tile_buffer.h
					end
					local color = "lime"
					if is_ghost_staircase then
						color = "yellow"
					end
					gui.drawRectangle(draw_x + tx * tile_w, draw_y + ty * tile_h, tile_w, tile_h, color, 0)
				end
			end
		end
	end

	-- Draw tiles
	tile_buffer.show_all(tile_buffer._draw_tile)
end

function tile_buffer.get_replaced()
	local write_offset = memory.readbyte(0x10)
	local x, y = tile_buffer_offs_to_xy(write_offset)
	local tiles = {
		offset = write_offset,
		x = x,
		y = y,
		ttl = new_tiles_max_ttl
	}
	new_tiles[write_offset] = tiles
end

function tile_buffer.show_new_tiles()
	-- Display the new tiles we collected in the callback
	local i, tiles = next(new_tiles, nil)
	while i do
		local alpha = math.floor((tiles.ttl / new_tiles_max_ttl) * 0x80)
		local color = forms.createcolor(0xFF, 0x80, 0x80, alpha)
		tile_buffer._draw_tile(1, tiles.x, tiles.y, 0, color)
		tile_buffer._draw_tile(1, tiles.x + 1, tiles.y, 0, color)

		if client.ispaused() then
			tiles.ttl = tiles.ttl - 0.05
		else
			tiles.ttl = tiles.ttl - 1
		end

		if tiles.ttl < 0 then
			new_tiles[tiles.offset] = nil
		end
		i, tiles = next(new_tiles, i)     
	end
end

-- MODULE on-screen-tiles

local on_screen_tiles = {}

local STAIR_COLORS = {
	[STAIR_NORMAL] = 0x00FF00,
	[STAIR_INVALID] = 0x20E000,
	[STAIR_V_DUPLICATE] = 0xD0F000
}

function on_screen_tiles._draw_tile_screen(tile_type, sx, sy)
	if tile_type ~= 0 then
		gui.drawRectangle(sx, sy, 16, 16, TILE_COLORS[tile_type], nil, "emucore")
	end
end

function on_screen_tiles._draw_tile(tile_type, tx, ty)
	local wx, wy = tile_buffer_xy_to_world(tx, ty)
	local sx, sy = world_xy_to_screen(wx, wy)
	-- Depending on which way the game is scrolling, different parts of the screen have collision
	-- Only draw tiles if the player can actually interact with them
	-- I am not sure if the game checks the screen position or has more sophisticated logic internally
	if tile_val.horizontal then
		on_screen_tiles._draw_tile_screen(tile_type, sx, sy)
	elseif tile_val.scroll_up and sy >= 224 then
		on_screen_tiles._draw_tile_screen(tile_type, sx, sy - 240)
	elseif not (tile_val.scroll_down and ((sy % 240) < 26)) then
		on_screen_tiles._draw_tile_screen(tile_type, sx, sy % 240)
	end
end

TILE_COLORS = {
	 [0] = 0x00000000, -- Empty
	 [1] = 0xFFB0E080, -- Mud
	 [2] = 0xFF80E0FF, -- Current Left
	 [3] = 0xFFE080FF, -- Current Right
	 [4] = 0xFFFFFFC0, -- Crumble
	 [5] = 0xFFFF0000, -- Spikes
	 [6] = 0xFFFFFFFF, -- Solid
	 [7] = 0xFFFF0000, -- Spikes
	 [8] = 0xFFFFFFFF, -- Solid
	 [9] = 0xFFFFFFFF, -- Solid
	[10] = 0xFFFFFFFF, -- Solid
	[11] = 0xFFFFFFFF, -- Solid
	[12] = 0xFFFFFFA8, -- Crumble 0
	[13] = 0xFFFFFF90, -- Crumble 1
	[14] = 0xFFFFFF78, -- Crumble 2
	[15] = 0xFFFFFF60  -- Crumble 3
}

function on_screen_tiles.show()
	if tile_val.horizontal then
		for wsx = 0, 256, 32 do
			for wsy = 32, 224, 16 do
				-- Horizontal rooms need special logic because they use LUTs, which can break at
				-- high camera positions
				local offs = tile_buffer.get_byte_offs_h(wsx, wsy)
				local tile = memory.readbyte(tile_val.tile_buffer.start_adr + offs)
				local tile_l = tile >> 4
				local tile_r = tile & 0x0F

				local sx = wsx - (tile_val.cam_x & 0x1F)
				local sy = wsy - 8

				on_screen_tiles._draw_tile_screen(tile_l, sx, sy)
				on_screen_tiles._draw_tile_screen(tile_r, sx + 16, sy)
			end
		end
	else
		tile_buffer.show_all(on_screen_tiles._draw_tile)
	end
end

function get_world_tile(staircase, wx, wy)
	local tx, ty = world_xy_to_tile_buffer_xy(wx, wy + tile_val.stair_v_offs)
	if tx == nil or ty == nil then
		return nil
	end
	return tile_buffer.get_tile(tx, ty)
end

function draw_staircase(staircase, num_stairs)
	local color_outline = 0xFF000000 | STAIR_COLORS[staircase.stair_type]
	local color_fill = 0x30000000 | STAIR_COLORS[staircase.stair_type]
	local is_duplicate_staircase = staircase.stair_type == STAIR_V_DUPLICATE
	if is_duplicate_staircase and not show_ghost_stairs then
		return
	end

	local stair_overload = num_stairs > 32
	local props = STAIR_PROPERTIES[staircase.flags]
	local x = staircase.x + props.x_offs
	local y = staircase.y + props.y_offs

	local start_x, start_y = world_xy_to_screen(x, y)
	if stair_overload or is_duplicate_staircase then
		start_y = start_y + 8
	end
	if start_y > 32 then
		gui.drawPolygon(props.marker, start_x, start_y - 9, color_outline, color_fill, "emucore")
	end

	if tile_val.vertical then
		if (tile_val.scroll_up and start_y >= 224) or (tile_val.scroll_down and ((start_y % 240) < 26)) then
			return
		end
	end

	-- Trace staircase until it hits an obstacle
	-- Unless there's way more stairs in the room than there should be
	-- In that case, peace out, and just draw the entrances. we do this for both performance and visual clutter
	if is_duplicate_staircase then
		-- For duplicate staircsaes, we just draw a small mark at the exact entrance height you need to line up your feet with to see it
		local x_cur, y_cur = world_xy_to_screen(staircase.x, staircase.y + 6)
		gui.drawLine(x_cur - 6, y_cur, x_cur + 6, y_cur, color_outline, "emucore")
		gui.drawLine(x_cur, y_cur - 1, x_cur, y_cur + 1, color_outline, "emucore")
		return
	elseif stair_overload then
		return
	end

	while true do
		if tile_val.horizontal and (y < 0 or y >= 240) then
			break
		elseif tile_val.vertical and (x < 0 or x >= 256) then
			break
		end

		local step_x, step_y = world_xy_to_screen(x, y)
		if step_y > 32 then
			gui.drawRectangle(step_x, step_y, 8, 8, color_outline, color_fill, "emucore")
			gui.drawRectangle(step_x + props.dx * 8, step_y + props.dy * 8, 8, 8, color_outline, color_fill, "emucore")
		end
		x = x + props.dx * 16
		y = y + props.dy * 16

		-- check if the tile terminates
		local y_check_offs = 0
		if staircase.up then
			y_check_offs = 8
		end
		local t_a = get_world_tile(staircase, x, y + y_check_offs)
		local t_b = get_world_tile(staircase, x - props.dx * 16, y + y_check_offs)
		if (t_a == nil or t_a ~= 0) or (t_b == nil or t_b ~= 0) then
			break
		end
	end
end

function on_screen_tiles.show_stairs()
	if not show_stair_data then
		return
	end

	local num_stairs = 0
	for _, staircase in pairs(tile_val.stair_data) do
		if staircase.stair_type ~= STAIR_V_DUPLICATE then
			num_stairs = num_stairs + 1
		end
	end

	for _, staircase in pairs(tile_val.stair_data) do
		draw_staircase(staircase, num_stairs)
	end

	-- Draw Stair Info
	local stair_draw_x = 2
	local stair_draw_y = 36
	local draw_pos = client.transformPoint(stair_draw_x, stair_draw_y)
	local open_bus_message = ""
	local stair_count = #tile_val.stair_data .. " staircases"
	if tile_val.stair_ptr > 0x2000 and tile_val.stair_ptr < 0x8000 then
		stair_count = "? staircases (Open bus)"
	end

	local message = string.format("Stair PTR: $%04X, %s", tile_val.stair_ptr, stair_count)
	gui.text(draw_pos.x, draw_pos.y, message)
end

-- Static Data

-- See https://datacrystal.tcrf.net/wiki/Castlevania_III:_Dracula%27s_Curse/RAM_map
-- The other game states don't show the map or do not align the scroll with the camera
local GAMEPLAY_SUBSTATES = table_key_set({ 0x03, 0x05, 0x0A, 0x0B, 0x0C, 0xF, 0x10, 0x11, 0x13, 0x16, 0x19, 0x1A, 0x1B, 0x1C })

-- Game Values

local replaced_tiles_cb_registered = false

-- Main script loop, execute every frame --
function map_viewer.show()
	tile_val.get()

	-- Check if ingame
	if tile_val.gamestate ~= 4 or not GAMEPLAY_SUBSTATES[tile_val.substate] then
		return
	end

	if show_tiles_onscreen then
		on_screen_tiles.show()
		on_screen_tiles.show_stairs()
	end

	if show_tile_buffer then
		tile_buffer.show()
	end

	if show_replaced_tiles then
		if not replaced_tiles_cb_registered then
			event.onmemoryexecute(tile_buffer.get_replaced, val(0xD29E, 0xD273), "cv3_map_viewer_tile_buffer_show_replaced")
			replaced_tiles_cb_registered = true
		end
		tile_buffer.show_new_tiles()
	else
		event.unregisterbyname("cv3_map_viewer_tile_buffer_show_replaced")
		replaced_tiles_cb_registered = false
	end
end

-- Game Values

local game_type = nil

-- Start Execution --

console.clear()

gui.clearGraphics("client")
gui.clearGraphics("emucore")
gui.cleartext()

print("Starting CV3 Map viewer...")

get_game_type()
gui.use_surface("client")

-- Main script loop, execute every frame --
while true do
	map_viewer.show()

	local cur_framecount = emu.framecount()
	repeat
		emu.yield()
	until cur_framecount ~= emu.framecount() or client.ispaused()

	gui.clearGraphics("client")
	gui.clearGraphics("emucore")
	gui.cleartext()
end