Skip to content

Bluetooth / Radio communication between bots! #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from

Conversation

Woonters
Copy link
Contributor

This should close #52

There's still some stuff I think might be worth doing:

[ ] - Review current message implementation, is the message size, style etc. worth it?
[ ] - Calculate the cooldown values and adjust them a for balancing
[ ] - Should the write buffer have better write functionality? I would consider using an offset from MEM_BLUETOOTH and writing each 4 bytes instead of just sending 1 byte at a time, this would probably mean you should be able to read from it as well, effectively treat it like a memory location
[ ] - Check over all the names for interfacing functions in kartoffel::bluetooth
[ ] - Add wider / better tests
[ ] - Tutorial level

Other notes

I added two test bots Ianthe and Coronabeth, happy to delete them, but thought if I was going to do a tutorial level they might be useful currently they just work as a tx rx pair doing nothing else

I'd certainly be interested in other messaging concepts as well, creating a send / receive stream between two bots for instance could be a fun task

I think when writing this there was a small part of my brain always thinking about electronic warfare, it'd be cool if you could do some sort of that in a way, but also probably really messy, I made the bots send their own ID as part of the message, but if you wanted to encourage people to write their own clever networking code then maybe the bot id section could be dropped (and i'd probably increase the Message length to accommodate

@Woonters Woonters marked this pull request as draft March 23, 2025 22:11
@Patryk27
Copy link
Owner

Thanks for the pull request - I like the direction and, having thought about it for a couple of days, I think we could take it a couple of steps further!

Note that everything below is a proposal, a bag of thoughts - feel free to comment, come up with counter-ideas, and so on.


Overall, I tend for each peripheral to be somewhat different from each other not only in the tangible functionality, but also the intrinsic mechanic -- serial drops older bytes, compass auto-refreshes in the background etc.

For Bluetooth (aka radio, and we'll get to the naming soon) the mechanic we could apply to make it intrinsically different from radar is to make it possible to send messages without specifying the radius. Rather, the message travels outwards from the bot and with every tick it travels, it wanes, increasing the chance for observing bit flip(s) within it.

(in a similar spirit -- when two messages collide mid-air, both can get mangled, which touches on the electronic warfare subject you mentioned.)

Practically speaking, instead of having send_bluetooth(radius), we'd have radio_send(msg) that emits given message outwards, propagating it with some constant speed, say 1 tile every 256 world-ticks:

  .

->

  .
 . .
  .

->

  .
 . .
.   .
 . .
  .

When a message "hits" a bot, that's when it's pushed onto the radio's "message stack" (if there's space for it), with some of the bits mangled (maybe 1 bit for each 1 traversed tile? though it would certainly make much more sense to apply inverse square law here so that messages received in close proximity have almost 100% chance of being correct).

Naming

I'm feeling radio more than Bluetooth - that's because Bluetooth kinda implies a high-level thing going on (with device discovery etc. provided by the protocol), while what we'd like to offer is more of a "send a bag of bytes and receive a bag of bytes" functionality.

In particular, I'd like to incentivize players to implement error recovery algorithms (Hamming code and whatnot) and higher-level protocols on top of the basic tools provided for the game.

Interface

As with everything in here, the interface here is also a proposal - a sketched version of my thoughts is:

// N.B. we could assume that radio is always turned on, but at some point I'd
//      like to revisit the concept of implementing a battery - turning the 
//      radio on/off could be a battery-saving strategy then
pub fn radio_on() {
    // N.B. we're reserving 0x01 as a general "set radio power" command - in the
    //      future we could have a "set_radio_power()" function that would cause
    //      the radio to eat more battery in exchange for allowing it to send
    //      messages for longer distances (and vice versa - reduce the chance of
    //      observing a bit flip in a retrieved message)
    //
    //      this would be then implemented as cmd(0x01, power, 0x00, 0x00)
    wri(MEM_RADIO, 0, cmd(0x01, 0x01, 0x00, 0x00));
}

pub fn radio_off() {
    wri(MEM_RADIO, 0, cmd(0x01, 0x00, 0x00, 0x00));
}

pub fn radio_status() -> u32 {
    rdi(MEM_RADIO, 0)
}

pub fn is_radio_on() -> bool {
    radio_status() & 1 > 0
}

// aka "can we send the next packet"
pub fn can_radio_send() -> bool {
    radio_status() & 2 > 0
}

// aka "is there any packet waiting for us in the queue"
pub fn can_radio_recv() -> bool {
    radio_status() & 4 > 0
}

pub fn radio_send(msg: &[u8; 32]) {
    for (idx, payload) in msg.array_windows().enumerate() {
        wri(MEM_RADIO, idx + 1, u32::from_le_bytes(payload));
    }

    wri(MEM_RADIO, 0, cmd(0x02, 0x00, 0x00, 0x00));
}

// if there's no packet, just fills msg with zeros
pub fn radio_recv(msg: &mut [u8; 32]) {
    for (idx, payload) in msg.array_windows_mut().enumerate() {
        *msg = rdi(MEM_RADIO, idx + 1).to_le_bytes();
    }
}

// Shiny thing! Allows to specify a predicate for radio_recv().
//
// E.g. calling `radio_filter(&[0xca, 0xfe, 0xba, 0xbe])` will cause the radio
// to transparently ignore all incoming messages which have a different prefix;
// doesn't affect `radio_send()`.
//
// This pairs up with `radio_send()` *not* allowing to send a message to a
// specific robot id -- rather, players are encouraged to come up with a
// "namespacing" scheme of their own.
//
// For instance - if someone wants to have a direct bot-to-bot connection, they
// can prefix their messages with the bot id (or rather a hash of it / half of
// it, since we purposefully keep the prefix shorter than a full bot id).
//
// Alternatively - if someone wants to form a bot-cluster and communicate within
// it instead of directly to a specific bot, they can just randomize a 32-bit
// number and use it to prefix the messages.
//
// Even more combinations exist, e.g. the prefix could encode a message type so
// that you can have different bots deployed on the field that respond respond
// only to their own specific message type (which is sort of a variation on the
// clustering mechanism above, now that I think about it).
pub fn radio_filter(prefix: &[u8; 4]) {
    wri(MEM_RADIO, 0, cmd(0x03, 0x00, prefix[0], prefix[1]));
    wri(MEM_RADIO, 0, cmd(0x03, 0x01, prefix[2], prefix[3]));
}

Tutorial

I think the tutorial is plenty long as it is - instead of introducing more chapters (which could be frustrating for people who want to just grasp the basics), we could introduce a new challenge.

I was thinking a distributed key-value database? 👀

You'd have to implement a couple of bots that together form a cluster that's able to respond to basic queries such as "put foo=bar" or "get foo" (those would also get transmitted to your robot over the radio from a built-in challenge-robot).

You'd be forced to implement more than one robot simply because the challenge-robot would generate over 128 kB of random queries - so you would have to keep multiple robots working together, so that each robot contains its own "set" (shard?) of the database, otherwise you wouldn't be able to respond back to all of the queries asked by the challenge-bot.

In the extreme version we could force the robots to fight with some enemies - this way you'd be forced to implement some kind of storage redundancy; but that's maybe for another day.

Open questions

radio_filter() vs bitrot

If messages are applicable to this "distance bitrot", then it would make sense for each bit of the message to be equally likely to get flipped - and this can make it frustrating to use radio_filter(), because when configured for filter 0xcafebabe, it would reject a message that begins with 0xcaffbabe, even though that message could well be meant for the robot.

(i.e. the specific problem is that radio_filter() isn't error-recovery-aware - and it cannot be, since it'd be implemented within the game itself)

A non-realistic (I think?) but good-enough solution could be to penalize further bits more than the initial bits, something like:

fn mangle_message(msg: &mut [Bit; 256], prob: f32) {
    for (idx, bit) in msg.iter_mut().enumerate() {
        let bit_prob = (*idx as f32 / 128.0).clamp(0.0, 1.0) * prob;
        
        if rng.gen(bit_prob) {
            *bit = bit.flip();
        }
    }
}

Intuitively, when calling mangle_message(..., 0.1) we'd expect for 10% of the bits to be mangled, just skewed towards the end of the message instead of uniformly throughout it; this wouldn't make radio_filter() bulletproof, but it'd make it more fun and practical to use, I think.

Message size vs cooldown vs challenge

If we're able to send just 32b of payload, then the whole "challenge requires you to handle hundredths of kilobytes of queries" part might take solid minutes to complete, mostly waiting for the queries to be sent/received (= no fun).

On the other hand, forcing messages to be e.g. 128b can also be wasteful (those bytes need to be copied into MMIO, after all).

Maybe a good compromise would be to allow to send messages of arbitrary length? (but more than 4b, to match the prefix, and less than 128b, not to overflow the receiving part)

This would be sort of "pay as you go" mechanism: you want to send a short message? cool; you want to send a longer message? cool, but you have to wait a bit longer.

Next steps

Alright, this turned out to be quite a message! Feel free to treat is as an RFC - nothing here is set in stone, it's just a couple of ideas.

In terms of the implementation, nothing here shouts terribly difficult to me - if you'd want to give it a stab, I'd love to support and review! If you don't have much time or ain't feeling like implementing more at the moment, that's also fine, just lemme know.

(but as I said, I think it's a nice, moderately challenging project that should be also quite rewarding; I've got a couple of blog posts to write myself, so I can't complain for lack of work at the moment either 😄)

Implementation hints

Assuming that the basic idea here stands, we'd have to store messages into the world itself:

fn create_world(res: Resources) -> World {

Something akin to:

struct Messages {
    message: Vec<Message>,
}

struct Message {
    emitted_at: IVec2,
    radius: i32,
}

Then we'd have to create a new system be responsible for propagating those messages, checking when they collide with bots, and putting them into bots' recv queues (performing the radio_filter() check, bitrot mechanism etc.):

fn main_schedule() -> Schedule {

Something like:

fn receive_messages(messages: ResMut<Messages>, bots: ResMut<AliveBots>) {
    let mut msgs_to_delete = Vec::new();
    
    for (msg_idx, msg) in messages.iter_mut().enumerate() {
        for bot in bots {
            // Having `O(n * m)` isn't ideal, especially since this comparison
            // will necessarily return _false_ most of the time, but it's a good
            // starting point
            if bot.pos.distance(msg.emitted_at) == message.radius {
                bot.msgs.receive(msg);
            }
        } 
        
        msg.radius += 1; // possibly every 128 world ticks or so, depending on what's more fun in practice

        if msg.radius >= 32 { // possibly even further apart, depending on how it turns out in practice
            msgs_to_delete.push(msg_idx);
        }
    }
    
    /* ... */
}

Eventually messages should also be persisted into the savefile:

... but for development you can start off with Messages::default().

@Woonters
Copy link
Contributor Author

Hi really like all of these ideas, and I think you've hit the nail on the head with practically all of them
I'm happy to go about implementing all of this, only really with one or two questions.

1.

If we are doing the different message sizes from 4 to 128 Bytes then should the recieve buffer still be 5 messages long?
I think the filter mainly deals with the issue of message spam, but still it feels a shame that a radio module would have it's buffer filled all the same from 20B and 512B

2.

With the comment on message mangling:

"(in a similar spirit -- when two messages collide mid-air, both can get mangled, which touches on the electronic warfare subject you mentioned.)"

Would it be worth considering the strength of the sent message as to how much it mangles / gets mangled

@Patryk27
Copy link
Owner

Patryk27 commented Mar 26, 2025

should the recieve buffer still be 5 messages long?

Yeah, that'd be a pity - I think a size-limited buffer makes more sense (e.g. 512b?).

Now that I think about it, dynamically-sized messages require changing the API as well, maybe something like:

pub fn radio_send(msg: &[u8]) {
    /* ... */
}

// high-level function
pub fn radio_recv() -> Vec<u8> {
    // would be much better to use MaybeUninit, just sketching a general concept
    let mut msg = vec![0; radio_recv_len()];
    radio_recv_msg(&mut msg):
    msg
}

// low-level function: returns the length of the next pending message (or 0, if there's none)
pub fn radio_recv_len() -> usize {
    /* ... */
}

// low-level function: fills msg with the pending message (requires extra care if msg.len() % 4 != 0, same as radio_send())
pub fn radio_recv_msg(msg: &mut [u8]) {
    /* ... */
}

Would it be worth considering the strength of the sent message as to how much it mangles / gets mangled

I think so!

Currently we can only define strength in terms of message distance (further apart from emitted_at = weaker), but in the future we could incorporate that set_radio_power() concept that'd affect the mangling as well.

@Woonters
Copy link
Contributor Author

Woonters commented Apr 7, 2025

Hello!
Thought I'd check in with what's been happening so far.
It's come on leaps and bounds, but there are some implementation choices that I'm spending a bit of time thinking about

The message buffer

Originally I tried to make the reading of the message buffer quite transparent. Basically the first 132 Bytes are for the data about the actual radio module, and the message it's getting ready to send.

radio data message we are going to send
0 4..132

Then the next chunk of addresses were for the message buffer.
I tried using a circular buffer, similar to my old implementation, but now with variable message lengths, we need to store pointers to each of the messages in the buffer and how long each one is.

Originally I was aiming for 512B of messages, meaning you could hold 4 max size messages
and 128 minimum size messages!

message address length unused
ByteByteByteByte

with 512 addressable bytes in the buffer we would need to store a message pointer as a u16, and length can be a u8.
This means that realistically each message pointer is 4 bytes itself, meaning the pointer list can be 512B as well! (as seen above)
Here we have a problem, with just the message buffer itself we would need 1028B of addressable space (more than any other module uses) and that doesn't include the rest of the radio data.

message buffer info message pointers message buffer
0 4..516 516..1028

or in total

radio data message we are going to send message buffer info message pointers message buffer
0 4..132 132 136..664 664..1176

An alternative is just presenting the top message on the buffer, this means that we only need to present 132B of data for the message buffer, but this reduces how much the user can interact with the message buffer.
If the user wants to do some logic thinking about multiple messages then they would need to copy messages into their own structures in memory which feels a little less efficient and clean.

The status of the radio module could then also send the length of the current message at the top of the buffer

Or we can limit the number of messages pointers we can store, this would mean that there could possibly be unused space in the message buffer if lots of 4B messages are sent

I've included solutions for both ways so it should be easy to play around and choose one, (though currently the read functionality for the presenting only the top message) has some really horrible slice manipulation / work that I want to go back to and clean asap

The message emitting

I've also been actually writing the code for the messages in world space, and already come into some fun weird interactions!

The mangling idea is interesting, but if we model a message as

#[derive(Clone, Debug)]
pub struct Message {
content: Vec<u8>,
source: IVec2,
strength: i32,
curr_radius: i32,
locations: HashSet<IVec2>,
expand_cooldown: usize,
hit_bots: HashSet<BotId>,
}

then two messages a and b that have an intersecting point c will mangle

  a b
 a c b
  a b

then even bots touching a on the left edge will receive a message mangled by b
this wouldn't make much sense... So the content of a message needs to be linked to each position
Also when the message expands next does the mangled version expand out or is it returned to the orignial state i.e. (ignoring now the b message

  a
 a c
a   c
 a c
  a

or just

  a
 a a
a   a
 a a
  a

This follows nicely onto the expanding message shape / code
I've tried implementing some way of working out the next positions in a message as it expands and come up with this little algorithm

fn expand(&mut self) {
let mut new_set: HashSet<IVec2> = HashSet::new();
let new_radius = self.curr_radius + 1;
let off = self.source - IVec2::new(new_radius, new_radius);
for column in 0..=new_radius {
new_set.insert(off + IVec2::new(column, new_radius + column));
new_set.insert(off + IVec2::new(column, new_radius - column));
new_set.insert(
off + IVec2::new(
(2 * new_radius) - column,
new_radius + column,
),
);
new_set.insert(
off + IVec2::new(
(2 * new_radius) - column,
new_radius - column,
),
);
}
self.locations = new_set;
self.curr_radius = new_radius;
}

Frankly I'm not happy with it, it feels messy and if the mangling being location dependent would also break the functionality I think
I'm going to have a bit of a look around, see if I can work out a nicer implementation

Finally in this part, (mostly as a note for myself) the check for if a message should be sent to a bot only happens when it expands, this could in theory mean a bot could miss a message by driving onto a cell whilst a message is there.

Conclusions / TLDR:

  • I'd appreciate thoughts on how the message buffer should be presented to the bot, should it just be effectively a raw representation (if so what do we limit to bring the size down) or should it just present the front value of the buffer?
  • How would we like to deal with message mangling? Do messages that hit each other continue to be mangled as they expand out, or is it just where they intersect?
  • Any big problems with my implementations so far? I know my code is pretty messy, I'm looking to clean up some signatures, get rid of some nasty lines that read poorly and probably reduce the comments down to something more reasonable

@Patryk27
Copy link
Owner

Patryk27 commented Apr 7, 2025

Hi, amazing! I'll take a look at it tomorrow 🦀

@Patryk27
Copy link
Owner

Patryk27 commented Apr 8, 2025

I'd appreciate thoughts on how the message buffer should be presented to the bot, should it just be effectively a raw representation [...] or should it just present the front value of the buffer?

How about we level the playing field?

Instead of having explicit message_ptrs: Vec<MessagePointer> etc., we could go with implied structure:

struct BotRadio {
    // Radio's memory.
    //
    // memory[0] is ignored - it would contain the status byte, but it's better
    // to materialize it on the fly (see BotRadio::mmio_read())
    //
    // memory[1] contains the length of the first message, memory[2] contains
    // the first four bytes of that message's payload etc.; if memory[1] == 0,
    // there's no message waiting to be received
    //
    // memory[2 + memory[1]] contains the length of the second message (possibly
    // zero if there's no second message waiting to be read) and similarly the
    // bytes that follow contain that message's payload etc.
    //
    // Messages essentially form a linked list, but instead of having `next`
    // as a pointer (which would take an extra `u16`), all messages are located
    // one after another:
    //
    // mem[0] | mem[1]          | mem[2..]            | mem[..]          | ...
    // -------|-----------------|---------------------|------------------|-----
    // xxxxxx | first msg's len | first msg's payload | second msg's len | ...
    memory: Vec<u32>,
}

impl Default for BotRadio {
    fn default() -> Self {
        Self {
            memory: vec![0; 1024 / 4],
        }
    }
}

impl BotRadio {
    pub fn mmio_read(&self, addr: usize) -> u32 {
        // if addr = 0, return the status byte, otherwise fall back to self.memory
    }

    pub fn mmio_write(&mut self, addr: usize, val: u32) {
        // we need support for a `radio_drop(nth: u8)` syscall
        //
        // invoking this drops nth message from `self.memory` and shuffles the
        // following messages to previous places
        //
        // e.g. `radio_drop(0)` removes the first message and then copies the
        // second message into first message's place etc. (can be done quite
        // efficiently with `&[T]::copy_within()`, just remember to zero-out 
        // length of the last message)
    }

    pub fn recv_message(&mut self, msg: &[u32]) {
        // TODO early-reject message if it doesn't match the filter

        let mut ptr = 1;
        
        loop {
            let Some(len) = self.memory.get(ptr) else {
                // No more space in the buffer, welp.
                return;
            };
            
            if len == 0 {
                self.memory[ptr] = len;
                self.memory[ptr + 1..=ptr + len].copy_from_slice(msg);

                // ^ careful, this can panic! 
                // |
                // | if `ptr + len >= self.memory.len()`, there is *some* space
                // | left, but not sufficient to store the entire message
                // |
                // | in this case we could either drop the message or just save
                // | it partially (store as many bytes as there's space left and
                // | call it a day)
                // |
                // | i think the latter is more fun, it makes also the best use
                // | of the buffer
                
                // Message received, yay!
                return;
            } else {
                // There's already a message here, skip over it
                ptr += len + 1;
            }
        }
        
        // Note that since this algorithms walks the entire message list for
        // each received message, it's quite inefficient - can be made O(1) by
        // saving the next free ptr (as in `self.next_free_ptr: usize`) instead
        // of recalculating it all the time
    }
}

I think this makes the best usage of the available space and also allows for interesting use cases (firmware has access to all messages, it can keep messages in the queue as a memory-saving measure etc., it's essentially a free 1 KB RAM block, just with limited writing capabilities).

How would we like to deal with message mangling?

I see the issues - I think it'd be best to postpone this for the time being and go back once the main mechanic is done; maybe a solution will materialize then 😄

Woonters added 3 commits April 8, 2025 17:10
Add all the tests as well as intergration with large portions of kartoffel/radio and bot/radio.rs
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.

Radio / Bluetooth
2 participants