so i think many people here probably know of the 1/256 chance "airshot" miss chance, as well as the 120/256 miss chance against uphill/under cover (non-stacking), but if you're not familiar, please check out the liquipedia page. TL user StaticNine also tested those numbers ingame in the past on their blog here.
DISCLAIMER: I'M NOT A STATISTICIAN OR ANYTHING RESEMBLING ONE AND PROBABILITY IS HARD
i haven't really had a chance to play much in the past few months because i've been moving continents and my ergonomic situation has been awful (bad furniture). and i don't want to aggravate my RSI. so i guess i am doing this type of shit instead of getting better at the game. but i got a bit curious watching a stream since i saw one of those 1/256 airshots happen. i feel like it's so rare to see them during gameplay, rarer than 1/256. i wanted to investigate and see if i could find any evidence to back this up.
i did this research based on the OpenBW github, so thanks to them.
so the RNG in BW is a linear congruential generator
specifically, it's the same as the LCG RNG from Borland C++.
Here's the relevant bits of code:
+ Show Spoiler +
https://github.com/OpenBW/openbw/blob/master/bwgame.h#L13187
st.lcg_rand_state = st.lcg_rand_state * 22695477 + 1;
return (st.lcg_rand_state >> 16) & 0x7fff;
https://github.com/OpenBW/openbw/blob/master/bwgame.h#L14370
fp8 unit_dodge_chance(const unit_t* u) const {
if (u_flying(u)) return 0_fp8;
if (unit_is_under_dark_swarm(u)) return 255_fp8;
if (st.tiles[tile_index(u->sprite->position)].flags & tile_t::flag_provides_cover) return 119_fp8;
return 0_fp8;
}
fp8 unit_target_miss_chance(const unit_t* u, const unit_t* target) const {
fp8 r = unit_dodge_chance(target);
if (!u_flying(u) && !u_flying(target)) {
if (get_ground_height_at(target->sprite->position) > get_ground_height_at(u->sprite->position)) {
if (r < 119_fp8) r = 119_fp8;
}
}
return r;
}
https://github.com/OpenBW/openbw/blob/master/bwgame.h#L14438
fp8::from_raw(lcg_rand(1) & 0xff) <= unit_target_miss_chance(bullet_owner_unit, target_unit)
the key thing in that code is the <=, and 0 for just normal, and the 119 for shooting up a cliff. the way the RNG is used there, gives a value from [0 to 255]. because of the <= have the equal in there, when the RNG rolls 0, you get a miss no matter what.
i did a bunch of testing by writing some rust code to replicate the rng, with different starting seeds.
the code is in the spoiler below if you want it, or if you just want to fuck around with it online, here's a rust playground link
+ Show Spoiler +
use std::num::Wrapping;
fn main() {
/////////////////////////////////////////////////////////////////
// constants to tweak here
let reroll_count = 65536; // how much should we reroll off a starting seed
// 0 normally, 119 for shooting uphill/under trees
let target = 0;
//let target = 119;
// -trial_range to trial_range as seed
let trial_range = 10000;
// how many misses in a row is weird
let outlier_chain_length = 10;
// the LCG seems to behave very mildly worse centered around initial seed 0
// so this gets added to +-trial_range
let trial_offset = 0;
// no more constants
/////////////////////////////////////////////////////////////////
// finding outliers
let expected_to_find = (reroll_count * (target+1)) / 256;
let found_threshold = expected_to_find / 4;
let found_threshold_high = expected_to_find + found_threshold;
let found_threshold_low = expected_to_find - found_threshold;
//
let mut found_count = 0;
let mut trial_count = 0;
let mut sequential_found_count = 0;
let mut sequential_not_found_count = 0;
let mut unusual_chain_count = 0;
let mut longest_chain = 0;
let mut loop_body = |start_state| {
let prev_found = found_count;
let mut lcg_rand_state = Wrapping(start_state);
let mut previous_roll_was_found = false;
let mut chain_length = 0;
for _ in 0..reroll_count
{
let (new_state, result) = lcg_rand(lcg_rand_state);
lcg_rand_state = new_state;
if (result & Wrapping(0xFF)) <= Wrapping(target)
{
found_count += 1;
if previous_roll_was_found {
sequential_found_count += 1;
}
previous_roll_was_found = true;
chain_length += 1;
} else {
if !previous_roll_was_found {
sequential_not_found_count += 1;
}
previous_roll_was_found = false;
if chain_length > outlier_chain_length {
unusual_chain_count += 1;
}
if chain_length > longest_chain {
longest_chain = chain_length;
}
chain_length = 0;
}
}
// finding outliers
let delta_found = found_count - prev_found;
if delta_found > found_threshold_high || delta_found < found_threshold_low {
println!("unusual number {} found for start seed {} outside range {} to {} centered around {}", delta_found, start_state, found_threshold_low, found_threshold_high, expected_to_find);
}
trial_count += 1;
};
// uncomment this and comment out the loop to test a specific seed
//loop_body(7);
for start_state
in -trial_range+trial_offset..trial_range+trial_offset
{
loop_body(start_state);
}
let total_count = trial_count * reroll_count;
println!(
"{} instances found in {} trials of {} rolls ({} total rolls)",
found_count, trial_count, reroll_count, total_count);
println!("{} were sequential misses", sequential_found_count);
println!();
//let reroll_count = reroll_count as f64;
let found_count = found_count as f64;
let total_count = total_count as f64;
let miss_rate = (found_count / total_count) * 100.0;
let miss_in = total_count / found_count;
let expected_rate = ((target as f64 + 1.0)/256.0) * 100.0;
println!("\n\t{}% miss rate", miss_rate);
println!("or roughly 1 in {}", miss_in as usize);
println!("in contrast to\n\t{}% expected", expected_rate);
println!("off by\n\t{}%", miss_rate - expected_rate);
println!();
let sequential_found_count = sequential_found_count as f64;
let sequential_miss_rate = sequential_found_count/found_count * 100.0;
let sequential_miss_in = found_count / sequential_found_count;
println!("{}% sequential miss rate",sequential_miss_rate);
println!("or roughly 1 in {}", sequential_miss_in as usize);
println!("in contrast to\n\t{}% expected", expected_rate);
println!("off by\n\t{}%", sequential_miss_rate - expected_rate);
println!();
let sequential_not_found_count = sequential_not_found_count as f64;
let sequential_hit_rate = sequential_not_found_count/(total_count-found_count) * 100.0;
println!("{}% sequential hit rate",sequential_hit_rate);
println!("in contrast to\n\t{}% expected", 100.0-expected_rate);
println!("off by\n\t{}%", sequential_hit_rate - (100.0-expected_rate));
println!();
println!("found {} chains of {} or more misses in a row", unusual_chain_count, outlier_chain_length);
println!("longest chain was {} misses in a row", longest_chain);
}
fn lcg_rand(lcg_rand_state : Wrapping<i32>) -> (Wrapping<i32>, Wrapping<i32>) {
let lcg_rand_state = lcg_rand_state * Wrapping(22695477) + Wrapping(1);
(lcg_rand_state, (lcg_rand_state >> 16) & Wrapping(0x7fff))
}
so for just raw percentages, everything seems fine. the biggest unexpected results was the uphill miss results for starting seed 2 being off by 0.37~% in 65535 rolls, which, is really not much at all.
next i examined "probability of a miss if the last roll was a miss" and "probability of a hit if the last roll was a hit", and i had roughly similar results. this assumes that there aren't other RNG rolls in the meantime, which is a pretty significant assumption. again some random seeds are a tiny bit "off" here, for example starting seed 4 with 65535 rolls gives is "off" by 0.72~% repeated successful hits against uphill. pretty mild stuff.
so yeah. idk. seems like it works as described. 1/256 chance and everything. if there's something weird, it might be something more periodic like "once every 4th roll". idk. or something i missed. i just really feel instinctively that 1/256 should happen more often than it actually does, it's real weird. like it's 0.390625%, right? and i compare it to rolling two d20s in a row and getting a 20 on both and that's 0.25%, which, if you've played any of those d20-using tabletop rpgs, i swear is more common than the starcraft "airshot". so what's up with that. maybe it's just that they're more memorable in other contexts and the airshots are usually not super noticeable.