<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>Sarah's Forge .Dev Blog</title>
  <link>https://sarahsforge.dev</link>
  <description>The latest thoughts, tutorials, and project updates on development and technology from Sarah's Forge.</description>
  <language>en-us</language>
  <lastBuildDate>Mon, 08 Jun 2026 17:58:15 +0000</lastBuildDate>
  <item>
    <title>Social Share, Image/Video Embeds, Download Counts, Reviews and More!</title>
    <link>https://sarahsforge.dev/blog/social-share-image-video-embeds-download-counts-reviews-and-more-</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/social-share-image-video-embeds-download-counts-reviews-and-more-</guid>
    <pubDate>Fri, 05 Jun 2026 07:17:38 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>I’ve recently added the ability to leave testimonials and ratings on Sarah’s Forge. This is something I’ve wanted to do for a while, because feedback from customers and visitors is incredibly valuable. It helps me understand what people really enjoy and what could be improved. If you’ve downloaded or purchased anything from the store, I encourage you to come back and leave a rating or a short testimonial. Your thoughts will guide future projects and help others decide what might work well for them. Even a quick star rating makes a difference.</p>
<p>I’ve also added download counts for every item in the store. Now you can see how many times each file has been downloaded, which gives a clearer picture of what’s popular. This is useful for both me and for you, because it highlights the designs that resonate most with the community. The download count adds a layer of transparency and helps you spot trending items at a glance.</p>
<p>Social sharing is now available on blog posts and store items as well. You can easily share your favorite finds on platforms like Facebook, or BlueSky. This makes it simple to spread the word about something you love, whether it’s a tutorial, a new product, or a behind-the-scenes story. I’ve also added embedding to blog posts, so I can include images and videos directly in the posts. This means richer, more engaging content without needing to click away to other sites. Photos of finished projects, step-by-step video guides, and close-up details can all be part of the reading experience now. It makes the blog feel more alive and connected to the crafting process.</p>]]></description>
  </item>
  <item>
    <title>50% OFF for Pride Month</title>
    <link>https://sarahsforge.dev/blog/50-off-for-pride-month</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/50-off-for-pride-month</guid>
    <pubDate>Thu, 04 Jun 2026 23:55:41 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>This month only, get 50% off everything in the store when you use the code PRIDEMONTH at checkout. It is a limited time offer, so do not wait. Celebrate with us and grab what you love while the sale lasts.</p>]]></description>
  </item>
  <item>
    <title>PocketProgrammer</title>
    <link>https://sarahsforge.dev/blog/pocketprogrammer</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/pocketprogrammer</guid>
    <pubDate>Thu, 04 Jun 2026 08:03:13 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>This is very much a work in progress, but I wanted to share a quick sneak peek at PocketProgrammer. It is an Android application I have been building. The core idea is simple. PocketProgrammer will let you read and configure serial radios right from your phone or tablet.</p>
<p><img src="https://sarahsforge.dev/uploads/blog/blog_1780560037_ff6c54fa.gif" alt="VID_20260604_022721" /></p>
<p>Right now, the focus is on serial connections. That is the primary way these radios talk, so that comes first. I am also looking at adding Bluetooth support down the line. That would cover a lot of newer radios and make the tool even more useful in the field.</p>
<p>The goal is to offer a proper alternative to carrying around a laptop just to change a frequency or update a setting. With PocketProgrammer, you can connect directly to the radio, read its current configuration, and write new settings. It is designed to be straightforward and practical.</p>
<p>The app is still rough around the edges. There are features to add, bugs to fix, and a lot of testing to do. But the foundation is there. If you work with serial radios and have wanted a more portable way to manage them, this is something to keep an eye on.</p>
<p>I will share more as development continues. Feedback from people who actually use these radios will be very valuable, so feel free to reach out when the time comes. For now, this is the start of something promising.</p>]]></description>
  </item>
  <item>
    <title>Texas Age ID Checks Updates</title>
    <link>https://sarahsforge.dev/blog/texas-age-id-checks-updates</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/texas-age-id-checks-updates</guid>
    <pubDate>Wed, 03 Jun 2026 06:14:34 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>I got this email from Google Play today. It says Texas has a new law, SB 2420, that forces app stores to verify user ages and get parental consent. Google is rolling out their Age Signals API to help developers follow the rules. I have no interest in participating in any of this. I will not add age checks or ID verification to my apps. I will evade this as long as I can.</p>
<p>I am still figuring out my next move. One option is to stop distributing Pocket25 in Texas through the Google Play Store. I could keep offering it for side loading from pocket25.com instead. That way, users in Texas can still get the app directly from me without any store enforced age gates. I am not sure yet. I need to weigh the hassle against the risk.</p>
<p>For now, I will not update my apps to use the Play Age Signals API. I will not collect or pass along any age data to Google. If Texas wants to track users, they can do it without my help. I built Pocket25 for a specific audience, and age checks are not part of that vision. I will find a workaround or simply pull the app from the Texas storefront. Side loading is simple and keeps the control where it belongs. With the user. Not with the state or the store.</p>]]></description>
  </item>
  <item>
    <title>The Return of Android Apps!</title>
    <link>https://sarahsforge.dev/blog/the-return-of-android-apps-</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/the-return-of-android-apps-</guid>
    <pubDate>Mon, 01 Jun 2026 02:47:56 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>After I deleted apps on the forge, I realized I had acted on impulse. Google had announced upcoming changes to its policy on side loading apps, and I felt a sudden pressure to clear everything out. It was a rash move, and I did not need to make it. The decision came from a place of caution, but it was not well thought out. I removed tools and utilities that people relied on, and I left a gap that I now have to fill.</p>
<p>I am slowly replacing those apps now. Each one requires careful attention. I need to get them signed properly, which is a step I should have taken from the start instead of panicking. The signing process ensures that the apps are trusted and secure, and it aligns with the new policy requirements. It is tedious work, but it is necessary.</p>
<p>At the same time, I am updating them with our new versioning API. This is a feature I have been meaning to implement for a while. The API allows the apps to tell you if there is an update waiting for it. Instead of you having to check manually or rely on outside sources, the app itself will notify you. It is a small improvement, but it makes a big difference in keeping everyone on the latest and safest version.</p>
<p>Rebuilding takes time, and I have to remind myself that progress is still progress even if it is slow. The apps are coming back one by one, each better than before. The rash move cost me some convenience and trust, but it also taught me a lesson about acting on fear. Now I am focused on doing it right, with proper signing and better update management. The forge will be fully stocked again, and this time it will be built to last.</p>]]></description>
  </item>
  <item>
    <title>DeepWP: How AI Takes Full Control of Your WordPress Site</title>
    <link>https://sarahsforge.dev/blog/deepwp-how-ai-takes-full-control-of-your-wordpress-site</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/deepwp-how-ai-takes-full-control-of-your-wordpress-site</guid>
    <pubDate>Sat, 30 May 2026 07:05:44 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>DeepWP is a WordPress plugin that brings a chat bot directly into your wp-admin dashboard. It connects you to DeepSeek AI, and you use your own API key. This setup gives the AI full control over your website through 41 built in tools.</p>
<p>Imagine having an AI assistant that lives inside your WordPress admin. You can talk to it just like you would with any chat bot. But instead of just answering questions, this AI can actually take action on your site. It can create posts, update pages, manage users, adjust settings, and much more. The 41 tools cover almost every aspect of site management.</p>
<p>Getting started is simple. You install the DeepWP plugin like any other. Then you add your DeepSeek API key from your account. Once that is done, the chat bot appears in your admin panel. You type a command or ask a question, and the AI responds. If you ask it to do something, it will use the appropriate tool to make it happen.</p>
<p>The power here is in the tool set. The AI can write and edit content. It can change themes and plugins. It can handle media uploads and user roles. It can even run database queries if you need that. Each tool gives the AI a specific ability, and together they cover the full range of what you can do in WordPress.</p>
<p>This is useful for busy site owners. You can delegate routine tasks to the AI. You can ask it to schedule posts or optimize images. You can have it check for broken links or update your site’s SEO settings. The AI works fast and follows your instructions precisely.</p>
<p>DeepWP turns your WordPress admin into a command center. Instead of clicking through menus and forms, you just tell the AI what you need. It handles the rest. For anyone who manages a WordPress site regularly, this plugin saves time and effort. The 41 tools give DeepSeek AI complete control, and that control is exactly what you need to run your site efficiently.</p>]]></description>
  </item>
  <item>
    <title>Version Tracking, Changelogs &amp; an Update API</title>
    <link>https://sarahsforge.dev/blog/version-tracking-changelogs-an-update-api</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/version-tracking-changelogs-an-update-api</guid>
    <pubDate>Sat, 30 May 2026 05:58:18 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>Every piece of software I ship through the Forge just got a whole lot smarter.</p>
<p>Starting today, every product page now shows a <strong>version number</strong> right next to the title, so you know exactly what you're getting. Below the screenshots, you'll find a full <strong>changelog</strong>  a running timeline of every update, what changed, and when it dropped.</p>
<p>No more guessing whether you have the latest build.
And to tie it all together, each product now exposes a lightweight <strong>API endpoint</strong> your apps can call to check for updates automatically. Give it your current version and it'll tell you if there's something newer waiting. </p>
<p>Dead simple.</p>
<p>Here's what that looks like in practice:</p>
<ul>
<li>Buy a product once, never wonder if you're out of date</li>
<li>Apps can ping the API and alert you when an update is available</li>
<li>Full changelog history lives right on the product page
This is the kind of polish I want baked into everything that leaves the Forge. More to come.
— Sarah</li>
</ul>]]></description>
  </item>
  <item>
    <title>Where is Pocket25?</title>
    <link>https://sarahsforge.dev/blog/where-is-pocket25-</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/where-is-pocket25-</guid>
    <pubDate>Fri, 29 May 2026 07:10:25 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>Pocket25 has been a quiet but essential tool for radio enthusiasts, public safety monitors, and scanner hobbyists who need to decode P25 Phase 1 trunked radio systems directly from their Android devices. For a long time, it lived comfortably outside the Play Store, available only through side loading. That setup worked well for the dedicated community that knew where to find it and how to install it. But in November, Google is making changes that reshape the landscape for side loaded apps. The shift was not sudden or dramatic for most users, but for a specialized app like Pocket25, it created a real problem. The app was removed from the site where it had been hosted, and suddenly the path to getting it onto a phone became much trickier.</p>
<p>The good news is that the developers are not walking away. The official website, pocket25.com, still has the APK file available for anyone who knows where to look and is willing to manually install it. That keeps the app alive for now, but it is not a long term solution. Google’s new policies and the increasing friction around side loading mean that relying on that method is becoming less reliable and more confusing for users who just want to listen to trunked radio traffic without jumping through hoops.</p>
<p>So the team behind Pocket25 has a plan. They are preparing to bring the app into the Google Play Store. This is a significant step because it changes how the app is distributed, how updates are handled, and how safe users can feel about downloading it. Instead of hunting down an APK file and enabling unknown sources, users will be able to find Pocket25 in the Play Store, tap install, and have it work like any other app on their phone. It will be released for free with no ads. That part matters. There will be no subscription fee, no banner ads cluttering the interface, and no paywall blocking access to the features that make the app useful. The goal is to keep it open and accessible to the same community that has supported it all along.</p>
<p>For anyone who relies on monitoring public safety communications, fire departments, police, or other agencies that use P25 Phase 1 trunking, this change is a welcome one. It means fewer headaches with compatibility, easier updates, and a smoother experience overall. The developers are working through the Play Store submission process now, which involves meeting Google’s requirements for privacy, security, and functionality. It takes time, but the end result will be a cleaner, more stable way to run Pocket25 on any compatible Android device.</p>
<p>If you have been using the side loaded version, keep an eye on the Play Store in the coming weeks. The official release will appear there, and when it does, you can simply install it over your existing setup or start fresh. For now, if you still need the APK directly, pocket25.com remains the place to get it. But that will change once the Play Store version is live. The transition is not about abandoning the old method entirely. It is about adapting to a shifting mobile ecosystem so that a useful tool does not get left behind.</p>]]></description>
  </item>
  <item>
    <title>Sideloading isn&#039;t Dead, It&#039;s Just More Complicated Now</title>
    <link>https://sarahsforge.dev/blog/sideloading-isn-t-dead-it-s-just-more-complicated-now</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/sideloading-isn-t-dead-it-s-just-more-complicated-now</guid>
    <pubDate>Fri, 29 May 2026 06:31:22 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>If you have spent any time around Android devices, you know that one of the great freedoms of the platform is the ability to install applications from outside the official Google Play Store. This process, called side loading, has always felt like a secret handshake among power users. You download a file with an APK extension, enable a simple toggle in your settings, and suddenly you have access to apps that are not available in your region or versions that offer features the Play Store version removed. It is a quiet form of digital independence.</p>
<p>But Google recently made a change to how APKs are registered on your device, and the implications for side loading are more significant than most people realize. To understand what happened, you need to look past the technical jargon and see what is actually different about the way your phone now treats these files.</p>
<p>In the past, when you downloaded an APK from a website and tapped on it, Android would ask for permission to install it. Once you granted that permission, the app would install and appear on your home screen. It was a straightforward transaction between you and the file. The app did not need to be registered with Google in any special way. It just worked.</p>
<p>The new change involves something called the Android App Bundle and a shift toward requiring apps to be registered with Google Play’s servers before they can be installed on newer versions of Android. This does not mean you cannot side load at all, but it means that the APK file you download must now match a verified signature that Google holds on its servers. If the app developer has not pushed that specific version through Google Play, your device may reject it.</p>
<p>At first glance, this sounds like a security measure. And in many ways, it is. Malicious actors have long used side loading to distribute fake versions of popular apps that contain malware or spyware. By requiring a verified registration, Google can ensure that the app you are trying to install actually came from the developer it claims to represent. For the average person who only side loads occasionally, this is probably a good thing. It adds a layer of protection that was previously missing.</p>
<p>But for the dedicated side loading community, the change feels like a tightening of the screws. If you enjoy using apps like NewPipe, which offers an ad free YouTube experience, or if you rely on a banking app that is only available in another country, this new registration requirement can become a wall. The developers of these alternative apps often do not register their builds with Google Play, either because they cannot or because they do not want to. So when you try to install their latest version, your phone may simply refuse.</p>
<p>There is also a practical inconvenience here. When you side load an app today, you are not just dealing with the file itself. You are dealing with the entire ecosystem that Google has built around app verification. If the app was originally uploaded to the Play Store by a developer, even if you then download that same APK from a third party site, it will likely install because the signature matches. But if the app was never on the Play Store, or if the version you have is a modified fork, your phone will block it.</p>
<p>What does this mean for the future of side loading? It means that the open nature of Android is slowly being replaced by a more curated experience. Google is not shutting the door completely, but they are adding a lock that only they hold the key to. The days of freely installing any APK you find on the internet are fading for users of the latest Android versions. You can still side load, but only as long as the app you are installing has been blessed by Google’s registration system.</p>
<p>Some will argue that this is a reasonable trade off for increased security. Others will feel that it undermines the very reason they chose Android over iOS in the first place. The truth likely sits somewhere in the middle. If you are careful about where you get your APKs and you understand the risks, you may never notice the change. But if you rely on apps that exist outside the official storefront, you may find yourself searching for workarounds or holding onto an older phone that still respects the old rules.</p>
<p>The change to APK registration is a quiet one. It did not make headlines like a new phone launch or a major software update. But it speaks to a larger shift in how Google views the relationship between the user and the device. They are moving from a philosophy of trust the user to a philosophy of trust the store. And for anyone who values the freedom to install what they want, when they want, that is a change worth paying attention to.</p>]]></description>
  </item>
  <item>
    <title>RozeFox - The API-Based AI ReWriter</title>
    <link>https://sarahsforge.dev/blog/rozefox---the-api-based-ai-rewriter</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/rozefox---the-api-based-ai-rewriter</guid>
    <pubDate>Fri, 29 May 2026 06:14:41 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>I have to be honest with you. When it comes to blog posts these days, I struggle to keep up. It is not for lack of ideas or a shortage of stories to tell. The problem is much simpler than that. It takes an enormous amount of time to type out a full post. My fingers just cannot keep pace with my thoughts, and by the time I finish writing, I often feel like I have already moved on to the next thing.</p>
<p>The projects I share on my blog are not quick little tasks I finish in an afternoon. They are the kind of projects that take days, sometimes even weeks, to complete. I pour myself into them. I work through the hard parts, solve the tricky problems, and finally get to the satisfying moment where everything comes together. By then, I am tired. The last thing I want to do is sit down and write a long, detailed post about something that I have already lived through in my head a hundred times. The excitement is still there, but the energy for typing it all out is gone.</p>
<p>That is where RozeFox comes in. It is a Firefox plugin that has become a quiet little helper in my workflow. The best part is that I did not have to sign up for anything new. It uses the DeepSeek API key I already had, sitting there unused in my account. I write a short post, just the bare bones of what I want to say. A few sentences. The key points. The heart of the story. Then RozeFox takes that short post and rewrites it into a full length article.</p>
<p>I do not just publish it right away. That would be too easy, and honestly, it would not feel like my voice. Instead, I read through the expanded version carefully. I tweak the parts that sound off. I add in details that the plugin could not have known. I smooth out the rough edges. It feels like having a collaborator who takes my messy first draft and hands me back something that looks like a real blog post, ready for me to make it truly mine.</p>
<p>For someone like me, who loves to create and share but hates the slow grind of typing, this has been a small gift. It lets me focus on what I do best. Building things. Figuring things out. Making progress on the next project. And when I am ready to share, the writing part does not feel like a burden anymore. It feels like the natural end to a good story.</p>
<p>Below is the original text I input:</p>
<p>When it comes to blog posts I have a hard time keeping up these days. It takes a long time to type out a full post and often I'm posting about projects I've been working on for days or even weeks. </p>
<p>When it comes to projects like that it's nice to have RozeFox, it's a firefox plugin that uses the deepseek api key I already had to re-write my short posts to full length posts that I read through and tweak.</p>]]></description>
  </item>
  <item>
    <title>Reverse Engineering the Unreal Tournament 2004 Master Server</title>
    <link>https://sarahsforge.dev/blog/reverse-engineering-the-unreal-tournament-2004-master-server</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/reverse-engineering-the-unreal-tournament-2004-master-server</guid>
    <pubDate>Mon, 25 May 2026 23:49:32 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<h1>Unlocking the Past</h1>
<p>If you walked into my high school's computer lab after hours, you wouldn't hear the clicking of people typing essays. You'd hear the frantic, rhythmic slamming of mice and keyboards, punctuated by the faint, muffled sound of the announcer screaming, <em>"M-M-M-MONSTER KILL!"</em> through someone's cheap headphones.</p>
<p>Unreal Tournament 2004 wasn't just a game for me—it was <em>the</em> game. It was the defining multiplayer experience of my high school years. Between dodging Shock Rifle combos, mastering the chaos of Onslaught mode, and the sheer adrenaline of Instagib matches, UT2004 cemented itself as one of my all-time favorite games. It was fast, it was unforgiving, and the netcode was legendary for its time.</p>
<p>Fast forward to today. The gaming landscape has changed, GameSpy is a relic of the past, and Epic Games eventually pulled the plug on the official master servers.</p>
<p>The community, as always, found a way. Projects like OpenSpy stepped in to emulate the old GameSpy infrastructure, keeping UT2004 and dozens of other classic games alive. But recently, I wanted to dig deeper. I wanted to host my own infrastructure and see exactly how this classic game communicated under the hood.</p>
<p>There was just one problem: OpenSpy’s master server implementation isn't open source.</p>
<p>If you know anything about engineers driven by nostalgia, you know that closed source isn't a roadblock—it's an invitation. So, I decided to build my own. Here is how I reverse-engineered the UT2004 master server protocol and built <strong>OpenUT2k4MasterServer</strong> from scratch.</p>
<hr />
<h2>Diving Into the Matrix: Wireshark and Ghidra</h2>
<p>Without access to the original server source code or OpenSpy's repository, I had to figure out how the game client and game server talked to the master server. This meant tackling the problem from two angles: observing the network traffic and decompiling the game's binary.</p>
<p>I started by routing my UT2004 client and a dedicated server through Wireshark to capture the packets talking to <code>utmaster.openspy.net</code>. Seeing the raw hex dump of GameSpy’s "enctype 2" protocol was like reading an ancient dialect.</p>
<p>To make sense of the bytes, I dropped UT2004's <code>IpDrv.dll</code> into Ghidra. By piecing together the decompiled C++ code with the packet captures, the puzzle finally started coming together.</p>
<h3>The Game Client Protocol</h3>
<p>When you click the "Internet" tab in UT2004, the client initiates a TCP connection to port 28902. It's a strict handshake process:</p>
<ol>
<li><strong>Challenge:</strong> The server sends a challenge string.</li>
<li><strong>Auth:</strong> The client responds with hashes.</li>
<li><strong>Approval:</strong> The server replies with an <code>APPROVED</code> packet.</li>
<li><strong>Verification:</strong> The client sends a verification string (starting with <code>!</code>).</li>
<li><strong>Request:</strong> Once <code>VERIFIED</code>, the client asks for either the MOTD (news) or the server list.</li>
</ol>
<h3>The Game Server Uplink</h3>
<p>If you're hosting a server, it needs to tell the master server it exists. This also happens over TCP 28902 but behaves completely differently after the auth phase:</p>
<ul>
<li>After approval, the master server sends "state 2" session keys.</li>
<li>The game server enters "state 3," keeping the connection alive.</li>
<li>It sends heartbeats and, after about 60 seconds, fires off a massive refresh packet containing the server name, port, and gametype.</li>
</ul>
<hr />
<h2>Building OpenUT2k4MasterServer</h2>
<p>Armed with the protocol specifications, I fired up Go. Go is fantastic for network applications, making it incredibly easy to handle concurrent TCP connections, binary packing, and UDP listeners.</p>
<p>I built <strong>OpenUT2k4MasterServer</strong>, a fully open-source drop-in replacement for the UT2004 master server.</p>
<h3>Core Features I Implemented:</h3>
<ul>
<li><strong>TCP Master Server (Port 28902):</strong> Handles both the intricate game client server list queries and the persistent game server uplinks.</li>
<li><strong>UDP Heartbeat Listeners (Ports 27900 &amp; 28902):</strong> Catches the lightweight UDP pings servers send to prove they are still alive.</li>
<li><strong>MOTD / News Support:</strong> I even reverse-engineered the GameSpy color codes (e.g., <code>\x1b\xff\xff\xff</code>) so the in-game News tab populates correctly with custom messages.</li>
<li><strong>HTTP Management API (Port 8090):</strong> Because it’s 2026, I added a modern JSON API to manually register servers and check the network status via the browser.</li>
</ul>
<h3>The Quirks of 2004 Tech</h3>
<p>Working on this project reminded me of the fascinating quirks of older game engines. For example, UT2004 categorizes <code>127.0.0.1</code> servers strictly under the <strong>LAN</strong> tab. If you're testing an internet master server locally, you have to use your actual network IP, or you'll be scratching your head wondering why your server isn't showing up in the Internet list. Furthermore, strings are often passed around with hardcoded length bytes and null terminators—classic early 2000s C++ networking.</p>
<hr />
<h2>Keeping the Legacy Alive</h2>
<p>There is something profoundly satisfying about writing modern Go code to interface with a C++ game engine compiled over two decades ago.</p>
<p>By open-sourcing the master server, my hope is that the UT2004 community never has to rely entirely on a single point of failure again. Whether you want to host a private master server for a massive LAN party, or just poke around the GameSpy protocol out of curiosity, the code is out there.</p>
<p>High school me would be pretty thrilled to know we’re still finding ways to keep the tournaments running.</p>
<p><em>If you want to check out the code, host your own server, or contribute, you can find the OpenUT2k4MasterServer repository on my GitHub. Happy fragging!</em></p>]]></description>
  </item>
  <item>
    <title>Android Sideloading is Dead</title>
    <link>https://sarahsforge.dev/blog/android-sideloading-is-dead</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/android-sideloading-is-dead</guid>
    <pubDate>Thu, 21 May 2026 06:03:05 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>We’ve made a few updates to how we handle mobile distribution for Sarah's Forge going forward.</p>
<p>Due to upcoming policy changes and tighter registration requirements from Google scheduled for November, we’ve decided to remove all Android apps previously distributed through Sarah's Forge. These changes introduce additional overhead and constraints that don’t align well with how we want to ship smaller, fast-moving tools and experimental software.</p>
<p>Going forward, we’ll be focusing our mobile releases on the official app marketplace instead. That means Pocket25 will be moving into the official app store ecosystem, with a proper, compliant release path and ongoing updates delivered through standard channels. This should make installation, updates, and trust far more straightforward for users.</p>
<p>Pocket25 will be the first project to follow this new distribution model, and it will serve as the baseline for how future mobile releases are handled.</p>
<p>Future software from Sarah's Forge will still be sold and distributed through official app store channels where applicable, rather than standalone Android packages. This should help streamline support, improve discoverability, and ensure long-term stability across devices.</p>
<p>We’ll continue building and shipping tools as usual—just with a more standardized distribution path moving forward.</p>]]></description>
  </item>
  <item>
    <title>Pocket25 1.0.0+4</title>
    <link>https://sarahsforge.dev/blog/pocket25-1-0-0-4</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/pocket25-1-0-0-4</guid>
    <pubDate>Mon, 19 Jan 2026 22:24:09 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<h1>Pocket25 Build 1.0.0+4 - Major Stability and Feature Updates</h1>
<hr />
<h2>Overview</h2>
<p>I'm excited to share the latest updates to <strong>Pocket25</strong>, my Android application for decoding APCO Project 25 (P25) trunked and conventional radio systems. Build 1.0.0+4 brings significant stability improvements, powerful new features, and a much smoother user experience. Over the past few days, I've been laser-focused on eliminating crashes, improving performance, and adding features that real users actually need.</p>
<hr />
<h2>🚀 Major New Features</h2>
<h3>Advanced Scan Mode</h3>
<p>One of the biggest additions is the new <strong>Advanced Scan</strong> feature, which gives power users complete control over DSD-Neo's command-line options directly from the app. Instead of being limited to the UI's quick scan presets, you can now input full command strings like:</p>
<pre><code>-i rtltcp:192.168.1.240:1234:771.18125M -fp -H key_here -4</code></pre>
<p>This means you can:</p>
<ul>
<li>Specify custom RTL-SDR or RTL_TCP configurations</li>
<li>Add encryption keys on the fly</li>
<li>Set custom decoder flags and modulation parameters</li>
<li>Use any DSD-Neo option without recompiling the app</li>
</ul>
<p>The Advanced Scan screen includes helpful examples for RTL-SDR direct USB, RTL_TCP remote connections, and encryption key usage. This is a game-changer for advanced users who know exactly what parameters they need.</p>
<h3>HackRF One Support</h3>
<p>is currently in testing, it's not complete and I don't recommend you use it, yet.</p>
<h3>Enhanced Radio Reference Import</h3>
<p>The Radio Reference import feature got some major love:</p>
<ul>
<li><strong>Canada Support:</strong> Canadian users can now import systems from RadioReference.com</li>
<li><strong>GPS-Based County Lookup:</strong> The app automatically detects your county using GPS coordinates</li>
<li><strong>Improved Location Accuracy:</strong> Fixed the county detection algorithm to check ALL counties in your state instead of just sampling, dramatically improving accuracy</li>
<li><strong>Auto-Scroll to Trunked Systems:</strong> When you select a county, the UI now automatically scrolls down to show available trunked systems</li>
</ul>
<h3>Scan Grid with Category Filtering</h3>
<p>Added a new <strong>Scan Grid</strong> view that organizes talkgroups by category (Law Enforcement, Fire/EMS, Public Works, etc.) with visual filtering. The grid remembers mute states and makes it easy to see what's active at a glance.</p>
<h3>Web Programmer Feature</h3>
<p>Introduced a multi-page <strong>Web Programmer</strong> interface for advanced radio configuration directly from the app. This is still experimental but opens up interesting possibilities for system management.</p>
<hr />
<h2>🛠️ Critical Bug Fixes</h2>
<h3>ANR (Application Not Responding) Crash Fixes</h3>
<p>The biggest stability work went into eliminating ANR crashes that were plaguing users:</p>
<ol>
<li>
<p><strong>Site Switching Crash (FIXED):</strong> The app was freezing when switching between sites during GPS-based site hopping. The problem? Heavy database operations and distance calculations were running on the main UI thread, blocking user interactions.</p>
<ul>
<li><strong>Solution:</strong> Moved site loading to a background thread and distance calculations to a compute isolate with a custom haversine function</li>
<li>Result: Butter-smooth site switching even with 200+ sites loaded</li>
</ul>
</li>
<li>
<p><strong>Talkgroup Mute Crash (FIXED):</strong> Muting talkgroups during an active transmission was causing the app to hang.</p>
<ul>
<li><strong>Solution:</strong> Optimized the native-level audio filtering to handle mute state changes asynchronously</li>
</ul>
</li>
<li>
<p><strong>Native USB RTL-SDR Crash (FIXED):</strong> Switching control channels with a native USB RTL-SDR dongle would crash the app.</p>
<ul>
<li><strong>Solution:</strong> Proper cleanup and reinitialization of the RTL-SDR device during channel changes</li>
</ul>
</li>
<li>
<p><strong>Advanced Scan Input Device Bug (FIXED):</strong> When using Advanced Scan with custom <code>-i</code> device parameters, the app would ignore the setting and default to 'pulse' audio.</p>
<ul>
<li><strong>Solution:</strong> Re-apply custom arguments in <code>nativeStart()</code> to prevent settings from being overwritten</li>
</ul>
</li>
</ol>
<h3>UTF-8 Crash Fix</h3>
<p>Fixed a crash caused by invalid UTF-8 characters in DSD decoder output. The native C++ code now properly handles encoding issues before passing strings to Flutter.</p>
<h3>Talkgroup Name Lookup Fix</h3>
<p>Corrected database field mapping for talkgroup name lookups, ensuring the right names display during monitoring.</p>
<h3>Scan Grid State Persistence</h3>
<p>Mute states in the scan grid now persist correctly between sessions and don't reset unexpectedly.</p>
<hr />
<h2>⚡ Performance Improvements</h2>
<h3>Stay on Control Channel for Muted Talkgroups</h3>
<p>When a talkgroup is muted, the scanner now stays on the control channel instead of following the voice channel. This dramatically reduces unnecessary frequency hopping and improves overall performance.</p>
<h3>DMR Audio Improvements</h3>
<p>Enhanced DMR audio output quality through better buffer management and frame timing.</p>
<h3>P25 Phase 2 Partial Improvement</h3>
<p>Implemented frame-by-frame audio output for P25 Phase 2 systems. While Phase 2 audio is still choppy (upstream DSD-Neo limitation), it's noticeably better than before. Full details are documented in <code>P25_PHASE2_AUDIO_ISSUE.md</code>.</p>
<hr />
<h2>🎨 UI/UX Enhancements</h2>
<ul>
<li><strong>Overhauled Scanner Screen:</strong> Redesigned with scaled status display and better information density</li>
<li><strong>Network Screen:</strong> Added comprehensive display of patches, group attachments, and affiliations</li>
<li><strong>Signal Metrics:</strong> State-based signal quality indicators and improved adjacent sites display</li>
<li><strong>Custom App Icon:</strong> Pocket25 now has its own distinctive icon</li>
<li><strong>Fixed Icon Cropping:</strong> Disabled adaptive icons to prevent the icon from being cut off on some launchers</li>
<li><strong>DSD Log Screen:</strong> Removed the "jump to bottom" button (auto-scroll works better)</li>
</ul>
<hr />
<h2>📦 Development Infrastructure</h2>
<ul>
<li><strong>Automatic Version Management:</strong> Build numbers now auto-increment, making release tracking easier</li>
<li><strong>Wakelock:</strong> App keeps the screen on while running to prevent interruptions during monitoring</li>
</ul>
<hr />
<h2>🔜 What's Next?</h2>
<p>I'm actively looking for sample recordings of non-P25 protocols (DMR, NXDN, D-STAR, YSF, dPMR) to improve multi-protocol UI support. If you have recordings or systems to test, please reach out!</p>
<p>Upcoming features on my radar:</p>
<ul>
<li>Better P25 Phase 2 audio</li>
<li>Enhanced talkgroup database management</li>
<li>Export/import of system configurations</li>
</ul>
<hr />
<h2>📥 Download</h2>
<p>You can download the latest APK from <strong><a href="https://sarahsforge.dev/products/Pocket25">SarahsForge.dev/products/Pocket25</a></strong></p>
<p>Source code is available on <strong><a href="https://github.com/SarahRoseLives/Pocket25">GitHub</a></strong> under GPL v3.</p>
<hr />
<h2>🙏 Acknowledgments</h2>
<p>Pocket25 is built on the amazing <a href="https://github.com/arancormonk/dsd-neo">DSD-Neo</a> decoder engine by @arancormonk. Huge thanks to the open-source digital radio community for their support and feedback!</p>
<p>If you're using Pocket25, I'd love to hear about your experience. Feel free to open issues on GitHub or reach out with feedback.</p>
<p><strong>73,</strong><br />
<strong>SarahRose</strong></p>]]></description>
  </item>
  <item>
    <title>UTAdmin</title>
    <link>https://sarahsforge.dev/blog/utadmin</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/utadmin</guid>
    <pubDate>Sat, 03 Jan 2026 07:39:41 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<h1>Building a Modern Admin Tool for Unreal Tournament 2004 on Windows</h1>
<p>Sometimes nostalgia meets modern development, and magic happens.  That's exactly what happened when I decided to build <strong>UTAdmin</strong> - a complete administration solution for Unreal Tournament 2004 servers that lets you manage your server from your phone. This project combines the ancient art of UnrealScript with modern Flutter development, all built on Windows.</p>
<h2>The Problem</h2>
<p>I've been running UT2004 servers on and off for years, and admin tools have always been...  clunky. Most require you to be at the console, use awkward in-game commands, or rely on web interfaces from 2004 that barely render on modern browsers. I wanted something simple:  <strong>manage my server from my phone while I'm playing or AFK</strong>.</p>
<h2>The Solution: A Two-Part System</h2>
<p>UTAdmin consists of two components:</p>
<ol>
<li><strong>A server-side mutator</strong> that exposes a TCP API server written in UnrealScript</li>
<li><strong>A Flutter mobile app</strong> that connects to the API and provides a clean, modern UI</li>
</ol>
<p>Let's dive into how I built this on Windows.</p>
<h2>Part 1: The Mutator - UnrealScript TCP API Server</h2>
<h3>Setting Up the UT2004 Development Environment on Windows</h3>
<p>First things first - I needed to get UnrealScript compilation working on Windows. Here's what I did:</p>
<ol>
<li>
<p><strong>Located my UT2004 installation</strong> (usually <code>C:\Program Files (x86)\Unreal Tournament 2004\</code>)</p>
</li>
<li>
<p><strong>Created the mutator structure</strong>:</p>
<pre><code>UT2004\
└── UTAdmin\
    └── Classes\
        ├── UTAdmin.uc
        ├── UTAdminAPIServer.uc
        ├── UTAdminConnection.uc
        └── UTAdminMessage.uc</code></pre>
</li>
<li>
<p><strong>Modified <code>UT2004.ini</code></strong> to add the compile path:</p>
<pre><code class="language-ini">[Editor. EditorEngine]
EditPackages=UTAdmin</code></pre>
</li>
<li>
<p><strong>Compiled using the command prompt</strong>:</p>
<pre><code class="language-cmd">cd "C:\Program Files (x86)\Unreal Tournament 2004\System"
ucc. exe make</code></pre>
</li>
</ol>
<h3>Architecture:  The Mutator Design</h3>
<p>The mutator follows a clean three-class architecture:</p>
<h4>1. <strong>UTAdmin.uc</strong> - The Main Mutator</h4>
<p>This is the entry point.  It initializes when the server starts and spawns the API server:</p>
<pre><code class="language-unrealscript">class UTAdmin extends Mutator config(UTAdmin);

var() config string AdminPasscode;
var() config int AdminAPIPort;
var UTAdminAPIServer APIServer;

function PostBeginPlay()
{
    Super.PostBeginPlay();

    Log("UTAdmin:  Initializing...");

    if (AdminPasscode == "" || AdminPasscode == "changeme")
    {
        Log("UTAdmin: WARNING - AdminPasscode not configured or using default value! Set AdminPasscode in UTAdmin.ini");
    }

    if (AdminAPIPort &lt;= 0)
    {
        AdminAPIPort = 7778;
        SaveConfig();
        Log("UTAdmin: Using default port 7778");
    }

    APIServer = Spawn(class'UTAdminAPIServer');
    if (APIServer != None)
    {
        APIServer.Initialize(AdminPasscode, AdminAPIPort, self);
        Log("UTAdmin: API Server started on port" @ AdminAPIPort);
    }
    else
    {
        Log("UTAdmin: ERROR - Failed to spawn API Server!");
    }
}

defaultproperties
{
    FriendlyName="UT Admin API"
    Description="Exposes a clean REST-like API for external admin tools"
    GroupName="UTAdmin"
    AdminPasscode="changeme"
    AdminAPIPort=7778
    bAddToServerPackages=True
}</code></pre>
<p><strong>Key features:</strong></p>
<ul>
<li>Configuration via <code>UTAdmin.ini</code> file</li>
<li>Validation for security (checks for default password)</li>
<li>In-game configuration support through the host game menu</li>
<li>Clean initialization and teardown</li>
</ul>
<h4>2. <strong>UTAdminAPIServer.uc</strong> - The TCP Listener</h4>
<p>This class extends <code>TcpLink</code> and listens for incoming connections:</p>
<pre><code class="language-unrealscript">class UTAdminAPIServer extends TcpLink;

var string Passcode;
var int APIPort;
var array&lt;UTAdminConnection&gt; Connections;
var Mutator OwnerMutator;

function Initialize(string InPasscode, int InPort, Mutator InMutator)
{
    Passcode = InPasscode;
    APIPort = InPort;
    OwnerMutator = InMutator;

    LinkMode = MODE_Line;
    ReceiveMode = RMODE_Event;

    if (BindPort(APIPort, true) &gt; 0)
    {
        Listen();
        Log("UTAdminAPIServer:  Listening on port" @ APIPort);
    }
    else
    {
        Log("UTAdminAPIServer: ERROR - Failed to bind to port" @ APIPort);
    }
}

event GainedChild(Actor C)
{
    local UTAdminConnection NewConnection;

    Super.GainedChild(C);

    NewConnection = UTAdminConnection(C);
    if (NewConnection != None)
    {
        Connections[Connections.Length] = NewConnection;
        NewConnection.LinkMode = MODE_Line;
        NewConnection.ReceiveMode = RMODE_Event;
        NewConnection.SetPasscode(Passcode);
        NewConnection.SetOwnerMutator(OwnerMutator);
        Log("UTAdminAPIServer: New connection accepted, total connections:" @ Connections.Length);
    }
}

defaultproperties
{
    AcceptClass=class'UTAdminConnection'
}</code></pre>
<p><strong>The magic here:</strong></p>
<ul>
<li><code>BindPort()</code> opens a TCP socket on Windows</li>
<li><code>MODE_Line</code> tells UnrealScript to buffer incoming data line-by-line</li>
<li>Each new connection spawns a <code>UTAdminConnection</code> actor</li>
<li>The <code>GainedChild</code> event tracks active connections</li>
</ul>
<h4>3. <strong>UTAdminConnection.uc</strong> - The Command Processor</h4>
<p>This is where the real work happens.  Each client connection gets its own instance:</p>
<pre><code class="language-unrealscript">class UTAdminConnection extends TcpLink;

var string Passcode;
var bool bAuthenticated;
var string ReceiveBuffer;
var Mutator OwnerMutator;

event ReceivedLine(string Line)
{
    Line = Repl(Line, Chr(13), "");
    Line = Repl(Line, Chr(10), "");

    if (Len(Line) &gt; 0)
    {
        ProcessCommand(Line);
    }
}

function ProcessCommand(string Command)
{
    local string CommandType, Payload;
    local int SplitPos;

    Log("UTAdminConnection: Processing command: " $ Command);

    SplitPos = InStr(Command, " ");

    if (SplitPos != -1)
    {
        CommandType = Left(Command, SplitPos);
        Payload = Mid(Command, SplitPos + 1);
    }
    else
    {
        CommandType = Command;
        Payload = "";
    }

    CommandType = Caps(CommandType);

    if (CommandType == "AUTH")
    {
        HandleAuth(Payload);
    }
    else if (!bAuthenticated)
    {
        SendJSON("{\"error\": \"Not authenticated. Send AUTH &lt;passcode&gt;\"}");
    }
    else if (CommandType == "PING")
    {
        SendJSON("{\"status\": \"pong\"}");
    }
    else if (CommandType == "STATUS")
    {
        HandleStatus();
    }
    else if (CommandType == "PLAYERS")
    {
        HandlePlayers();
    }
    else if (CommandType == "KICK")
    {
        HandleKick(Payload);
    }
    else if (CommandType == "SAY")
    {
        HandleSay(Payload);
    }
    else if (CommandType == "CHANGEMAP")
    {
        HandleChangeMap(Payload);
    }
    else
    {
        SendJSON("{\"error\": \"Unknown command: " $ CommandType $ "\"}");
    }
}

function HandleAuth(string AttemptedPasscode)
{
    Log("UTAdminConnection: HandleAuth called. Attempted='" $ AttemptedPasscode $ "' Expected='" $ Passcode $ "'");

    if (AttemptedPasscode == Passcode)
    {
        bAuthenticated = true;
        SendJSON("{\"status\": \"authenticated\"}");
        Log("UTAdminConnection: Client authenticated successfully");
    }
    else
    {
        bAuthenticated = false;
        SendJSON("{\"error\": \"Invalid passcode\"}");
        Log("UTAdminConnection: Failed authentication attempt");
    }
}

function HandleStatus()
{
    local GameInfo GI;
    local string Response;

    GI = Level.Game;

    Response = "{";
    Response = Response $ "\"gametype\": \"" $ GI.GameName $ "\",";
    Response = Response $ "\"map\": \"" $ GetURLMap() $ "\",";
    Response = Response $ "\"players\": " $ GI.NumPlayers $ ",";
    Response = Response $ "\"maxplayers\": " $ GI.MaxPlayers $ ",";
    Response = Response $ "\"timelimit\": " $ GI.TimeLimit;
    Response = Response $ "}";

    SendJSON(Response);
}

function HandlePlayers()
{
    local Controller C;
    local PlayerController PC;
    local string Response;
    local bool bFirst;

    Response = "{\"players\": [";
    bFirst = true;

    for (C = Level. ControllerList; C != None; C = C.NextController)
    {
        PC = PlayerController(C);
        if (PC != None &amp;&amp; PC.PlayerReplicationInfo != None)
        {
            if (!bFirst)
            {
                Response = Response $ ",";
            }
            bFirst = false;

            Response = Response $ "{";
            Response = Response $ "\"name\": \"" $ EscapeJSON(PC.PlayerReplicationInfo.PlayerName) $ "\",";
            Response = Response $ "\"id\": " $ PC.PlayerReplicationInfo.PlayerID $ ",";
            Response = Response $ "\"score\": " $ PC.PlayerReplicationInfo.Score $ ",";
            Response = Response $ "\"ping\": " $ PC.PlayerReplicationInfo.Ping;
            Response = Response $ "}";
        }
    }

    Response = Response $ "]}";
    SendJSON(Response);
}

function HandleSay(string Message)
{
    local Controller C;
    local PlayerController PC;

    // Send message to all players - ClientMessage for HUD display
    for (C = Level. ControllerList; C != None; C = C.NextController)
    {
        PC = PlayerController(C);
        if (PC != None)
        {
            // ClientMessage displays on HUD
            PC.ClientMessage("[ADMIN] " $ Message, 'Say');
        }
    }

    Log("UTAdmin: Broadcasting message: " $ Message);
    SendJSON("{\"status\": \"Message broadcasted\"}");
}

function SendJSON(string JSON)
{
    SendText(JSON $ Chr(13) $ Chr(10));
}

function string EscapeJSON(string Input)
{
    Input = Repl(Input, "\\", "\\\\");
    Input = Repl(Input, "\"", "\\\"");
    return Input;
}

defaultproperties
{
    bAuthenticated=false
    LinkMode=MODE_Line
    ReceiveMode=RMODE_Event
}</code></pre>
<p><strong>Design decisions:</strong></p>
<ul>
<li><strong>Line-based protocol</strong>: Simple and easy to debug with telnet</li>
<li><strong>JSON responses</strong>: Even though UnrealScript doesn't have native JSON, I manually construct it (it works!)</li>
<li><strong>Authentication first</strong>: No commands work until you authenticate</li>
<li><strong>Direct game state access</strong>: Uses <code>Level.Game</code> and <code>Level.ControllerList</code> to query server state</li>
</ul>
<h3>The Protocol</h3>
<p>The protocol is beautifully simple - send a command, get a JSON response: </p>
<pre><code>CLIENT:  AUTH changeme
SERVER: {"status": "authenticated"}

CLIENT: STATUS
SERVER: {"gametype": "DeathMatch", "map": "DM-Rankin", "players": 4, "maxplayers": 16, "timelimit": 20}

CLIENT: PLAYERS
SERVER: {"players": [{"name":  "Player1", "id": 0, "score": 15, "ping": 45}, ... ]}

CLIENT: SAY Hello everyone! 
SERVER: {"status": "Message broadcasted"}

CLIENT: KICK 42
SERVER: {"status": "Player kicked"}

CLIENT: CHANGEMAP DM-Deck17
SERVER: {"status": "Changing map to DM-Deck17"}</code></pre>
<h3>Windows Firewall Configuration</h3>
<p>Don't forget to open the port in Windows Firewall!  On Windows 10/11:</p>
<pre><code class="language-powershell">New-NetFirewallRule -DisplayName "UT2004 Admin API" -Direction Inbound -LocalPort 7778 -Protocol TCP -Action Allow</code></pre>
<p>Or through the GUI:</p>
<ol>
<li>Windows Defender Firewall → Advanced settings</li>
<li>Inbound Rules → New Rule</li>
<li>Port → TCP → 7778</li>
<li>Allow the connection</li>
</ol>
<h2>Part 2: The Flutter App</h2>
<h3>Setting Up Flutter on Windows</h3>
<p>On my Windows development machine, I installed Flutter: </p>
<ol>
<li>Downloaded Flutter SDK from flutter.dev</li>
<li>Extracted to <code>C:\src\flutter</code></li>
<li>Added <code>C:\src\flutter\bin</code> to PATH</li>
<li>Ran <code>flutter doctor</code> to verify installation</li>
<li>Installed Android Studio for mobile development</li>
</ol>
<h3>Project Structure</h3>
<pre><code>FlutterApp/
├── lib/
│   ├── main.dart                    # App entry point
│   ├── utadmin_client.dart          # TCP client library
│   └── screens/
│       ├── connection_screen.dart   # Server connection UI
│       ├── dashboard_screen.dart    # Main dashboard
│       ├── players_screen.dart      # Player management
│       ├── broadcast_screen.dart    # Broadcast messages
│       ├── maps_screen.dart         # Map changing
│       └── status_screen.dart       # Detailed status
├── android/                         # Android-specific files
└── pubspec.yaml                     # Dependencies</code></pre>
<h3>The TCP Client Library</h3>
<p>The heart of the Flutter app is <code>utadmin_client.dart</code>, which handles all communication with the server:</p>
<pre><code class="language-dart">import 'dart:io';
import 'dart:convert';
import 'dart:async';

class UTAdminClient {
  final String host;
  final int port;
  final String passcode;

  Socket? _socket;
  StreamController&lt;String&gt;? _responseController;
  bool _isAuthenticated = false;
  bool _isConnected = false;

  UTAdminClient(this.host, this.port, this.passcode);

  bool get isConnected =&gt; _isConnected;
  bool get isAuthenticated =&gt; _isAuthenticated;

  Future&lt;bool&gt; connect() async {
    try {
      // Close existing connections first
      await _cleanupConnection();

      _responseController = StreamController&lt;String&gt;. broadcast();
      _socket = await Socket.connect(host, port, timeout: Duration(seconds: 5));
      _isConnected = true;

      _socket!.listen(
        (data) {
          final response = utf8.decode(data);
          final lines = response.split('\n');
          for (final line in lines) {
            if (line.trim().isNotEmpty &amp;&amp; _responseController != null) {
              _responseController!.add(line. trim());
            }
          }
        },
        onError:  (error) {
          _isConnected = false;
          _isAuthenticated = false;
        },
        onDone: () {
          _isConnected = false;
          _isAuthenticated = false;
        },
      );

      // Give socket time to establish
      await Future.delayed(Duration(milliseconds: 200));

      // Authenticate immediately
      await authenticate();
      return _isAuthenticated;
    } catch (e) {
      _isConnected = false;
      _isAuthenticated = false;
      await _cleanupConnection();
      return false;
    }
  }

  Future&lt;void&gt; authenticate() async {
    try {
      _sendCommand('AUTH $passcode');
      final response = await _readResponse(timeout: Duration(seconds: 5));
      final json = jsonDecode(response);
      _isAuthenticated = json['status'] == 'authenticated';
      if (! _isAuthenticated) {
        throw Exception('Authentication failed:  Invalid passcode');
      }
    } catch (e) {
      _isAuthenticated = false;
      throw Exception('Authentication failed: $e');
    }
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; getStatus() async {
    _sendCommand('STATUS');
    final response = await _readResponse();
    return jsonDecode(response);
  }

  Future&lt;List&lt;dynamic&gt;&gt; getPlayers() async {
    _sendCommand('PLAYERS');
    final response = await _readResponse();
    final json = jsonDecode(response);
    return json['players'] as List&lt;dynamic&gt;;
  }

  Future&lt;void&gt; say(String message) async {
    _sendCommand('SAY $message');
    await _readResponse();
  }

  Future&lt;void&gt; changeMap(String mapName) async {
    _sendCommand('CHANGEMAP $mapName');
    // Don't wait for response as server will disconnect
    await Future.delayed(Duration(milliseconds: 100));
    await disconnect();
  }

  Future&lt;bool&gt; reconnect({int maxAttempts = 10, int delaySeconds = 2}) async {
    // Ensure clean disconnect first
    await disconnect();

    // Wait before first attempt
    await Future.delayed(Duration(seconds: delaySeconds));

    // Try to reconnect multiple times
    for (int i = 0; i &lt; maxAttempts; i++) {
      try {
        final success = await connect();
        if (success &amp;&amp; _isAuthenticated) {
          return true;
        }
      } catch (e) {
        print('Reconnect attempt ${i + 1} failed: $e');
      }

      if (i &lt; maxAttempts - 1) {
        final waitTime = delaySeconds + (i * 1);
        await Future.delayed(Duration(seconds: waitTime. clamp(2, 5)));
      }
    }

    return false;
  }

  void _sendCommand(String command) {
    if (! _isConnected) {
      throw Exception('Not connected');
    }
    _socket!.write('$command\n');
  }

  Future&lt;String&gt; _readResponse({Duration timeout = const Duration(seconds: 2)}) async {
    if (_responseController == null) {
      throw Exception('Not connected');
    }

    try {
      final response = await _responseController!.stream.first. timeout(timeout);
      return response;
    } catch (e) {
      throw Exception('Timeout waiting for response');
    }
  }

  Future&lt;void&gt; disconnect() async {
    _isConnected = false;
    _isAuthenticated = false;
    await _cleanupConnection();
  }

  Future&lt;void&gt; _cleanupConnection() async {
    try {
      await _socket?.close();
    } catch (e) {
      // Ignore
    }
    try {
      await _responseController?.close();
    } catch (e) {
      // Ignore
    }
    _socket = null;
    _responseController = null;
  }
}</code></pre>
<p><strong>Key features:</strong></p>
<ul>
<li><strong>Async/await throughout</strong>: Clean async code using Dart futures</li>
<li><strong>Stream-based responses</strong>: Uses <code>StreamController</code> to handle incoming data</li>
<li><strong>Auto-reconnect logic</strong>: Handles map changes that kill the connection</li>
<li><strong>Connection cleanup</strong>: Properly closes sockets and streams</li>
</ul>
<h3>The UI:  Material Design 3</h3>
<p>The app uses Flutter's Material Design 3 with a dark theme and orange accent:</p>
<pre><code class="language-dart">import 'package:flutter/material. dart';
import 'screens/connection_screen.dart';

void main() =&gt; runApp(const UT2004AdminApp());

class UT2004AdminApp extends StatelessWidget {
  const UT2004AdminApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'UT2004 Admin',
      theme:  ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.orange,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const ConnectionScreen(),
    );
  }
}</code></pre>
<h3>Building the APK on Windows</h3>
<p>Building for Android on Windows is straightforward:</p>
<pre><code class="language-cmd">cd FlutterApp
flutter pub get
flutter build apk --release</code></pre>
<p>The APK ends up in <code>build\app\outputs\flutter-apk\app-release.apk</code>.</p>
<p><strong>Pro tip:</strong> If you get Android SDK errors, make sure you have: </p>
<ul>
<li>Android SDK 36 installed</li>
<li>NDK version 27.0.12077973</li>
<li>Updated <code>android\app\build.gradle. kts</code> with correct versions</li>
</ul>
<h2>Testing the Complete System on Windows</h2>
<h3>1. Start the UT2004 Server</h3>
<pre><code class="language-cmd">cd "C:\Program Files (x86)\Unreal Tournament 2004\System"
UT2004.exe DM-Rankin? Game=XGame.xDeathMatch? Mutator=UTAdmin. UTAdmin -server</code></pre>
<h3>2. Verify the API is Running</h3>
<p>Use PowerShell to test: </p>
<pre><code class="language-powershell">$client = New-Object System.Net.Sockets.TcpClient("localhost", 7778)
$stream = $client.GetStream()
$writer = New-Object System.IO. StreamWriter($stream)
$reader = New-Object System.IO. StreamReader($stream)

$writer.WriteLine("AUTH changeme")
$writer.Flush()
$reader.ReadLine()  # Should return:  {"status": "authenticated"}

$writer.WriteLine("STATUS")
$writer.Flush()
$reader.ReadLine()  # Should return server status JSON

$client.Close()</code></pre>
<h3>3. Deploy to Android Device</h3>
<p>With USB debugging enabled on your Android device:</p>
<pre><code class="language-cmd">adb install build\app\outputs\flutter-apk\app-release. apk</code></pre>
<p>Or connect via the Flutter app and enter: </p>
<ul>
<li><strong>IP</strong>: Your Windows machine's local IP (e.g., <code>192.168.1.100</code>)</li>
<li><strong>Port</strong>: <code>7778</code></li>
<li><strong>Password</strong>: <code>changeme</code> (or whatever you configured)</li>
</ul>
<h2>Challenges and Solutions</h2>
<h3>Challenge 1: UnrealScript's Limited String Handling</h3>
<p><strong>Problem:</strong> Building JSON responses with UnrealScript's primitive string operations was tedious.</p>
<p><strong>Solution:</strong> Created helper functions like <code>EscapeJSON()</code> and carefully constructed strings with proper escaping: </p>
<pre><code class="language-unrealscript">function string EscapeJSON(string Input)
{
    Input = Repl(Input, "\\", "\\\\");
    Input = Repl(Input, "\"", "\\\"");
    return Input;
}</code></pre>
<h3>Challenge 2: Map Changes Kill Connections</h3>
<p><strong>Problem:</strong> When you change maps, the server restarts and kills all TCP connections.</p>
<p><strong>Solution:</strong> Implemented automatic reconnection in the Flutter app with exponential backoff:</p>
<pre><code class="language-dart">Future&lt;bool&gt; reconnect({int maxAttempts = 10, int delaySeconds = 2}) async {
  await disconnect();
  await Future.delayed(Duration(seconds: delaySeconds));

  for (int i = 0; i &lt; maxAttempts; i++) {
    try {
      final success = await connect();
      if (success &amp;&amp; _isAuthenticated) {
        return true;
      }
    } catch (e) {
      print('Reconnect attempt ${i + 1} failed: $e');
    }

    if (i &lt; maxAttempts - 1) {
      final waitTime = delaySeconds + (i * 1);
      await Future.delayed(Duration(seconds: waitTime. clamp(2, 5)));
    }
  }

  return false;
}</code></pre>
<h3>Challenge 3: Windows Firewall Blocking Connections</h3>
<p><strong>Problem:</strong> Local testing worked, but remote devices couldn't connect.</p>
<p><strong>Solution:</strong> Configured Windows Firewall rules (see earlier section) and tested with both local and remote connections.</p>
<h3>Challenge 4: No Native JSON Parsing in UnrealScript</h3>
<p><strong>Problem:</strong> UnrealScript doesn't have JSON libraries. </p>
<p><strong>Solution:</strong> Manually construct JSON strings and rely on the Flutter app to do all parsing.  Simple protocol design meant this was actually pretty clean.</p>
<h2>Performance and Testing</h2>
<p>The system performs excellently:</p>
<ul>
<li><strong>Connection time</strong>: ~200ms on local network</li>
<li><strong>Command response</strong>: &lt;50ms for most commands</li>
<li><strong>Reconnection after map change</strong>: 5-10 seconds</li>
<li><strong>Memory footprint</strong>:  Negligible impact on UT2004 server</li>
<li><strong>Multiple connections</strong>: Tested with 5 simultaneous clients without issues</li>
</ul>
<h2>What's Next?</h2>
<p>Future improvements I'm considering:</p>
<ul>
<li><strong>HTTPS/TLS support</strong> (challenging in UnrealScript!)</li>
<li><strong>Multiple server management</strong> in the app</li>
<li><strong>Push notifications</strong> for server events</li>
<li><strong>Server logs viewer</strong></li>
<li><strong>Scheduled commands</strong> (automatic restarts, etc.)</li>
<li><strong>iOS build</strong> (already Flutter, just need to test)</li>
</ul>
<h2>Conclusion</h2>
<p>This project was a blast.  Bridging a 2004 game engine with 2026 mobile development taught me a lot about:</p>
<ul>
<li><strong>Protocol design</strong>:  Simple is better</li>
<li><strong>Connection management</strong>: Handle failures gracefully</li>
<li><strong>Cross-platform networking</strong>: TCP is universal</li>
<li><strong>Legacy code integration</strong>: Sometimes you have to work with what you've got</li>
</ul>
<p>The full source code is available on my GitHub at <a href="https://github.com/SarahRoseLives/UTAdmin">SarahRoseLives/UTAdmin</a>. Feel free to use it, modify it, or learn from it. If you're still running UT2004 servers in 2026, you're my kind of person.  🎮</p>
<p>Now I can kick troublemakers and change maps from my phone while I'm getting a snack. Technology is beautiful. </p>
<hr />
<p><strong>Tech Stack:</strong></p>
<ul>
<li>UnrealScript (Unreal Engine 2)</li>
<li>Flutter 3.0+ (Dart)</li>
<li>TCP/IP networking</li>
<li>JSON (manual construction in UnrealScript, native parsing in Dart)</li>
<li>Windows 10/11 development environment</li>
</ul>
<p><strong>Repository:</strong> <a href="https://github.com/SarahRoseLives/UTAdmin">github.com/SarahRoseLives/UTAdmin</a></p>
<p><strong>Platform:</strong> Windows (server and development), Android (app), iOS-ready</p>]]></description>
  </item>
  <item>
    <title>PocketDigi Update</title>
    <link>https://sarahsforge.dev/blog/pocketdigi-update</link>
    <guid isPermaLink="true">https://sarahsforge.dev/blog/pocketdigi-update</guid>
    <pubDate>Tue, 16 Dec 2025 23:31:35 +0000</pubDate>
    <dc:creator>SarahRose</dc:creator>
    <description><![CDATA[<p>Hey everyone,</p>
<p>Since launching PocketDigi, it’s been incredible watching you turn your Android phones and Bluetooth TNCs into portable APRS powerhouses in the field. We’ve seen setups at parks on the air (POTA), public service events, and field days.</p>
<p>But we also heard a consistent piece of feedback from the community: <em>"I want to run an iGate from my basement shack, but I can't get a GPS signal down here!"</em></p>
<p>Up until yesterday, PocketDigi had a hard requirement for a GPS lock. It needed your precise location to send out beacons and, more importantly, to authenticate with the APRS-IS servers for iGating. If your phone couldn't see the satellites, the app wouldn't fully function.</p>
<p>We realized that for a tool designed to be flexible, relying 100% on GPS was actually pretty limiting.</p>
<p>Today, we are thrilled to roll out a major update that solves this problem entirely.</p>
<h3>Introducing Manual Location Mode</h3>
<p>We have updated PocketDigi to allow operators to manually specify their location using the system hams already know best: <strong>Maidenhead Grid Squares.</strong></p>
<p>Whether you are operating from inside a concrete convention center, a dense urban canyon, or just your home shack where GPS reception is spotty, you can now get full functionality from the app.</p>
<h3>How It Works</h3>
<p>We’ve kept the interface clean and simple. When you open the updated app, look at the <strong>Operation</strong> card. You will now see a new toggle switch:</p>
<ol>
<li><strong>GPS Mode (Default):</strong> This works exactly the same as before. The app actively listens to your phone's GPS sensors to provide real-time, precise location data for mobile operations.</li>
<li><strong>Manual Mode:</strong> When you switch to this mode, PocketDigi stops listening to the GPS (which is a nice battery saver if you are stationary!).</li>
</ol>
<p>A new text field will appear below the toggle. Simply type in your Maidenhead locator—for example, <code>EM79</code> for a general location, or a 6-character grid like <code>FN31pr</code> for higher precision.</p>
<p>PocketDigi instantly calculates the lat/long coordinates for the center of that grid square and uses that "spoofed" location for all your beaconing and iGate traffic.</p>
<h3>Why This Matters</h3>
<p>This update significantly expands where and how you can use PocketDigi:</p>
<ul>
<li><strong>Indoor iGating:</strong> Set up a bi-directional iGate from your desk to help get local RF traffic onto the internet, even if you are indoors.</li>
<li><strong>Pre-Event Setup:</strong> Configure your station settings while inside the staging area before deploying to the field.</li>
<li><strong>Stationary Operation:</strong> If you are set up at a fixed location for the day, switch to manual mode to save your phone battery by turning off constant GPS polling.</li>
</ul>
<p>This update is available immediately. We hope this makes PocketDigi an even more valuable tool in your ham radio toolkit, regardless of where you are operating.</p>
<p>73,
Sarah Rose
AD8NT/K8SDR</p>]]></description>
  </item>
</channel>
</rss>
