--- /dev/null
+{"format":"particle","title":"Pipboy Prop Custom Apps","content":[{"type":"button","label":"Home","action":"https://athene.gay/projects/../index.json"},{"type":"paragraph","text":"Pipboy Prop Custom Apps"},{"type":"paragraph","text":"2025"},{"type":"paragraph","text":"There\u0027s an official Fallout TV show prop. It\u0027s hackable."},{"type":"paragraph","text":"OK, we\u0027re doing something a little different with this one. But first the preamble."},{"type":"paragraph","text":"The Preamble"},{"type":"paragraph","text":"19/03/2025"},{"type":"paragraph","text":"So I haven\u0027t cosplayed properly in a while. I did it prettyu regularly in 2023, but 2024 was a nadir for me menty h and cosplay, with its ill-fitting, itchy fabrics that are not designed with trans people in mind, was not gonna help that. I didn\u0027t go to many cons, those I did I avoided being in cosplay, yadda yadda."},{"type":"paragraph","text":"Thing is, is I do actually really like cosplay, when I\u0027m of the frame of mind to do it. I like prop work. I like wearing the clothes Cat has designed for me. It\u0027s fun when it\u0027s not autistic agony."},{"type":"paragraph","text":"You may have noticed I\u0027ve got mad into Fallout of late. See the previous entry on this blog. I\u0027ve loved Fallout for a long time but never really considered cosplay for it, not really sure why."},{"type":"paragraph","text":"But this dive back into the series coincided well with me wanting to get back into cosplay proper. And because I want to do it properly, I\u0027ve gone a bit all out."},{"type":"paragraph","text":"I\u0027ve ordered a reaaaalllly nice vault 33 suit, with backpack. I\u0027m gonna dye my hair instead of wearing a wig, which is the worst thing sensory-wise for me in cosplay."},{"type":"paragraph","text":"And, I made a Pip-Boy! Look at it here, it\u0027s pretty great!"},{"type":"paragraph","text":"It\u0027s also absolutely fucking massive."},{"type":"paragraph","text":"Wearing it is deeply uncomfortable and my arms get tired after mere minutes, let alone the hours of continuous wear a con would require. It\u0027s impractical."},{"type":"paragraph","text":"There is, however, a solution. Bethesda and \u0027The Wand Company\u0027 produce a screen-accurate version of the tv series hero prop, which normally wouldn\u0027t particularly interest me, as often screen-accurate props are just display pieces and are non-functional."},{"type":"paragraph","text":"Props I use for cosplay need to have flare. I use LEDs to make them nicer, or in the case of my own homebrew pipboy, I was using a phone with an android app that mimics the Fallout 3 pip-boy interface, fully interactable. That\u0027s cool! And it\u0027s a showcase!"},{"type":"paragraph","text":"But luckily this prop also actually works. It features a lot of animations from the tv show, but more to the point, all the dials on it function and are used to interact with it. It\u0027s really excellent."},{"type":"paragraph","text":"It\u0027s expensive, though, and that alone wouldn\u0027t have been enough to sway me. That is, until I did a little bit of digging and discovered that the firmware is customisable, supporting custom applications out of the box."},{"type":"paragraph","text":"That\u0027s basically a red rag to a bull for me. I love writing software for esoteric things. I mean. Look at the projects on this website. Two of them use a midi device and one of them is a plugin for a videogame. I\u0027ve also written plenty of software for and interfacing with embedded hardware. This is simply made for me!"},{"type":"paragraph","text":"But if you click through the link at the top there, you\u0027ll notice that there\u0027s basically nothing in the repository right now. That\u0027s because, dear reader, we\u0027re gonna be exploring this hardware TOGETHER. IN REAL TIME."},{"type":"paragraph","text":"There\u0027s a sequence at the start of the social network where zuck liveblogs him making facesmash. A lot of what he writes is despicable, and the concept of facesmash is awful, but I dfo love the idea of liveblogging a project. It\u0027s not something I have really done before. So we\u0027re gonna do it with this one."},{"type":"paragraph","text":"It\u0027s a good candidate. There\u0027s some documentation at the above link, but not a lot, and there\u0027s lots left to explore in terms of how the system works. The author of the above linked article makes an assumption, for example - that the graphics context should be one bit per pixel - but I don\u0027t think that\u0027s actually the case. There are different tones in the monochrome screen, and it doesn\u0027t look like they\u0027re made using dithering. So how are they done? That\u0027s jsut one of many questions we will explore together."},{"type":"paragraph","text":"My ultimate aim here is to put something like the in-game stats screen together. When I was setting up my homemade pip-boy, I put some funny and personaly jokes into what stats and perks I picked - being able to replicate them here would be really nice."},{"type":"paragraph","text":"There\u0027s a long walk between here and there, though, so strap in."},{"type":"paragraph","text":"However, let\u0027s get one thing straight here - I\u0027m not a teenager in a university dorm running on monster energy and rage. I\u0027m a 31 year old woman with a full-time job and a bedtime."},{"type":"paragraph","text":"So the \u0027liveblog\u0027 will not be me hacking away at this until 4AM, oh no no. I will be working on this off and on over the next few weeks most likely, and keeping this post updated as I go."},{"type":"paragraph","text":"I am sure you will agree that this is a much healthier choice."},{"type":"paragraph","text":"Beginnings"},{"type":"paragraph","text":"19/03/2025"},{"type":"paragraph","text":"OK, so, the device itself. This is a boutique prop with functionality that, while not an afterthought, is certainly not economically worthy of any kind of custom silicon. This is a collector\u0027s item, there\u0027s only a few thousand of them made, best to use something off the shelf."},{"type":"paragraph","text":"And, indeed, it does! An STM32 to be exact, an absolute classic bit of IC hardware. The STM32 series are ARM microcontrollers, architecturally similar to the hardware in your common or garden smartphone. ARM is wonderful because it\u0027s somehow managed to succeed in all 3 corners of the \u0027you can only have two\u0027 triangle: it\u0027s [relatively] quick, it\u0027s cheap, and it\u0027s Good. It also sips power relative to bigger, more traditional chips, but that\u0027s true of any microcontroller, really, so shouldn\u0027t really be counted as a strength here."},{"type":"paragraph","text":"I was a smidge surprised to see an ARM chip in this - if this were a homebrew project you\u0027d expect probably an arduino, an RP2040 or maybe a teensy - but this thing does have some relatively complex graphics to drive."},{"type":"paragraph","text":"I imagine the main reason this was chosen, however, was hardware video decoding capability. Most (maybe all?) of the show-derived animations are video files on-disk that are just decoded and straight to the graphics context. You can argue this is cheating if you want but to me it reeks of sensible design. Instead of requiring the programmers to design and animate elegantly in a very inelegant context (we\u0027ll get to that, believe me), you get the raw files made for the show, re-encode them, and plonk them on. Easy!"},{"type":"paragraph","text":"Additionally, the raw power the STM32 chip here has allows for a less conventional (but friendlier-ish) development context. This chip uses Espruino."},{"type":"paragraph","text":"Espruino is javascript for microcontrollers. Some of you may have just hissed, and you\u0027d be right to. Javascript is, pretty infamously, horrible. It\u0027s heavy and unwieldy, it\u0027s untyped, it\u0027s messy, it\u0027s functional-but-not-quite. If you want an example of how not to design a programming language, you need look no further than javascript. Yet because it runs in-browser, it is the most common language in the world. Go figure."},{"type":"paragraph","text":"Some of the words in that paragraph may have you convinced that javascript is a bad fit for the lean, high-performance world of microcontrollers, and really, you\u0027d be right. But that hasn\u0027t stopped the most insane people alive, javascript monodevelopers, from crowbarring it into them anyway. And so: Espruino."},{"type":"paragraph","text":"Cards on table, I\u0027ve never used Espruino before today. I\u0027ve touched basically every other microcontroller going, and everything else uses C\u002B\u002B. I\u0027m not a great C\u002B\u002B programmer, but I can get by."},{"type":"paragraph","text":"Comparative to my javascript, I might as well be the Bach of C\u002B\u002B. I do not like promises, I think throwing all your code to \u0027some indeterminate point in the future\u0027 is a horrendous choice, but it\u0027s what we have to work with here, so we go with it."},{"type":"paragraph","text":"The thing is though, in this case, this is actually a fairly massive strength. Because Espruino is a JS interpreter, it will run any valid JS you throw at it. This means you can actually program it interactively from a serial connection, which is pretty snazzy. Here\u0027s me throwing some debug code at it purely from the terminal and seeing it display the results in real time."},{"type":"paragraph","text":"It also means we can dump the firmware with one line from the terminal and, instead of being binary and unreadable, it\u0027s in regular-ass javascript. Holy shit!"},{"type":"paragraph","text":"As mentioned, the guy in the link above has already done this to some extent, but I want to dig through a bit further and understand a bit more what\u0027s going on. There\u0027s some very interesting functions here that I wanna figure out."},{"type":"paragraph","text":"For legal reasons, I can\u0027t share this firmware wholesale in the repo, so you won\u0027t be able to see precisely what I\u0027m talking about. As we go, however, I\u0027ll screenshot various parts of the code so you, reader, have context. Like the above!"},{"type":"paragraph","text":"Anyway, I think that\u0027s where I\u0027m leaving it for tonight. It\u0027s 11pm, after all. More tomorrow."},{"type":"paragraph","text":"Let\u0027s get some images displayed"},{"type":"paragraph","text":"20/03/2025"},{"type":"paragraph","text":"OK, it\u0027s 6pm, there\u0027s 3 hours until the Apprentice is on, let\u0027s get hacking. The first thing I need to do is, uh... take the thing apart."},{"type":"paragraph","text":"That\u0027s an micro-sd extender cable stringing out of it, there. Nearly everything that makes up the pip-boy is stored on an sd card which is, conveniently, not bolted into the board."},{"type":"paragraph","text":"You can open up the thing and take it out, you can copy all of its files over to a folder and, most usefully for us, you can copy those same files over to another, bigger sd card (the one installed is only 250MB) and it works, as long as the card is fat32 formatted."},{"type":"paragraph","text":"I\u0027ve put a 32GB one in there, which is overkill, but I had it lying around. It also means I can put the entirety of the FO3/4/NV soundtracks on there, if I want. Which, maybe, I do in future! Who knows."},{"type":"paragraph","text":"More importantly, however, that SD card has a USER/ folder where we can drop our own custom javascript files and it\u0027ll display them in a nice \u0027APPS\u0027 menu in the INV menu."},{"type":"paragraph","text":"We\u0027re gonna start with the the helloWorld and graphicsTest files that are currently in the repo. Some file wiggling and inserting and removal of SD cards and bang, there they are!"},{"type":"paragraph","text":"So we want tio draw something to screen that isn\u0027t just text, next. So I need to dive into some docs. More updates in a bit..."},{"type":"paragraph","text":"OK, first thing I want to do is draw a square. Which means we need to understand how the graphics buffer is working. See, right here, in the dump of the buffer of the main portion of the screen, is some interesting evidence."},{"type":"paragraph","text":"The interesting thing here is \u0027UInt8.\u0027 This is an array of 8bit integers. This could mean the pixels are rendered as 3-byte RGB values, with the r and b just ignored, or it could mean each bit in the byte is a pixel, and the different tones is achieved using dithering. Right now, I\u0027m not sure!"},{"type":"paragraph","text":"So, to find out, we\u0027re gonna draw three squares."},{"type":"paragraph","text":"And there we have it! Three squares. Now if we look at the code I\u0027ve written, and note that the middle square is the dimmest of the three, we can deduce..."},{"type":"paragraph","text":"That I was completely wrong and the screen is compeltely monochrome, and any dimming is done by dithering. I\u0027m so glad I\u0027m liveblogging this so everyone can see how stupid I am."},{"type":"paragraph","text":"That\u0027s ok though! We\u0027re here to learn, and this actually makes things relatively nice and easy. I\u0027m sure there are monochrome image -\u003E uint arrays somewhere out there one the web already, thisn is a very common format on embedded platforms, so I just need to find one and run the icons for various perks etc through them, and we can get one displaying."},{"type":"paragraph","text":"In fact, maybe that\u0027s what we do next. BRB..."},{"type":"paragraph","text":"Excellent, here\u0027s one, first hit on google. image2cpp Let\u0027s run the Cherchez La Femme image through it and upload it to the machine aaaand..."},{"type":"paragraph","text":"So, that\u0027s not good. In fairness, I am loading a 167x167 bitmap into memory here, I suppose it makes sense that that wouldn\u0027t exactly work. But it is going to make this more difficult."},{"type":"paragraph","text":"Next question then. What is a reasonable maximum array size we can use here? Time to experiment. We\u0027ll start with half the resolution, 83x83."},{"type":"paragraph","text":"OK, it\u0027s displaying, but that\u0027s clearly not right. Here\u0027s what it\u0027s supposed to look like:"},{"type":"paragraph","text":"That\u0027s ok though, there\u0027s options on the converter for just this predicament. This was the default (Horizontal - 1bpp), and Vertical - 1bpp landed similar results, so let\u0027s move on from that converter and use the one hosted on Espruino\u0027s website instead. Man I\u0027m really proving myself soooo smart today."},{"type":"paragraph","text":"Using the right tool for the job gives us this! And it\u0027s even full resolution! Huzzah!"},{"type":"paragraph","text":"OK, that\u0027s real, genuine progress. We can encode images and display them, which we need for the perks screen. They are pretty big though, so I think next order of business is going to be keeping those strings in text files and loading them in when we need to. Back to the docs... Although I might leave it there tonight, my back hurts. No wonder Zuck had to be 19 to do this live."},{"type":"paragraph","text":"Loading from files"},{"type":"paragraph","text":"21/03/2025"},{"type":"paragraph","text":"Evening! Let\u0027s get right back to work."},{"type":"paragraph","text":"Yesterday, we figured out how to correctly convert and display an image on the screen. Today,we\u0027re going to figure out storing those images, loading them at runtime, and switching to another image later."},{"type":"paragraph","text":"So, first up: storing and loading the image. This is gonna need me to look up how the espruino fs library works. BRB..."},{"type":"paragraph","text":"Well that was tremendously easy. First try baby, finally I\u0027m not completely stupid about something! We\u0027re gaming! I would take a photo of the screen but I didn\u0027t change the image\u0027s position so it looks exactly the same. but rest assured it is there!"},{"type":"paragraph","text":"OK, next up, we swap images on the fly."},{"type":"paragraph","text":"Done! Again! Wow we\u0027re going win after win today. Apart from the weird bit of artifacting in the top left of the image there, but, er... I\u0027m sure that\u0027s fine. Let\u0027s ignore it for now. We\u0027ll see if it continues with other images."},{"type":"paragraph","text":"OK, so this is actually a fair cut through the work we need to do here. So let\u0027s structure the screen a bit, and add the name and descriptions."},{"type":"paragraph","text":"Hoooo boy I spent a while here huh! So much for \u0027live\u0027 blogging."},{"type":"paragraph","text":"While I\u0027ve been gone I basically drew the rest of the fucking owl. Look! It\u0027s the screen from the game! Pretty much completely!"},{"type":"paragraph","text":"There\u0027s some artistic license; in-game the perk description displays in the same column as the image, but the available area there is too small to display it readably on screen, so I\u0027ve bannered it at the bottom instead."},{"type":"paragraph","text":"But yeah, we\u0027ve got the basics of the screen here! The list of perks, the box around the selected one, the image, the description. They\u0027re all loaded dynamically from the list of files on the SD card, and I\u0027ve just gone in and tested the reselection with a timeout, and hot damn, it works."},{"type":"paragraph","text":"Additionally, if you\u0027re code digging, you\u0027ll see I\u0027ve done a bunch of reorganisation. While I was hacking before, I\u0027ve gone in and refactored and made all of this actually functionally useful for building up the application proper."},{"type":"paragraph","text":"Next thing, then, is input. Which hopefully, shouldn\u0027t be too bad? I\u0027ll tackle that at some point over the weekend. Then it\u0027s just filling the rest of the perk data (and fixing whatever is wrong with the action girl image) and presto, that\u0027s a screen!"},{"type":"paragraph","text":"OK I lied did a tiny bit more tonight. It\u0027s some housekeeping code - I wanted to make sure the images displayed centrally if they were cropped to just their actual data, any rows of empty pixels around them removed."},{"type":"paragraph","text":"This turned out to be a smart decision - It saves space, it looks nice, and it meant I redid the action girl icon with its bit of corrupted data, a bug I would have otherwise inevtiably ignored until the end. No photos right now because not much has actually changed but it\u0027s good and sets us up for just implementing the manual selection now."},{"type":"paragraph","text":"Chasing accuracy"},{"type":"paragraph","text":"22/03/2025"},{"type":"paragraph","text":"Good morning! I\u0027m up bright and early to continue my vital work."},{"type":"paragraph","text":"Actually, I\u0027ve already started. I looked up what the perk menu looks like in Fallout 4 to check if there a) is one other than the chart (there is) and b) if what I\u0027m making is accurate to that."},{"type":"paragraph","text":"And it mostly is, but the selection box around the perk in the list is actually all green and the text is black, see the below image."},{"type":"paragraph","text":"So I kinda want to recreate that. Doing so would be useful as it\u0027ll also bring my selection menu in line with some of the official submenus on the device as well, making the app look more official :)"},{"type":"paragraph","text":"This is what I\u0027ve got so far - I couldn\u0027t get the text to draw in black so I settled on a halftone selection box instead. I\u0027m still not completely happy with it though and want to get as close to accurate as I can, so I\u0027m going to dive into the firmware and see if I can divine how it does the black on white."},{"type":"paragraph","text":"Ooookay, a lot has happened since I said I\u0027d check the firmware. Here\u0027s the current state of the screen:"},{"type":"paragraph","text":"So, like I said, I dove into the firmware code. I found quite a lot of interesting stuff in there while trying to figure out how they did the black on white textbox. Let\u0027s run through."},{"type":"paragraph","text":"Top of the order - there are three foreground colours the pipboy can draw in, 0-3. These are actually different brightnesses of the pixels. So drawing a fullbright pixel is colour 3, an off pixel is colour 0, but there\u0027s also colours 1 and 2 which are slightly dimmer on colours!"},{"type":"paragraph","text":"This is how the fading \u0027Attachment\u0027 and \u0027Aid\u0027 labels at the top are drawn, although it\u0027s not very clear in the photos I\u0027ve taken."},{"type":"paragraph","text":"I actually discovered this by accident - I saw a setColor(0) in the firmware and deduced that was black, so then I added that in to my drawing code - but in so doing I also set the colour of everything not black to setColor(1), which made everything dimmer."},{"type":"paragraph","text":"This was a bit of a eureka moment - I\u0027d figured out that there were roughly 4 colours displayed on screen but earlier assumed this was all dithering after my experiments a few days ago. Turns out I was right all along! Ha-HA!"},{"type":"paragraph","text":"So, using this new knowledge, I\u0027ve added correct coloring to everything. I\u0027ve also chosen to dim the perk image a shade and I think it makes it look really good. The full brightness was overpowering some of the details of the icons."},{"type":"paragraph","text":"While digging, I also found an interesting function call: setFontMonofonto18(). This was a call on the graphgics context and was pretty self-explanatory."},{"type":"paragraph","text":"The thing is, I\u0027d noticed my fonts were a bit off. They weren\u0027t as tall as they should have been. Turns out, that\u0027s because there\u0027s a custom font in use, but only when you specifically set it. So because I was just using setFontVector() instead of these Monofonto calls, the font was getting set back to the default Espruino vector font."},{"type":"paragraph","text":"Ctrl-F-ing \u0027Monofonto\u0027 in the firmware dump showed up a few similar calls, listed here for convenience:"},{"type":"paragraph","text":"I assumed Monofonto was the font name, and tried to plug it in to a few of the Espruino font functions, but got errors each time, which was troubling. Giving it a quick duckduckgo presented an excellent result: DaFont Monofonto. Turns out the font used on the pip-boy (and presumably in the actual game!) is freely available to download. Perfect!"},{"type":"paragraph","text":"With that in hand, I looked at the Espruino setCustomFont call, and with a smidge more docs digging I found this page that converts a font and size into a graphics context function call - just like the ones already in the firmware!"},{"type":"paragraph","text":"This was perfect. I plugged in the font and set size to 14, dropped the code in my file, and now my description is in the correct font. I used the pre-existing setFontMonofonto18 for the Title - 16 was proving a little too small, and other menus in the device are size 18."},{"type":"paragraph","text":"I\u0027m honestly over the moon about this. I had kind of already settled for things not looking quite right, but with a bit of digging, I solved basically all the imparities with the games."},{"type":"paragraph","text":"What that isn\u0027t, though, is input. Which was the title of this update (I\u0027ve changed it now). So I\u0027ll tackle that next!"},{"type":"paragraph","text":"Actually doing input this time"},{"type":"paragraph","text":"22/03/2025"},{"type":"paragraph","text":"OK we\u0027re back again baby. And this time I swear I am going to do input. To show willing I\u0027ve even already started diving into the firmware to work out how it\u0027s done!"},{"type":"paragraph","text":"So that firmware screenshot tells us all we need to know, really. The Pip object has some events (in this case, \u0027knob1\u0027, which is a) funny and b) the left hand wheel control) one which you can register functions to call."},{"type":"paragraph","text":"This screenshot is from the portion of the code that handles switching between the different health animations, but this applies anywhere really. So what we need to do is:"},{"type":"paragraph","text":"Pretty simple! OK, lets go do that."},{"type":"paragraph","text":"And look at that! We\u0027re done."},{"type":"paragraph","text":"Nothing really special to talk about here, just some basic increment/decrement handling and looping back to the start of the list when necessary."},{"type":"paragraph","text":"There is a specific wrinkle of having to deregister the input event. Early on in my testing I hadn\u0027t done that and it kept the .js loaded even after removing the SD card, which meant it looked like any edits I made weren\u0027t actually working. In order to prevent this I added gracefulClose() which deregisters the handler and shows the main menu again."},{"type":"paragraph","text":"Last thing here is really to handle what happens when we have more perks than will fit in our available space. Then this screen is basically done!"},{"type":"paragraph","text":"And now that\u0027s done too! Couple of bugs with loading the right files to list (primarily loading too many) but smart use of the modulo operator and we\u0027re done! I filled in every perk I think is funny and applicable to me, did some manual edits to some of the icons (new vegas icons seem to generally be less optimised for a real monochrome display, a rare instance where Bethesda\u0027s attention to detail is better than Obsidian\u0027s) and filled in all their data and, well, that\u0027s it!"},{"type":"paragraph","text":"I\u0027m pretty happy with it! This was a really enjoyable project and let me flex a lot of the muscles I don\u0027t tend to use a lot in my pure software day-to-day."},{"type":"paragraph","text":"I might come back to this later and make a second screen with stats on it, but that\u0027s basically the same layout etc as the perks screen just with a number, so I probably won\u0027t write it up."},{"type":"paragraph","text":"So yeah, for now, I\u0027ll leave it here. Thank you for reading and following my thought processes, if you did!"},{"type":"paragraph","text":"OK I LIED one last update. I wanted to handle the selector on the right moving away from INV gracefully. Until now, switching this while in the app would do nothing, and to get around this I had the click in of the left wheel as the \u0027return me to the main menu\u0027 key. That\u0027s not very accurate to how it should work, though."},{"type":"paragraph","text":"So I dove into the firmware again and after a few false starts I found checkMode(). This is called on a timer in all the other apps every 50 milliseconds. I experimented a little with this timeout in my app - 50ms made the perk list feel laggy (because I\u0027m loading data from disk so ops take a bit more time than everything else in the system) so I tweaked it up to 100ms, which was a nice compromise."},{"type":"paragraph","text":"Twiddling the mode dial triggers a menu change, but I also made sure to gracefully shutdown when a change was detected - this includes running showMainMenu(), which if we don\u0027t do the control handlers don\u0027t get registered properly. Important."},{"type":"paragraph","text":"I also dropped in a handler for the torch button - it does nothing other than shutdown and activate the torch normally. While testing this I actually found a bug with the current firmware - if you stay on the torch page and the time changes, the footer will display over the torch screen. Neat!"},{"type":"paragraph","text":"Anyway that is really it this time. Thanks for reading \u003C3"},{"type":"paragraph","text":"The Stats Screen"},{"type":"paragraph","text":"22/03/2025"},{"type":"paragraph","text":"OK I WORKED ON IT A BIT MORE but as mentioned it wasn\u0027t particularly exciting - this actually reuses basically all the same code, with just a smidge more for drawing the skill points."},{"type":"paragraph","text":"I did actually have all the skills in one big JSON but it caused an out of memory error, so I abandoned it and went back to individual files for each skill."},{"type":"paragraph","text":"Anyway, here\u0027s it working!"},{"type":"paragraph","text":"The Special Screen"},{"type":"paragraph","text":"23/03/2025"},{"type":"paragraph","text":"Final update! I added the SPECIAL screen too, and reordered the screens to match the in-game pipboy. Finally I also did some TLC to make the app a lot more responsive and reduce redraws, so now it feels really snappy :)"}]}
\ No newline at end of file