User File #638469016728918485

Upload All User Files

#638469016728918485 - Visualize chances of getting a level selected (not 100% random!)

draw_roulette.lua
Game: Virtual Bart ( Genesis, see all files )
54 downloads
Uploaded 3/24/2024 6:27 PM by Velpa (see all 4)
I often hear that the level selected in Virtual Bart is random, but this is actually not true!
Depending on when exactly you press Start, A, B or C, you can get different chances of getting different levels! The goal of this Lua script is to display exactly that and explain (through the comments) how it was found and how you can use that information for your runs. I give a concrete example through the 'timing_mode', which I personally used for my No Damage Run of the game (you can find it in YouTube if you are interested). Of course, you can also use it for TAS to know when it's impossible to get certain levels, helping in your strategy planning.
--AUTHOR: VelpaChallenger, you can find me with that username in both YouTube and Discord (open to DMs for
--any questions, concerns, etc., in YouTube you can also find other of my social media if you don't use
--Discord for example.)

--[[ HOW TO USE
Visualize how a level is selected in Virtual Bart for the Sega Genesis (I didn't do the same for the SNES version
so I can't tell if it's the same logic or different, although I do know from Maskaman's run that the first time
you play the game, you are guaranteed to get a level if you press the button at more or less the same position,
it doesn't seem to have to be frame-perfect, but afterwards it seems to be random, it would be awesome if someone
could do the same for the SNES version and confirm, there's more work to be done! =D ). This can be helpful for:
    --Just learning, satisfying curiosity, 'is it actually THAT random?', etc. etc.
    --If you are doing a No Damage Run (like me, it's already completed as of March 23th 2024 though not yet
    --uploaded but I'm on that as I type this), see at which frames you can press a button to guarantee that
    --you will get either the -1UP/+1UP level or the desired level, such as Pig or Dinosaur, this can help
    --to do first the hardest levels and overall plan your strategy.
    --For speedruns, I think this information might help to know when to press buttons to reduce chances of
    --getting the -1UP/+1UP icon, which takes time of course.
    --It's a tool, you can use it for whatever purpose you might think it fits :) .
There are a couple of config options which I will explain below (you can directly skip to CONFIG section)
]]

--[[ HOW IT WAS FOUND
C7AF stores the current level. I made sure of this by pressing a button, getting a level (Pig, for example) and
going to the Hex Editor and updating it to whatever other (valid, that is, from 0 to 6) value. Even though I
was in another position corresponding to another level, I was still directed to the level that I entered
in the Hex Editor.

After that, I monitored how C7AF changed depending on the 2-bytes angular position stored in C1EA (C1EA = 0 is NOT 
when Bart's head is positioned north with respect to the center though, that's why later down I change the angle of 
the coordinates system). Eventually, I realized the level selected depends on which range of 0x400 are we in, or in
other words, on the result of dividing the current angular position by 0x400, although I still made sure manually 
that the below values are correct. They are.
(if you increase/decrease the value, C7AF changes accordingly)
(Also, freezing helped a lot, since I could change the angular position in the Hex Editor and then freeze it
to see the change in C7AF without having to, say, rewind or something like that to see the effects of the
angular position change on C7AF)

]]

--TABLE: what level is selected depending on range of angular position stored in C1EA.
--0, (1024, 2303) HEX: 0x400 - 0x8ff
--1, (2304, 3583) HEX: 0x900 - 0xdff
--2, (3584, 4863) HEX: 0xe00 - 12ff
--3, (4864, 6143) HEX: 0x1300 - 0x17ff
--4, (6144, 7423) HEX: 0x1800 - 0x1cff
--5, (7424, 8703) HEX: 0x1d00 - 0x21ff
--6, (8704, 1023), HEX: 0x2200 - 0x3ff --it wraps around and the cycle restarts.

--ASSUMPTIONS:
    --Input delay is constant (in practice, SOMETIMES your input is processed too fast by 1 frame or too slow by
        --1 frame, so there's a margin error, however it's still accurate enough that you can see the chances of
        --getting a level if you press at a certain frame, also non-constant input delay isn't as constant, meaning
        --it doesn't happen so often.)
    --There's a specific scenario that happens rarely enough to not be considered by the script. The thing is,
        --C7AF actually takes a little bit of time to update. So it may be the case that your angular position
        --is such that you should get the next C7AF level, but because it takes one more frame to get updated,
        --the game will "believe" (according to its logic) that you are still in the previous level! And this is a 
        --problem IF and big IF (this is why it's a rare scenario and why it's not considered in the script) the
        --previous level was NOT completed. Why? Because if it was not completed, the game will consider it's
        --time to start decelerating (stopping, getting speed to 0), so your final position will be where you
        --were (remember, actually in the already completed level, the game assumed you were in the non-completed
        --level but you were actually in the already completed level) minus 96, still not enough to end in the
        --non-completed level, so it doesn't break which levels can be selected AND it doesn't break the chances
        --either, because of the assumption below that levels are never completed. I still thought this scenario
        --was worth noting though. Perhaps it could go to GOOD TO KEEP IN MIND but even though it doesn't break
        --the script in any way (not that I know of at least, but I reviewed it many times), I think it's still an
        --assumption this scenario never happens.
        --(also, when this happens, you will see Bart being "shoved" by some mysterious force after he reached
        --a very slow speed, until he gets to the next non-completed level clockwise, it's actually very funny 
        --to watch hahaha.)¨
        --(also, for whatever reason C1EA positions don't seem to be the same when levels are completed, so
        --so part of why the "shove effect" as I called it happens is because of this, otherwise you can
        --end in the non-completed level and not see any "shove effect", but in all scenarios, your final
        --C1EA position is still as calculated, so this is more a fun fact in that sense.)
    --Levels are never completed. Completed levels are not taken into account but there's a reason for that and
        --it's that they don't affect calculations, they can just produce the above scenario but other than that
        --they don't make any difference in the algorithm. If in the algorithm you get a completed level, you can
        --safely assume that you WILL get the next non-completed level.

--GOOD TO KEEP IN MIND:
    --IT'S DESIGNED TO WORK IN BIZHAWK! Sorry for the uppercase but I remember trying to run Lua scripts and
        --they just failed because they used APIs not compatible with other emulators other than BizHawk. So,
        --please keep it in mind, if you want to migrate it to, say, Gens, you'll probably have to change a
        --couple of calls (hopefully just a couple, am I being too optimistic?).
    --Even though the algorithm to select a level depends partially on the H/V counter, which is deterministic on
        --emulator but not on console, the chances are still the same: only difference is that they'll (likely) be
        --more accurate on console than on emulator. That means that if you try to select a level by pressing
        --at the exact same frame/angular position say, a hundred (100) times, you'll most likely see the chances
        --displayed here, for example, 25% of the time you'll get one level, 20% of the time you'll get another,
        --etc.
    --Simplified formula to get level is C1EAf = (C1EAi + 480 + 192*C1E8) % 9216, where C1EAi is the initial
        --angular position when the input was pressed (Start, A, B or C, they can all affect which level will be
        --selected but not the chances themselves, simply the H/V counter will be checked at different times due
        --to different amounts of code being processed.), C1E8 can vary from 0 to 31 both inclusive (so total of
        --32 possibilities/possible values) and C1EAf is the final angular position after applying the formula
        --with all the values known. Then you can convert the C1EAf to a C7AF level, and you got it! (that's how
        --the code works actually)
    --When a level is completed, logic changes like this: IF at the moment C1E8 reaches -1, C7AF (not C1EA, that's
    --why the "shove effect" described in ASSUMPTIONS happens) stores a level that is already completed, THEN
    --Bart will continue to move at 192 speed, UNTIL C7AF stores a level that is not completed. At first, you
    --might think this breaks the script, but it actually doesn't. It just delays the rest of the math to more
    --frames depending on how far away Bart is from a completed level, but in the end he will end in the same
    --position minus 96, which will be always the next non-completed level. Yes, the calculations might have
    --said you were going to end at a completed level, but because we are saying that equals to getting the
    --next non-completed level clockwise, it just works out. You just have to wrap your head around the idea
    --that if for example Water and Picture are completed, then you have much higher chances of getting Bike
    --because if as per the calculations you should get Water, you will get Bike, and same goes for Picture.

--SPECIAL THANKS:
    --To people in the TASVideos's Server, I don't want to say specific names because honestly a lot of people
    --helped me, explaining me about the H/V counter in the Sega Genesis and also about other internals/
    --specifics of M68K's assembly language.
    --MarkeyJester's M68K tutorial, link: https://mrjester.hapisan.com/04_MC68/Index.html
    --Wikibooks at https://en.wikibooks.org/wiki/68000_Assembly (not sure who wrote it)
    --https://www.nxp.com/docs/en/reference-manual/M68000PRM.pdf (idem)
    --http://wpage.unina.it/rcanonic/didattica/ce1/docs/68000.pdf (idem)
    --People at SpritesMind.Net, even though I didn't directly talk to any of them, many posts specially on
    --H/V counter helped me have a better understanding
    --(There is more than I am probably forgetting, I mean I made a lot of Google search and got help from a lot
    --of different sites, but it's kinda impossible to list them all so I made my best to list those.)

--CONFIG
draw_rectangle_bool = true --Draws rectangles around the potentially selectable levels AND it also shows you the potential final C1EA positions.
draw_text_bool = true --Draws the minimum and maximum selectable levels at any C1EA position and also whether you pressed the button at the right frames or not.
draw_string_bool = true --Draws the 'CHANCES' text around the potentially selectable levels and also the current mode for the config below.
draw_only_percentage = true --I understand the 'CHANCES' text is huge and it can be bad for some (it's not for me honestly, I prefer it that way, in fact it was the original design), so I added this option in case you only want to see the %.
timing_mode = false --Ok I didn't know how to shorten it but it's an option specifically designed for my No Damage purposes, it shows the current mode AND it lets you know if you pressed the START button specifically (doesn't work with A, B or C, feel free to add it if you want) in the frames that it would allow you to get the desired level. If you are interested, try it and/or change it to adjust to your needs ;) .

--And now... the code (that is not config)

function get_level_selected(angular_position_in_game)
    if (angular_position_in_game >= 1024 and angular_position_in_game <= 2303) then
        return 0
    elseif (angular_position_in_game >= 2304 and angular_position_in_game <= 3583) then
        return 1
    elseif (angular_position_in_game >= 3584 and angular_position_in_game <= 4863) then
        return 2
    elseif (angular_position_in_game >= 4864 and angular_position_in_game <= 6143) then
        return 3
    elseif (angular_position_in_game >= 6144 and angular_position_in_game <= 7423) then
        return 4
    elseif (angular_position_in_game >= 7424 and angular_position_in_game <= 8703) then
        return 5
    elseif (angular_position_in_game >= 8704 or angular_position_in_game <= 1023) then --It can be very small OR very big.
        return 6
    end
end

function restart_start_pressed()
    start_pressed = false
end

function clear_level_selected_chances()
    level_selected_chances = { --By default, they all have zero chances of getting selected. Guilty until proven otherwise. Or innocent until proven otherwise.
    0,
    0,
    0,
    0,
    0,
    0,
    0,
    }
end

function draw_level_selected_chances()
    for i=0,6 do
        if level_selected_chances[i+1] ~= 0 then --Don't even bother to do any drawing at all if the level doesn't have any chance of being selected.
            local pos_x = get_pos_x_level(i)
            local pos_y = get_pos_y_level(i)
            my_draw_rectangle(pos_x, pos_y, 31, 39, "#800080")
            local chances = string.format("%.2f%%", level_selected_chances[i+1] / 32 * 100) --Remember, it's 0 to 31 both inclusive, so 32 total. And I decide two decimal places is accurate enough so that's what the 2f means.
            local offset_x = get_offset_x(i)
            local offset_y = get_offset_y(i)
            local chances_string = ""
            if not draw_only_percentage then --If you want the chances text to be displayed also
                chances_string = "Chances: "
            end
            my_draw_string(pos_x + offset_x, pos_y + offset_y, chances_string .. chances, "#0000FF", nil, 11, nil, "Bold") --Chances text.
        end
    end
end

function get_pos_x_level(level)
    return level_selected_x[level+1] --Tables in Lua start from 1, to keep in mind.
end

function get_pos_y_level(level)
    return level_selected_y[level+1]
end

function get_offset_x(level)
    return offset_level_selected_x[level+1]
end

function get_offset_y(level)
    return offset_level_selected_y[level+1]
end

function my_draw_text(pos_x, pos_y, text) --I delegate the responsibility to this function to decide.
    if draw_text_bool then
        gui.text(pos_x, pos_y, text)
    end
end

function my_draw_rectangle(pos_x, pos_y, width, height, line_color)
    if draw_rectangle_bool then
        gui.drawRectangle(pos_x, pos_y, width, height, line_color)
    end
end

function my_draw_string(pos_x, pos_y, text, forecolor, backcolor, font_size, font_family, font_style) --I don't need all but since this is a wrapper...
    if draw_string_bool then
        gui.drawString(pos_x, pos_y, text, forecolor, backcolor, font_size, font_family, font_style)
    end
end

function my_key_press(key) --Used for timing_mode. I want to know if the key is being pressed but not held. It's specifically designed for the 'N' key which I want to use to change the current mode, but I don't want it changing forever, so if it detects there's a hold, it will only consider the first press to change the current mode.
    if new_keyboard_presses[key] and not is_key_hold(key) then
        return true
    end
    return false
end

function is_key_hold(key)
    if old_keyboard_presses[key] and new_keyboard_presses[key] then
        return true
    end
    return false
end

function change_mode()
    if current_mode == #modes then
        current_mode = 1
    else
        current_mode = current_mode + 1
    end
end

--All of those are CONSTANT and are used to draw the rectangles and chances texts at desirable positions. The index marks the level, so that's why there are exactly 7 entries (levels 0-6). level_selected_x and level_selected_y are the (x, y) coordinates for the rectangles, the offsets are used to draw the strings in such a way that they are readable (well they were made based on my own preferences but you can change them to your liking)
offset_level_selected_x = {
    -25,
    -35,
    0,
    -70,
    -30,
    -41,
    30,
}

offset_level_selected_y = {
    -15,
    -25,
    40,
    40,
    -24,
    -39,
    -10,
}

level_selected_x = {
    176,
    192,
    148,
    75,
    31,
    41,
    112,
}

level_selected_y = {
    39,
    110,
    166,
    166,
    110,
    39,
    9,
}

--Normally, if you see the word 'modes', it means it's designed for timing_mode. If you set it to false, it shouldn't do anything.
modes = {
    {3264 + 192, 3648 + 192}, --Lower and upper bounds for Pig
    {3648 + 192, 4416 + 192}, --Lower and upper bounds for Bike.
    {4416 + 192, 4992 + 192}  --Lower and upper bounds for Dinosaur.
}

modes_strings = {
    "Pig",
    "Bike",
    "Dinosaur"
}

--initial/default values.
r = 60 --radious of the circunference in which Bart is rotating. How did I find it out? Paper, trial and error and a lot of math basically. I arbitrarily (but conveniently) chose a pixel of reference to know the (x, y) coordinates of Bart's current position. I chose the left antenna in Bart's helmet because it's often the case that antenna tells you which level is selected (that's what conveniently meant). The game doesn't give them to you but it does give you the angular position. And, if you draw little rectangles that represent such (x, y) coordinates in BizHawk's own coordinate system, and you find 3 points, well, you can do some math to find the equation of the circle and then the radius. Of course, it's approximated.
angular_velocity = 192 --angular, initial velocity. Yes I love to comment my code.
start_pressed = false --Used because originally my_key_press did not exist. Also designed specifically for timing_mode.
current_mode = 1 --By default, it expects you to press frames at which Pig is certain to be selected (using my strats for the No Damage Run, at this point you would have Picture, Water and Bike completed, that's what makes it work)
new_keyboard_presses = input.get()
successful_attempts = 0 --Used to help me know my consistency at pressing at the desired frames.
text_pos_x = 300 --Coordinates for the minimum and maximum selectable levels texts.
text_pos_y = 300
msg = "" --Used for setting text messages.
event.onloadstate(restart_start_pressed) --In practice, sometimes I would have to load a savestate again due to getting a level selected, but I still wanted to keep practicing in Timing Mode. So this helped that.
while true do --main loop
    inputs_pressed = joypad.getwithmovie(1)
    old_keyboard_presses = new_keyboard_presses
    new_keyboard_presses = input.get() --This one doesn't matter if it's in a movie or not, actually I think it's better if it's ignored during a movie, then again, can it remember keyboard presses that happened during a movie? I don't think that info is stored anywhere. Makes sense that in the documentation, there's no input.getwithmovie.
    angular_position_in_game = mainmemory.read_u16_be(0xC1EA)
    if (timing_mode and not start_pressed and inputs_pressed['Start']) then --specific timing mode code.
        lower_bound = modes[current_mode][1]
        upper_bound = modes[current_mode][2]
        if (angular_position_in_game >= lower_bound and angular_position_in_game <= upper_bound) then
            successful_attempts = successful_attempts + 1
            text_pos_x = 300
            text_pos_y = 300
            msg = "Well done! Ang. pos: " .. angular_position_in_game .. " SUC: " .. successful_attempts .. " MODE: " .. modes_strings[current_mode]
        else
            text_pos_x = 300
            text_pos_y = 300
            msg = "Try again! Angular_position_in_game: " .. angular_position_in_game .. " SUC: " .. successful_attempts .. " MODE: " .. modes_strings[current_mode]
            successful_attempts = 0
        end
        start_pressed = true
    end
    if timing_mode then
        string_msg = "Mode: " .. modes_strings[current_mode]
        my_draw_string(100, 100, string_msg, nil, nil, 11, nil, "Bold")
    end
    my_draw_text(text_pos_x, text_pos_y, msg)
    if timing_mode and my_key_press('N') then --more specific timing mode code.
        change_mode()
    end
    if (timing_mode and start_pressed and not inputs_pressed['Start']) then --This means the button was released.
        start_pressed = false
    end
    --Calculations code.
    angular_position_in_degrees = angular_position_in_game*(0.0390625) --1 unit of rotation in-game = 0.0390625 degrees. How I calculated it, well, a complete loop/cycle are 9216 units (you can find out monitoring C1EA and keeping in mind constant angular velocity without pressing any buttons is 192), so 9216 units = 360 degrees. Then, by rule of three, 1 unit of rotation in game = 0.0390625 degrees.
    angular_position_in_rad = math.rad(angular_position_in_degrees - 116) --Ok 116 is kind of arbitrary but not so much. Remember BizHawk's coordinate system (well, and pretty much every computational coordinate system I know) uses a different convention than the coordinate systems they usually teach us at school: downwards means greater Y values instead of smaller. So first, there's a rotation of 90 degrees to be done to convert, but then you also need to add some degrees more because where the left antenna is in angular position = 0 is not exactly perpendicular to the y axis, it's actually a little bit moved to the left, more or less 26 degrees. So there you have it.
    pos_x = r*(math.cos(angular_position_in_rad))
    pos_y = r*(math.sin(angular_position_in_rad))
    my_draw_rectangle(pos_x + 125, pos_y + 109, 4, 4, "#FF0000") -- Red, this represents the leading point, the current left antenna position.
    clear_level_selected_chances() --Start fresh basically.
    for i=0,31 do --C1E8 will & the least 5 significant bits of the VDP's H/V counter, so it can be any value from 0 to 31, both inclusive.
        potential_angular_position_in_game = (angular_position_in_game + 480 + 192*i) % 9216 --i is all C1E8 possible values.
        level_selected = get_level_selected(potential_angular_position_in_game)
        level_selected_chances[level_selected+1] = level_selected_chances[level_selected+1] + 1 --+1 because as said in other comments Lua tables start from 1 and not 0. Also, as far as I know/investigated, there's no way to ++ or +=, so that's why I am doing it this way.
        if i == 0 then --C1E8 cannot be any lower, so we have a minimum selectable level here.
            minimum_selectable_level_text = "Minimum selectable level is " .. level_selected
            my_draw_text(100, 100, minimum_selectable_level_text)
        end
        if i == 31 then --C1E8 cannot be any higher, so we have a maximum selectable level here.
            minimum_selectable_level_text = "Maximum selectable level is " .. level_selected
            my_draw_text(100, 200, minimum_selectable_level_text)
        end
        potential_angular_position_in_degrees = potential_angular_position_in_game*(0.0390625) --Conversions and calculations, same as above. Could have encapsulated it in a function but I think it's clearer this way and with the comments, my take on it.
        potential_angular_in_rad = math.rad(potential_angular_position_in_degrees - 116)
        pos_x = r*(math.cos(potential_angular_in_rad))
        pos_y = r*(math.sin(potential_angular_in_rad))
        my_draw_rectangle(pos_x + 125, pos_y + 109, 4, 4, "#80ff80") -- Green, this represents the potential final angular position.
    end
    draw_level_selected_chances() --We finished with the calculations, now proceed to draw the rectangles and the chances texts.
    emu.frameadvance() --Required since control when running scripts is on Lua and basically if Lua doesn't make the emulator move then nothing will and the program will just crash due to there not being any response whatsoever.
end