Cover illustration

TheDaily Front

Issue No. #260926 Saturday, September 26 2026 #260926 — SATURDAY, SEPTEMBER 26, 2026
Agents redraw the workflow while old machines and old grievances refuse to stay buried.
Saturday, September 26, 2026 The Daily Front No. #260926 — Contents
30stories
6,345points
3,177comments
309kllm tokens
Assembled with 34 model calls — 221,110 tokens read, 87,648 written.

Highlights

Plan mode is dead

A builder who bet on AI planning tools argues that the plan has become a conversation rather than a separate phase.

Breaking Up with Google Play: Why Conversations Is Now Free

The developer of Conversations explains why the long-running Android messaging client is leaving Google Play and becoming free.

How I changed teaching after AI managed to do all my homework assignments

A professor rebuilds course assessment around live, in-person demonstrations after AI learns to finish the homework.

One Piece of Flock Camera Data Put This Innocent Woman in Jail for 13 Days

A wrongful arrest tied to license-plate camera data makes the costs of automated suspicion painfully concrete.

16GB iPod Nano 3G Upgrade

A formidable hardware-reversing project turns an old iPod Nano into a 16GB machine.

From the Editor

The machines are no longer waiting politely at the edge of the desk: they are writing code, grading homework, drawing diagrams, and demanding ever larger sandboxes. Elsewhere, the old civic troubles persist—privacy, platform tolls, and the small human cost of an automated mistake. A lively edition, in short, with one foot in tomorrow and the other caught in an escalator.

  1. Plan mode is dead3
  2. Fifteen years later, the Apple Cards origin story4
  3. Breaking Up with Google Play: Why Conversations Is Now Free5
  4. We're gonna need a lot more mathematicians6
  5. Modern Object Pascal Introduction for Programmers7
  6. The Murky History of Soviet-Born Tetris8
  7. How I changed teaching after AI managed to do all my homework assignments9
  8. Drawgent: Coding agent on a live Excalidraw canvas10
  9. A single function Jev-like wrapper for LLMs, including vision models11
  10. Floci: Locally emulating any cloud service12
  11. Fourier Analysis: Drawing Llamas with Circles13
  12. One Piece of Flock Camera Data Put This Innocent Woman in Jail for 13 Days14
  13. 16GB iPod Nano 3G Upgrade15
  14. Remembering Johannes Doerfert16
  15. Lab on a Contact Lens Can Measure Stress Through Serotonin17
  16. The Lost Atomic Update on Loongson CPU18
  17. I wrote a ray tracer in Brainfuck19
  18. LA Metro has some of the slowest escalators20
  19. Welcome to the Medical Clinic at the Interplanetary Relay Station21
  20. Generate fonts where every LLM token is the same width22
  21. Analyzing Frontier Model Progress with My Favourite Game: Prince of Persia23
  22. PipePipe: NewPipe hard fork implementing SponsorBlock24
  23. Jury finds Facebook liable for deceiving users in Cambridge Analytica case25
  24. Show HN: Reladraw – A diagram language where you decide where to place things26
  25. DeepSeek Elastic Compute (DSec)27
  26. Microsoft abandons personal AI chatbot race with Copilot reboot27
  27. How to keep enjoying programming in a world of LLMs28
  28. A searchable library of forgotten public-domain film clips from 1915 onward28
  29. Is your Postgres migration safe or not safe?28
  30. Show HN: Hacker Atlas - A map of what Hacker News talks about28
The Daily Front Page 2 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The End of the Plan
article

Plan mode is dead

by jmvldz·▲ 562 points·487 comments·aymannadeem.com ↗
Nuanced’s approach to planning failed.

Earlier this year, I believed planning was going to become the most important part of building software with AI.

My belief in this idea was so strong that I built and launched an entire desktop coding app around it. Nuanced was motivated by the observation that AI had radically increased the speed and volume of code generation, but interfaces required to support this new pace of working hadn’t caught up yet.

Model speed and code generation race ahead of a horse-drawn carriage labeled “Interfaces.”

Nuanced’s approach to planning failed, but it also revealed to me how plan modes more broadly aren’t as useful anymore. Historically, plan modes served two purposes: (1) they specified instructions that were sufficiently precise enough for an agent, and (2) they helped humans understand what they were building.

I think #1 is rapidly becoming obsolete as models get better. I think #2 matters more than ever, but plan modes are the wrong abstraction for it, especially as the number of parallel agents we run increases.

Why I built Nuanced

The product I was motivated to build ultimately answered a question I think is still relevant, and will always be relevant, which is: how do humans maintain a coherent mental model of a software system while machines are changing it faster than humans can inspect the changes?

Models could write thousands of lines of code in minutes, meaning you’d inherit a massive maintenance burden before even thinking through what you were building or why. This made it difficult to reason about behavior and debug incorrect assumptions that had prematurely been hardened into code.

An overwhelmed developer surrounded by agent-generated code, plans, diffs, and files, with questions about decisions, assumptions, and who changed what.

While the ease of generating code this way triggered a greater dopamine reward, it obfuscated the uncomfortable work of understanding why building something mattered, whether it mattered at all, and rigorously evaluating product, design, and infrastructure decisions. I would often have a product before consciously making any product decisions. If I under-specified the architecture, the agent would take the liberty of filling those gaps, even if the way it carved abstraction boundaries created problems for me later on. These misunderstandings about intended behavior and design would propagate through several files far beneath the surface of chat, and could be easily missed. Fishing around for problems inscrutable under this surface felt less efficient than having designed something correctly to begin with.

A developer fishing from a boat labeled “chat” above submerged code files, assumptions, product decisions, API boundaries, intended behavior, and hidden dependencies.

There was something about this experience that made me feel mentally disconnected and zombie-like, especially as coding apps like Conductor and Codex enabled the ability to run more agents in parallel. I felt I couldn’t really focus and access the same depth or understanding about what I was working on as I was able to in beforetimes. This also made it more difficult for me to verify whether generated results were correct.

There was no clear, interpretable trace showing the connection between user prompt → agent decision → code → product behavior. This didn’t mean I wanted to return to the olden days of looking at lines of code or at files. I actually felt that reasoning about ideas in natural language was easier and more efficient. I wanted to confidently sail above the code without having to sacrifice my understanding of how the system worked.

Existing plan modes didn’t feel collaborative enough

I felt that while plan modes existed, the right abstraction for planning well didn’t exist. As I rotated through the Claude Code CLI, to Conductor, and finally Codex when it launched, I found myself meticulously shaping and whittling plans without having a clear home to iterate on them. I would do this by using chat and then copying pieces of a plan into new messages to revise them (before Codex’s annotation features). This cut-and-paste workflow felt clunky and made it arduous to work through an idea while keeping track of the current plan.

I wanted to give my plans a home, ground them in my overall workflow, and take them from being ephemeral blobs of text that disappeared into the backscroll as conversations progressed, into a living, breathing, persistent document. I wanted to turn plan mode into a first-class primitive guiding development for a few reasons:

  • I needed to think about what to do.
  • I needed to make sure I described it precisely enough.
  • I needed to understand what had been done.
  • I needed to understand when things went wrong and why.

Building the dream

I thought about my dream workflow, and decided to bring it to life by encoding it into a product. Nuanced let you spin up threads, where each thread was a chat conversation. You would talk through what you wanted to build, and the system would surface ambiguities and decisions that required your input, and together you would arrive at a persistent plan before implementation began. Then, Nuanced would implement your plan, making sure that the generated code adhered to your requirements. I wanted an end-to-end pipeline that started with intent, carried through all the way to implementation, review, and verification. I looked at it more as a prosthesis for the human mind (or my ADHD mind) more than simply just another coding app, in that it was designed to instruct as much as it was to help me keep tabs on what was going on.

Why I was wrong

It’s not so much that my ideas regarding gaps in existing tools or how the SDLC was changing were incorrect, but that our implementation didn’t deliver the solution we expected it to. This is because:

  • I conflated planning with a plan
  • models got really good
  • no one wants to read AI-generated text
  • we separated planning from building in a way that was disruptive

Planning != Plan

The first thing we learned was that we had conflated planning with a plan. Those are actually not the same thing. My assumption was that having the space to think thoroughly about something before it was implemented was valuable. I also thought preserving that thinking in a large structured artifact would be valuable as the project evolved. But early users had surprisingly little appetite for that spec.

Models got really good

As models got better at understanding large codebases through context and memory, they excelled at exploring a repository and making reasonable assumptions. The need to explicitly instruct them to produce a well-thought out result had shrunk.

I hadn’t originally thought of model capability as being in competition with designing a better interface for human thinking, but in many ways, it was. This is because each decision the model could reliably make by itself was one fewer decision that needed to be surfaced.

AI-generated text is painful to read

The spec contained more information without creating more clarity. This is because our specs were long. They captured important decisions and contained a lot of context that seemed useful but the problem was: they were AI-generated. There’s something about the pacing and overly structured nature of AI-generated text that makes it very difficult to read. My eyes kept glazing over.

Instead of reacting to this discovery by killing the spec, we built a Spec Tour to solve this. We thought that rather than asking someone to absorb the whole document, Spec Tour could walk them through the important parts. But that only bolted on an additional layer of complexity, more text on my screen demanding attention. If we needed to generate a shorter representation of the spec to make the spec usable, what was the point of the full document in the first place?

We split up a process that needed to feel continuous

Our workflow was too linear and sequential. Our process looked something like this:

chat → disambiguate by answering questions → generate spec → review spec → revise spec → approve → implement → review code

A developer tumbles down a waterfall of sequential steps: chat, disambiguate by answering questions, generate spec, review spec, revise spec, approve, implement, and review code.

Real thinking doesn’t happen this way, and the separation between these parts felt artificial and forced. Usually you’ll understand a part of the problem, try something, the initial generation teaches you something new, which might make you change your mind about it and try something else. Each step exposes a new question. Planning and building are interleaved and emerge more organically than plan modes allow, especially how we built it in Nuanced. Our interface forced users to prematurely “finish thinking” so that they could start building. Once implementation began, returning to the earlier chat-based reasoning felt like moving backward in the workflow. There was no going back up the waterfall.

When you look at how Codex currently works, the boundary between planning and execution is disappearing as they collapse into one. Earlier on, coding agents benefited from the workflow where humans would do the following:

plan → approve → execute

Back then, the cost of going in the wrong direction was significantly higher. But as agents got better at understanding systems, they also got better at acting autonomously and testing their own work. They’re also good at revising their approach after inspecting the result. This enables a different loop:

understand → act → inspect → clarify → adjust → act again

There is still an enormous amount of planning happening inside that loop, but it doesn’t necessarily need to appear as a document called “the plan”. I think the biggest mistake I made was turning the plan into an artifact instead of designing a process for improved human understanding.

The old linear workflow moves from plan to approve to execute, leaving a confused human at a barrier. The new iterative workflow cycles through understand, act, inspect, clarify, adjust, and act again, with a happy human DJ at its center.

Plan mode and build modes were a strange separation

Nuanced had a plan mode and build mode, and users could start with either. Plan mode always produced a spec, while build mode wouldn’t force users into a spec and could be used for smaller tasks that didn’t justify a spec. But the separation between these modes was awkward and required the user to have to ask themselves the meta-question of whether a task deserved planning, and then remember to activate plan mode through a button or keyboard shortcut if it did. This felt like the kind of distinction AI should make for you based on context it already has, and remembering which mode you needed to be in felt like it introduced more cognitive load.

This realization made me feel like the midwit meme, in that the simplicity of a chat interface where planning was done as-needed rather than by default, was actually pretty great.

Midwit bell-curve meme: the characters at both ends say “chat threads are good,” while the character in the middle says “you need a new planning interface to really think.”

We still haven’t solved the understanding problem

Thinking through what to build, why it matters, and evaluating decisions remains important. People need to build a coherent mental model of what’s going on, but I don’t think a large body of AI-generated text is the right interface for that. Working through decisions in chat feels more intuitive, but keeping that understanding up to date as the system changes is still an open problem.

That problem gets harder as you go from five agents to hundreds. Keeping up can’t mean reading every conversation and trying to prompt an explanation for each code change. Agents need to identify the fewest places where human attention can have the greatest impact and surface enough context to make that attention useful.

We still haven’t solved how to help people stay oriented as hundreds of increasingly capable agents change a system at once. But I think the deeper problem, which is how humans understand systems, navigate complex information hierarchies and use powerful tools, is an enduring one. Interfaces keep changing alongside with the technology that powers them; the need to make complexity comprehensible doesn’t.

Humans and a robot mourn beside a gravestone labeled “Plan Mode, 2025–2026,” with flowers, a laptop, a checklist, and a memorial wreath.

The Daily Front Page 3 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Postcards from Cupertino
article

Fifteen years later, the Apple Cards origin story

by ksec·▲ 400 points·103 comments·lexontech.org ↗
100% cotton paper. 100% crazy.

100% cotton paper. 100% crazy.

In 2011, Apple released the Cards app. The app — which required iOS 5 — let you design custom letterpress cards, with your messages and photos printed upon them — which Apple would ship to your recipient on your behalf.

The Cards app icon: a cream note card and envelope with a fountain pen on a maroon background⤢

I recently learned the origin story of Cards, and that it was specifically a Steve Jobs product. It also wasn’t smooth sailing. But first, some backstory, and inserting myself into the narrative, because it’s my site and I can do what I want here.

I wrote about the app several times for Macworld. Apple complained first to me, and later to my editors, that I didn’t go deep on the fact that the cards Apple was offering were printed on 100 percent cotton paper. Apple thought my article should be updated to mention this. My editors and I disagreed.

I wasn’t the only person ever to get in trouble with Apple over Cards.

Two iPhone screenshots of a holiday card message being edited, with a Cut/Copy/Paste menu above the keyboard⤢

In 2019, someone wrote me this email:

I saw your old review of Apple Cards linked on Twitter, and it brought traumatic flashbacks.

I ran the print program for Apple’s production and fulfillment partner in those years. In my decade working with Apple and in fact my 30-year career, this project — code named Speed Racer and dreamed up by Steve Jobs himself — stands unsurpassed as a pinnacle of incompetence, project mismanagement and intercontinental mayhem. I hope to never top it.

It is probably best left dead, but if you felt like disinterring it, I’m a witness to the crime…

It took me seven years to reply. I don’t know why.

Although Apple launched cards in 2011, and it’s now 15 years later, this person — despite his confidence that any non-disclosure agreements have long since expired, his knowledge that the Cards app was sunset long ago, and the fact that the printing companies involved no longer really exist — still asked to remain anonymous, for fear of angering Apple.

I get it.

The prints of darkness

In 2006, Mike was hired at a printing company that already had a relationship with Apple, handling printing for iPhoto, which let you order custom books, calendars, and cards. The company had won that business because it was also, at the time, handling Apple’s in-box production of documentation — quick start guides, instruction manuals, and the like. (Remember those?)

For several years, this company worked as the exclusive Apple partner for those print products, after Apple had grown frustrated with a prior photo printing partner.

Order volume was massive, Mike told me on a Zoom, especially between Thanksgiving and Christmas when card and calendar volume exploded. The printing company had to spin up several sister companies to handle the load. They printed internationally, too; Mike, as essentially the program manager, also stood up print operations in the Czech Republic and Japan.

In 2010, Apple added a new product to the iPhoto print lineup — codenamed Downieville. Apple wanted fine letterpress printing on very toothy cotton stock. Your photo would be digitally printed on these preprinted shells.

This, Mike told me, was an enormous technical challenge. The printing company had to work with a shop in upstate New York that had two dozen restored Heidelberg Letterpresses from the 1850s.

The Steve Jobs origin story

In 2011, Mike said, “Steve Jobs was on one of his famous walks after having had a dinner with someone,” and he said to his walking companions from Apple, Mike was later told: “Wouldn’t it be great if I could just right now on my iPhone send [the dinner companion] a thank you card?”

Apple decided to build an iPhone app just for creating and mailing cards. And the project moved fast, because it was a Steve-initiated one. As it turns out, the app was built and launched during the final year of Steve’s life.

The project kicked off in the beginning of 2011, and it didn’t reuse the card designs that were in iPhoto. These were new designs and letterpress templates and shells. And Apple wanted to ensure that Cards could be printed and shipped in both the US and Europe.

And Apple would stamp and ship these cards for you. Which meant the printing company and their letterpress subcontracted company were printing and stamping and shipping these cards for you.

Well, I of course went to letterpress expert Glenn Fleishman. He clarified that these cards weren’t just printed. “They were heavily debossed by the letterpress contractor into that toothy paper. (Debossing is pushing down; embossing is pushing up.) Letterpress printing historically didn’t try to put much bite into printing, preferring what’s called a kiss impression—just perfectly laying the ink on top of the paper. Martha Stewart popularized debossing because, unlike real letterpress, it felt like letterpress. The real thing seems less genuine than the re-creation.” (Glenn knows a lot about this. He even has some old photos of the letterpress printing on these cards.)

The post with the most

Mike told me that Apple worked with the US Postal Service and the Czech Post on several aspects of Cards that were unique to Apple wanting things done its way:

For one, Apple was unwilling to have visible barcodes printed on the envelopes — but it wanted every step of the shipping and delivery of your card tracked. That wasn’t something the US Postal Service did. Apple and the printing company created an invisible barcode that was sprayed on the envelope, visible only under certain UV light, so that the envelope itself would remain unadulterated. And the USPS agreed to scan cards when sent, when processed at mail facilities, up to and including when they went out on a mail truck for delivery.

The lack of visible barcodes made what would typically be a robot-driven process an extremely human-powered one, Mike said. “We wanted to make sure your baby photos were never sent to someone else,” he told me. “It was not infallible. And when cards were mis-sent, it became an emergency-level event from Apple.”

Apple also wouldn’t let the printing company print postage. In fact, in the US, Apple worked with the postal service for something Mike believes had never been done before: a custom, Apple-designed stamp that all Apple Cards bore. It was a heart. You can see the design in this old Apple publicity photo.

A photo card of two girls on a beach reading “Wishing you were here in Hawaii,” with a stamped envelope⤢

The custom-designed Apple stamp

The Czech Post didn’t let Apple design a stamp, but Apple chose a specific stamp to use there.

The USPS had high hopes that Apple’s focus on this process would save the struggling entity the high volume of direct mail Apple was forecasting. That didn’t happen.

Mike describes the process of getting all the equipment set up — Bell + Howell sorting equipment, the custom barcode printing and scanning, the printing — as “mayhem” before launch. Apple was flying in engineers from China. Mike and some of his team slept in conference rooms. The Cards app’s code name was “Speed Racer,” and teams were definitely racing to get all the logistics ready for the October 2011 launch.

Even printing the cards themselves was enormously complicated. Getting the cotton Crane stock to work with the industry-grade HP Digital Indigo Press was a challenge: Ink wouldn’t adhere to it. Mike said the final printing process involved three passes: a pre-treatment to get a pre-coating on it, the letterpress shell design, and finally the digital imprint of your photo.

“Getting that to work was just painful,” Mike said. “And of course, Apple wouldn’t budge on it.”

Ready for launch

Apple insisted the printer be ready for hundreds of thousands of cards to ship on launch day. “I’d been doing their volume forecasting for the iPhoto print program,” Mike said. “So I knew their forecasting was always terrible.”

The day the app went live, Mike said, “The global demand for Cards could fit in a shoebox.” The Czech Post and the US Postal Service had trucks on site ready to go, and the stack of first-day orders could nearly fit in a glovebox.

“It grew gradually,” over time, Mike said, “but not significantly.”

Cards was announced on October 4, 2011. Steve Jobs died on October 5.

The Photos Create menu with Card highlighted, whose only submenu item is “App Store…”

“No one had the heart to pull the plug on the program” until September 2013, Mike said.

Throughout the process of ramping up the printing and shipping capabilities, and the actual deliveries of tens of thousands of cards, “Wow, I took some abuse,” Mike told me. “I got yelled at by [Apple] a lot on this project.”

You can order prints from within Apple Photos even today — but Apple’s barely involved. Now, the iPhoto successor’s Create menu option simply pushes you to the App Store. I guess Apple didn’t really cotton to printing partners longterm.

The Daily Front Page 4 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Breaking with the Gatekeeper
article

Breaking Up with Google Play: Why Conversations Is Now Free

by ezst·▲ 667 points·275 comments·gultsch.de ↗
Google doesn’t deserve me and my money anymore.

Conversations, my federated instant messaging client for Android, started out as many traditional open-source projects do: as an attempt to scratch my own itch. Development started in January 2014 in my student dormitory, and within weeks I started dogfooding and using the client as the primary means of communicating with my friends. However, when it came to releasing the app to the public on March 24, 2014—exactly twelve and a half years ago today—it was immediately clear to me that I would at least try to turn my open-source project into a business. While I didn’t invent the business model of making the source code publicly available but charging for the convenience of a compiled binary, it was certainly unusual in 2014.

Fast forward a decade, and I did manage to turn Conversations into a sustainable business. Ever since March 2014, Conversations—or other related activities—have been my primary source of income. Admittedly, sustaining life as a student in a tiny dormitory doesn’t take much, but luckily revenue has steadily increased as I grew older.

The exact sources of income have shifted over the years. In the beginning, it was a lot of paid development for companies that wanted to use Conversations. Some paid for features that made it into mainline Conversations; others wanted custom features so specific to their workflow that they never made sense to merge upstream. This was occasionally supplemented with providing server setup or even some consulting on instant messaging and security-related topics. Later on, grants and funding opportunities played a more and more important role.

One surprisingly steady source of income, however, has always been the Play Store revenue. I used to say that it pays my rent. Every freelancer knows the feeling of uncertainty that comes with only being able to send out invoices every few months or receiving payment for funded projects only at the end of the funding period. Any form of regular income—especially in the early stages, when you have not yet built up any savings—is a blessing.

Gross revenue of Conversations 2014-2026 (before Google’s fee and sales tax)

Gross revenue of Conversations 2014-2026 (before Google’s fee and sales tax)

My relationship with Google was never good. App updates have been rejected more times than I can count for incomprehensible reasons. Conversations has been removed twice from the Play Store. Once, Google just randomly accused me of uploading users’ contacts1—which simply wasn’t true and was also not triggered by a specific update. Countless times I wished I could just talk to an actual human for five minutes. So many misunderstandings could have been cleared up in no time if I wasn’t going up against AIs and click workers. At the time of writing this blog post, I’ve been waiting 14 days for Google to review an app update. Review times were never good or anything close to what I would deem acceptable, but they have been getting a lot worse over the last year or so. One can imagine that part of the problem is an avalanche of AI-generated slop apps—something Google played no small part in creating in the first place. But Google should have the responsibility to prioritize long-standing, non-AI-generated apps with infrequent updates. Waiting a little longer for new features might not sound like a big deal to some, but Google makes no distinction between feature updates and security updates. Delaying security updates by days or weeks is outright dangerous.

At this point, I should add some context. Google takes a 15% cut on my app sales. This effectively means I’m paying Google more than 1000 Euro per year for their services. 1000 Euro per year is 1.5x what I’m paying for my internet access. It’s roughly what I’m paying for my notebook per year if you assume I use it for three to four years. When my internet breaks, someone drives to my house and fixes it. When my notebook breaks, someone drives to my house and fixes it. When Google fucks up, there is absolutely nothing I can do. Apparently that amount of money doesn’t give me the privilege to talk to a fucking human for five minutes once per year.

For years I’ve felt like I was in a toxic relationship with Google, and the only reason I stayed was economic dependency.

Over time, the source of my income has shifted more and more towards grants. Sometimes via NLnet234, sometimes more directly from the European Commission5. I have secure funding via various grants until the end of 2029 and I’m fairly confident that other funding opportunities will come up for the time after that.

Conversations was always available on F-Droid, but in the beginning, I didn’t advertise the option of downloading it for free. Initially, the F-Droid package maintainers asked for my permission, knowing that Conversations was a paid app on Google Play. I didn’t refuse, but I also didn’t link to F-Droid from the official website because I wanted to steer users towards the paid version. Over time, as my sentiment toward Google shifted from bad to worse, I did start linking to F-Droid. Now F-Droid has become the primary method of distributing the app. The APK distributed over F-Droid is now built reproducibly and signed with my personal signing key.

Fortunately, I’m no longer economically dependent on Google Play Store revenue. Google doesn’t deserve me and my money anymore. I’m done. Fuck the gatekeepers.


  1. https://gultsch.social/@daniel/111929074071688694 ↩︎
  2. https://nlnet.nl/project/Conversations/ ↩︎
  3. https://nlnet.nl/project/Conversations-3.0/ ↩︎
  4. https://nlnet.nl/project/Conversations-OpenPGP-refresh/ ↩︎
  5. https://mobifree.org ↩︎
The Daily Front Page 5 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The Intellectual Reserve
article

We're gonna need a lot more mathematicians

by srcreigh·▲ 383 points·478 comments·terrytao.wordpress.com ↗
a “deployable intellectual reserve”

[This is a guest post by Amit Sahai. This blog post was initially written in a different file format and converted using AI. — T.]

When I was an undergraduate student, I remember talking with several students who felt that the pace at which the top students could understand new math concepts was far too fast for them. They, too, could understand the ideas, but it would take them much longer. Eventually, almost all of these students gave up their dream of pursuing research mathematics and found something else to do. I have been thinking about those students a lot in the last few days.

The research mathematics community consists largely of those of us who either rarely felt that way, or who felt it and managed to overcome it through hard work. We have had the good fortune to find a place in mathematics where we could make progress. But we are now entering a time for humility: a time when all of us are going to know what it feels like to be unable to keep up.

The AI systems I have worked with are already producing beautiful new ideas. They are doing far more than impressive calculations or quickly carrying out arguments that a strong human researcher would already understand. And we probably can’t even imagine the wonderful ideas that future systems will be capable of producing.

When we feel that we cannot keep up, will we take that as a reason to leave research mathematics, like the students I am remembering? As more of us experience this, there will undoubtedly be a temptation to draw the same conclusion as they did: If the machines can move so much faster than us, perhaps we should find something else to do.

For our community to give up the work of understanding would be a profound abdication of our responsibility to humanity. Each of us is entitled to choose a different life. The responsibility I am talking about belongs to us collectively: to build a future in which humans can understand and contribute to the discoveries that will change our world. A future with meaningful human agency.

Struggle is essential to understanding difficult concepts. Fortunately, this struggle can be shared. I have been blessed to experience this time and time again with my students and collaborators. Imagine a multitude of research groups, each with sustained support, each spending a term or a year trying to understand an extraordinary set of ideas produced by an AI system, with the help of AI systems. [1]

This may very well be among the most important mathematical work in the years to come, and we should support and prioritize it accordingly. This enterprise will require a significant expansion in the number of mathematically sophisticated human researchers available world-wide, as major breakthrough ideas accumulate.

Why should society want this? So far, this might sound like a utopian fantasy for us – a civilization focused on depth of human understanding, awash with mathematicians and physicists and the like. I would certainly love to live in such a world. And indeed there are deep philosophical reasons for society to move in this direction. But I think society has a much more immediate stake in making this possible, too.

Imagine that a future AI system proposes a radically new design for a one terawatt nuclear fusion power plant. It has found a way to sustain and control fusion that no human had conceived of. The design promises abundant, inexpensive, clean electricity. Robots stand ready to manufacture the components and build the plant.

A terawatt is an insane amount of electrical power. We would be deciding whether to construct a machine that handles extraordinary flows of energy using principles we have never conceived of, let alone put into practice. We would need to understand how failures can be contained, what happens to energy already stored in the system when it shuts down, how we can be sure that the materials that make up the power plant behave as expected, and what other questions we should ask before proceeding. The very novelty that makes the proposal exciting would mean that we cannot inherit confidence from decades of operating similar plants or experiments.

Before approving construction, I would want communities of humans to understand why the design works and what justifies confidence in its safety. I would hope that we all would.

Human involvement does not automatically improve a technical decision , and I see no reason to insist that humans manually repeat work an AI system might be able to perform more reliably, even including proving mathematical guarantees. But a theorem can only exist within a model. Understanding the guarantee means understanding the model, the experimental evidence for it, and our uncertainties about the accuracy of the model. This is demanding work, and mathematically sophisticated people must be available to engage with it.

One might respond that AI systems should handle those questions too, and ultimately decide whether the plant should be built. That is a serious position. But it asks us to accept a future in which decisions of enormous consequence rest on reasons that no human community understands.

I do not want us to arrive at that future simply because we failed to invest in our own capacity to understand. Human agency is a value of fundamental importance. We must retain the ability to meaningfully consider alternatives and decide what kind of world we want to be a part of building. I think it is worth the effort. [2]

To take on this responsibility, we may need to broaden our view of what a mathematician can contribute. I have in mind something like a “deployable intellectual reserve”: communities of mathematically sophisticated people that humanity can call upon to help understand consequential AI-enabled breakthroughs.

Our ability to understand difficult and unfamiliar ideas may become one of the most important contributions we can offer to society. We should be willing to bring that skill to problems far beyond our usual research interests. [3] Doing so asks us to expand our sense of our vocation.

A counter-argument might be that AI systems will make each of us so much more effective that fewer people could do this work, even as the pace of discovery accelerates. But each of us is merely human. We have fundamental limitations based on our biology. Depth of understanding needs time and a pace of life that humans can sustain. Each individual human can only be asked to do so much, but through earnest cooperation we can accomplish much more.

If AI fulfills its promise, we will encounter more beautiful and consequential ideas than we have ever seen. We must respond by building thriving human communities that can understand them together.

We’re gonna need a lot more mathematicians.

The ideas and opinions presented here are entirely my own, but GPT 6 Astra was instrumental in helping me draft this note. I also thank my former student Dakshita Khurana, my current student Isaac Hair, my colleague Terence Tao, and my family members Anant Sahai and Gireeja Ranade for valuable feedback. Note that there is much more to be said here, but I tried to keep this relatively short to focus succinctly on my primary thoughts.

Notes

[1] By this, I do not mean to imply that only AI-created results will be of interest in the future. But for major results generated by humans, we already have a tradition of spending extended periods of time studying them.

[2] And the relevant understanding cannot belong only to the organization proposing the technology. Imagine a public hearing at which the company’s experts are the only people capable of following the technical argument. Independent expertise is critical.

[3] Indeed, AI systems are likely to be very helpful in allowing researchers with diverse backgrounds to talk effectively with one another, and more generally understand unfamiliar concepts.

The Daily Front Page 6 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Pascal, Present Tense
article

Modern Object Pascal Introduction for Programmers

by birdculture·▲ 176 points·77 comments·castle-engine.io ↗
a consistent whole

1. Why this book

I wanted to describe the modern Object Pascal: programming language with classes, units, generics, interfaces and other modern features you expect. I wanted to show how all the language features, basic and advanced, connect together into a consistent whole.

I also wanted this book to be practical and concise to fellow developers. As such, I assume you already have some programming experience, and we can talk about things like "how to declare a variable" and avoid a lengthy explanation "what even is a variable and what is its purpose". When covering the basics, I will give a brief description, and then move on, like this: a variable is a container for some value; the container has a name; the value it holds may change over time.

I emphasize the word modern in modern Object Pascal. That’s because Pascal has evolved a lot, and it’s quite different from e.g. Turbo Pascal that many people learned in schools long time ago. Feature-wise, modern Pascal is quite similar to C++ or Java or C#.

  • It has all the modern features you expect — classes, units, interfaces, generics…​
  • It’s compiled to a fast, native code,
  • It’s very type safe,
  • High-level but can also be low-level if you need it to be.

For more reasoning about why use Pascal, see here.

We also have an active ecosystem of tools and libraries. To name just a few:

2. Basics

2.1. "Hello world" program

// Just use this line in all modern FPC sources.
{$ifdef FPC} {$mode objfpc}{$H+}{$J-} {$endif}

// Below is needed for console programs on Windows,
// otherwise (with Delphi) the default is GUI program without console.
{$ifdef MSWINDOWS} {$apptype CONSOLE} {$endif}

program MyProgram;
begin
  WriteLn('Hello world!');
end.

This is a complete program that you can compile and run.

  • If you use the command-line FPC, just create a new file myprogram.dpr and execute fpc myprogram.dpr.
  • If you use Lazarus, create a new project (menu "Project → New Project → Simple Program"). Paste this as the program source code. Compile using the menu item "Run → Compile" (or use shortcut Ctrl + F9).
  • If you use Delphi, also create a new project (menu "File → New → Console Application - Delphi"). Paste this as the program source code. Compile using the menu item "Project → Compile" (or use shortcut Ctrl + F9).

This is a command-line program, so just run the compiled executable from the command-line.

Note

You can also run it from Lazarus or Delphi IDE using the "Run" menu item (shortcut F9 in both IDEs). In this case, note that the console will appear and disappear quickly. The simplest way to avoid it is to add Readln (wait for Enter) at the end of the application.

The rest of this article talks about the Object Pascal language, so don’t expect to see anything more fancy than the command-line stuff. If you want to see something cool, just create a new GUI project in Lazarus ("Project → New Project → Application") or Delphi ("File → New → Multi-Device Application"). Voila — a working GUI application, cross-platform, with native look everywhere, using a comfortable visual component library.

The Pascal compilers come with lots of standard units for networking, GUI, database, file formats (XML, json, images…​), threading and everything else you may need. I already mentioned my cool Castle Game Engine earlier:)

2.2. Compilers and FPC "syntax modes"

This book, all the text and Pascal examples, has been written to support two modern Pascal compilers:

  1. Free Pascal Compiler (FPC), open-source Pascal compiler, used also by the Lazarus IDE.
  2. Delphi, a proprietary Pascal compiler from Embarcadero.

In this book, we support fully both compilers.

To complicate matters a bit, FPC compiler has multiple "syntax modes". In this book, we decided to show the ObjFpc syntax mode, which is recommended by the FPC developers and is the default for new Pascal projects created using Lazarus or Castle Game Engine. It’s a bit different from the Delphi syntax mode, which is most compatible with Pascal language as implemented by Delphi. We wrote a detailed comparison here.

But you don’t want to read about these differences now, if you’re just starting to learn Pascal!

The differences are minor, both between compilers and between FPC ObjFpc mode and Delphi mode. Just be aware you may see some {$ifdef FPC} …​ {$endif} clauses in the examples, that make the code valid for both FPC ObjFpc mode and Delphi. Using {$ifdef FPC_OBJFPC} …​ {$endif} in some of these cases would be more precise, but look even more complicated. If your project targets only one of these compilers, you can simplify your code, just pick the variant for your compiler and remove the {$ifdef …​}, {$endif} stuff.

2.3. Functions, procedures, primitive types

{$ifdef FPC} {$mode objfpc}{$H+}{$J-} {$endif}
{$ifdef MSWINDOWS} {$apptype CONSOLE} {$endif}

program MyProgram;

procedure MyProcedure(const A: Integer);
begin
  WriteLn('A + 10 is: ', A + 10);
end;

function MyFunction(const S: string): string;
begin
  Result := S + 'strings are automatically managed';
end;

var
  X: Single;
begin
  WriteLn(MyFunction('Note: '));
  MyProcedure(5);

  // Division using "/" always makes float result, use "div" for integer division
  X := 15 / 5;
  WriteLn('X is now: ', X); // scientific notation
  WriteLn('X is now: ', X:1:2); // 2 decimal places
end.

To return a value from a function, assign something to the magic Result variable. You can read and set the Result freely, just like a local variable.

function MyFunction(const S: string): string;
begin
  Result := S + 'something';
  Result := Result + ' something more!';
  Result := Result + ' and more!';
end;

You can also treat the function name (like MyFunction in example above) as the variable, to which you can assign. But I would discourage it in new code, as it looks "fishy" when used on the right side of the assignment expression. Just use Result always when you want to read or set the function result.

If you want to call the function itself recursively, you can of course do it. If you’re calling a parameter-less function recursively, be sure to specify the parenthesis () (even though in Pascal you can usually omit the parentheses for a parameter-less function), this makes a recursive call to a parameter-less function different from accessing this function’s current result. Like this:

function SumIntegersUntilZero: Integer;
var
  I: Integer;
begin
  ReadLn(I);
  Result := I;
  if I <> 0 then
    Result := Result + SumIntegersUntilZero();
end;

You can call Exit to end the execution of the procedure or function before it reaches the final end;. If you call parameter-less Exit in a function, it will return the last thing you set as Result. You can also use Exit(X) construct, to set the function result and exit now — this is just like return X construct in C-like languages.

function AddName(const ExistingNames, NewName: string): string;
begin
  if ExistingNames = '' then
    Exit(NewName);
  Result := ExistingNames + ', ' + NewName;
end;

Note that the function result can be discarded. Any function may be used just like a procedure. This makes sense if the function has some side effect (e.g. it modifies a global variable) besides calculating the result. For example:

var
  Count: Integer;
  MyCount: Integer;

function CountMe: Integer;
begin
  Inc(Count);
  Result := Count;
end;

begin
  Count := 10;
  CountMe; // the function result is discarded, but the function is executed, Count is now 11
  MyCount := CountMe; // use the result of the function, MyCount equals to Count which is now 12
end.

2.4. Testing (if)

Use if .. then or if .. then .. else to run some code when some condition is satisfied. Unlike in the C-like languages, in Pascal you don’t have to wrap the condition in parenthesis.

var
  A: Integer;
  B: boolean;
begin
  if A > 0 then
    DoSomething;

  if A > 0 then
  begin
    DoSomething;
    AndDoSomethingMore;
  end;

  if A > 10 then
    DoSomething
  else
    DoSomethingElse;

  // equivalent to above
  B := A > 10;
  if B then
    DoSomething
  else
    DoSomethingElse;
end;

The else is paired with the last if. So this works as you expect:

if A <> 0 then
  if B <> 0 then
    AIsNonzeroAndBToo
  else
    AIsNonzeroButBIsZero;

While the example with nested if above is correct, it is often better to place the nested if inside a begin …​ end block in such cases. This makes the code more obvious to the reader, and it will remain obvious even if you mess up the indentation. The improved version of the example is below. When you add or remove some else clause in the code below, it’s obvious to which condition it will apply (to the A test or the B test), so it’s less error-prone.

if A <> 0 then
begin
  if B <> 0 then
    AIsNonzeroAndBToo
  else
    AIsNonzeroButBIsZero;
end;

2.5. Logical, relational and bit-wise operators

The logical operators are called and, or, not, xor. Their meaning is probably obvious (search for "exclusive or" if you’re unsure what xor does:)). They take boolean arguments, and return a boolean. They can also act as bit-wise operators when both arguments are integer values, in which case they return an integer.

The relational (comparison) operators are =, <>, >, <, <=, >=. If you’re accustomed to C-like languages, note that in Pascal you compare two values (check are they equal) using a single equality character A = B (unlike in C where you use A == B). The special assignment operator in Pascal is :=.

The logical (or bit-wise) operators have a higher precedence than relational operators. You may need to use parenthesis around some expressions to have the desired order of the calculations.

For example this is a compilation error:

var
  A, B: Integer;
begin
  if A = 0 and B <> 0 then ... // INCORRECT example

The above fails to compile, because the compiler first wants to perform a bit-wise and in the middle of the expression: (0 and B). This is a bit-wise operation which returns an integer value. Then the compiler applies = operator which yields a boolean value A = (0 and B). And finally the "type mismatch" error is risen after trying to compare the boolean value A = (0 and B) and integer value 0.

This is correct:

var
  A, B: Integer;
begin
  if (A = 0) and (B <> 0) then ...

The short-circuit evaluation is used. Consider this expression:

if MyFunction(X) and MyOtherFunction(Y) then...
  • It’s guaranteed that MyFunction(X) will be evaluated first.

  • And if MyFunction(X) returns false, then the value of expression is known (the value of false and whatever is always false), and MyOtherFunction(Y) will not be executed at all.

  • Analogous rule is for or expression. There, if the expression is known to be true (because the 1st operand is true), the 2nd operand is not evaluated.

  • This is particularly useful when writing expressions like

    if (A <> nil) and A.IsValid then...
    

    This will work OK, even when A is nil. The keyword nil is a pointer equal to zero (when represented as a number). It is called a null pointer in many other programming languages.

The Daily Front Page 7 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Blocks Behind the Iron Curtain
article

The Murky History of Soviet-Born Tetris

by EA-3167·▲ 107 points·36 comments·thereader.mitpress.mit.edu ↗
a struggle over mathematical ideas, erased collaborators, offshore millions, and copyright power

Behind the beloved game lies a struggle over mathematical ideas, erased collaborators, offshore millions, and copyright power.

Opening screen of the Atari-Tengen Tetris port. The designer made sure to highlight the Soviet origins of the game.

Earlier this month, the Trump administration released a politically charged clone of Tetris called Build the Wall. The Tetris Company (TTC), which owns the rights to the iconic video game, swiftly responded with a thinly veiled legal threat, writing on X: “We take copyright infringement very seriously.” Four days later, the White House took down the game without explanation.

Whether TTC’s post prompted the removal is unknown. But the episode points to a broader and more dubious claim advanced by the company: that it holds exclusive rights to what it misleadingly refers to as “Tetris” pieces — and, more broadly, to any electronic puzzle that involves falling shapes. The claim relies on a flawed ruling rendered in 2012 in Tetris Holding, LLC v. Xio Interactive, Inc., a case in which a federal judge decreed that geometric forms made of four equal-sized squares, each joined together with at least one other square along an edge, were copyrighted and belonged to Russian engineer Alexei Pajitnov and game designer and entrepreneur Henk Rogers, collectively organized as TTC.

The problem: Pajitnov did not design these puzzle pieces, known as tetrominoes, and despite TTC’s relentless public-relations campaign to the contrary, they are not unique to Tetris. In fact, famed Caltech mathematician Solomon Golomb first described them in 1953 during a talk at the Harvard Mathematics Club, and they were later popularized in Scientific American.

The Tetris pieces were disclosed in the mathematics literature by Solomon Golomb as early as 1953, and subsequently were popularized, here in Scientific American in 1959: notice, in the left column of the right page, the five core tetrominoes: the straight tetromino, the square tetromino, the T-tetromino, and the flippable L-and skew tetrominoes. Source: Martin Gardner, “The Scientific American Book of Mathematical Puzzles & Diversions” (Simon & Schuster, 1959)

But TTC’s false claim to originality is only one aspect of the game’s dark and bizarre history.

Rewind to 1993. First Lady Hillary Clinton is pictured aboard an Air Force plane, absorbed in her Game Boy, playing Tetris. The black-and-white White House photograph doesn’t show the cartridge clearly enough to make out the details, but Clinton’s Staff Secretary, Robert Russo, confirmed to me that she was indeed playing the game.

Hillary Rodham Clinton plays Game Boy Tetris on her flight from Austin, Texas, to Washington, DC, on April 6, 1993. Photo credit: Ralph Alswang / William J. Clinton Presidential Library and Museum

The irony was great. The United States was boasting that it had just won the Cold War. A major instrument of that victory had been the spread of Western culture throughout the Eastern bloc through films, television, popular music, and other media. Yet the video game that had made it aboard the First Lady’s plane, and into a vast number of U.S. and Western-European households, was not an American product, but a Soviet one. What’s more, it had been designed and coded in a state artificial-intelligence research lab tightly controlled by the KGB, the dreaded secret police of the Russian dictatorship, at a time when U.S. President Ronald Reagan referred to the Soviet Union as the “evil empire.” The story of the commercial success of Tetris is the poster child for two systems radically opposed in history, culture, economics, and politics. In fact, it arises out of this opposition.

Ironically, Tetris’s success began because the totalitarian Soviet regime did not recognize what we take for granted in the West: the freedom to publish. After Pajitnov adapted Golomb’s geometric discoveries for the nascent digital world, he had no choice but to distribute his game as a samizdat, a self-published underground cultural good passed on the sly — except that rather than being mimeographed on paper like its illustrious sibling, Aleksandr Solzhenitsyn’s “The Gulag Archipelago,” Pajitnov’s samizdat got passed around on floppy disks.

The game rapidly made its way through the Eastern bloc before reaching Hungary, where British software entrepreneur Robert Stein recognized its commercial potential. From there, its licensing history became notoriously tangled. Rights to various versions passed, under sometimes competing claims, through the Robert Maxwell group, Atari, Sega, and finally to Nintendo, which packaged Tetris as the Game Boy’s “killer app.” By then, the USSR had taken control of the game through its technology export agency, ELORG, run by a stern bureaucrat, Nikolai Belikov. The micro-computers, Nintendo, and coin-op versions sold in the West filled the Communist Party’s coffers handsomely.

Screenshot of a 2019 emulated version of Electronika 60 Tetris (the original version). All of the gameplay, as in the original, is rendered using text symbols such as periods and brackets. Source: Old Classic Retro Gaming, 2019. Note that there exist other emulations. In particular, in 2024, Digital Eclipse released Tetris Forever, under license from The Tetris Company. The above screenshot is not from that release.

And then, the Berlin Wall collapsed. The floodgates of capitalism — and, in Russia’s case, mayhem — opened. At the game of perestroika (Gorbachev) and privatization (Yeltsin), the rule of law and Russia lost. Out of the resulting confusion emerged two competing claims to ownership of Tetris: one advanced by Patjinov and the other by Nikolai Belikov.

Despite the fact that the USSR generally did not recognize private property, or the copyrightability of software for that matter, or the fact that he had designed the game on his work time using his work computer, Pajitnov, who by then had moved to the U.S., cast himself as the victim and claimed that the game’s copyright had been his all along. The appeal to pity was powerful and should not have survived reasonable legal scrutiny, but thanks to a few sympathetic judges, it did.

In 1988, U.S. company Spectrum HoloByte capitalized on Cold War tensions to market the game.

Meanwhile, the nomenklatura man in charge of exporting the game on behalf of the USSR pulled a rather bold trick: He created a corporation in Delaware, also conveniently named ELORG, and, as head of Soviet ELORG, assigned the Tetris rights to that American shell. This was most likely done through a backroom deal with Yeltsin’s people and the KGB, although specific details have not been documented.

What has been documented is that in exchange, Belikov owed Soviet generals and colonels, now organized as a mafia entangled with the nascent Russian state, 50 percent of the Tetris proceeds. In grand oligarch fashion, Belikov then crossed the Russians and hid the proceeds through a flurry of shell corporations and accounts in the U.S., Africa, the Grand Cayman Islands, and the Bahamas. Lest this sound too much like a spy movie to be true, years of litigation produced Belikov’s own testimony, along with detailed charts of the opaque setup.

A chart prepared as part of a litigation document. Notice that ELORG, the Delaware company, is owned by an opaque Cayman Islands trust, itself controlled by Belikov. Source: Nikolay Belikov, Techno-TM Zao v. Maryann Huhs and Roy Huhs, R-Amtech International, Inc., Techno-TM LLC, Suncadia Properties LLC, 12-2-23972-0 SEA, Superior Court of the State of Washington, County of King (2014).

After the Soviet Union collapsed, Belikov and Patjinov — who by then had teamed up with Nintendo’s fixer Henk Rogers — sued each other over the rights to the game in federal court in New York. Pajitnov had very little going for him. The USSR did not recognize software copyright, or any private property for that matter other than for household pots and pans. Further, he developed the game on his work computer during work hours, which under U.S. legal principles would make it his employer’s property.

Belikov, for his part, claimed that ELORG, a Soviet state agency, had miraculously morphed into the Delaware company he had formed, and that he therefore owned the rights. When the new Russian state, entangled in its own domestic mess, failed to react, Pajitnov, Rogers, and Belikov settled. The pair of friends would get the rights, while the bureaucrat received $15 million, which he quickly made disappear from his homeland’s sight through an elaborate web of offshore corporate shells and a Panamanian passport he bought for $200,000 for the occasion.

Pajitnov and Rogers also proceeded to erase anything that would come in the way of Pajitnov’s miraculous claim of original exclusive ownership. That included a young Soviet programmer named Vadim Gerasimov. In 1985, Gerasimov worked closely with Pajitnov to develop the game and single-handedly designed and coded the MS-DOS port (the original game had been coded by Pajitnov on an arcane Russian minicomputer, the Electronika 60). Although Pajitnov had originally credited Gerasimov for his work (including in an under-oath testimony in U.S. court), he later testified (also under oath) that he was the sole author. Rogers played his part in supporting his friend and changed the credits on the console port he developed for the Nintendo Famicom to erase any mention of Gerasimov. The before-and-after screenshots of the credits are telling.

Pajitnov (seated) and Gerasimov, with Gerasimov’s Version 3.12 of Tetris for MS-DOS on the screen. Source: Vadim Gerasimov

Copyright notice of the first Japanese Famicom release. Gerasimov is credited for the Original Design and Program. Photo credit: Julien Mailland

The copyright notice for the Japanese Famicom version was subsequently changed. Gerasimov was erased. Photo credit: Julien Mailland

By the mid-1990s, Pajitnov and Rogers seemed to have a firm grip on the Tetris intellectual property. But the World Wide Web changed the picture. The Web enabled a flurry of Tetris derivatives to flourish and be widely distributed as freeware. TTC would have none of it and aggressively unleashed its army of lawyers after what were often teenage amateur creators. It was ironic, since the early success of Tetris happened by virtue of it being distributed as freeware. TTC didn’t hesitate to overreach, going after any game that had falling shapes, such as Alphatris, which involves single-square blocks featuring letters. In these post-perestroika days, a symbol of the socialist republic had become a symbol of capitalist copywrongs.

TTC went after games that had very little to do with Tetris, such as Alphatris, which involves single-square blocks featuring letters. Source: Screenshot from https://alphatris.software.informer.com.

TTC went one step further, falsely claiming in a court filing that the “seven Tetrimino playing pieces made up of four equally sized square [sic] joined at their sides,” and “the visual delineation of individual blocks that comprise each Tetrimino piece and the display of their borders” are “original and distinctive” and “were designed in the 1980s by Alexey Pajitnov.” As we saw, however, the tetrominoes are elementary geometric forms that Golomb had identified and described in the 1950s. And although U.S. copyright law does not protect geometric forms, the Xio court fell for TTC’s bait and effectively extended copyright protection to the tetrominoes themselves. Which is doubly flawed because if such shapes were copyrightable, then Golomb would be the copyright holder, and Pajitnov should have been found to be infringing!

Tetris is a story of contrasts. Contrasts between two systems: the oppressive Soviet communist dictatorship and the Western free-market capitalist economy, and the story of what happens when the two meet. Contrasts between the official corporate story and a more nuanced reality, which shows a darker side to the Tetris story: a pattern of internally contradictory statements by Pajitnov; blatant misstatements of facts, some of which were made under oath; a systematic erasure by TTC of important facts and characters; and the monopolization through copyright law of geometrical forms and mathematical concepts that should have remained in the public domain.

But Tetris is also a story of ambiguities caused by the murkiness of changing times in the East — first with Mikhail Gorbachev’s perestroika and then amid the rise of oligarchs under Boris Yeltsin, from which Belikov benefited handsomely before fleeing and disappearing with his Tetris millions. In sum, Tetris is a story of tumultuous times.

The Daily Front Page 8 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — After Take-Home Work
article

How I changed teaching after AI managed to do all my homework assignments

No longer test understanding with anything that is done at home

Around 2021, well before ChatGPT launched, Vincent Hellendoorn suggested I try GPT-3 on the reading quizzes in my course. It produced convincing answers passing our rubric without actually seeing the assigned paper. At the time, I changed nothing. Five years later, AI agents could do all my assignments and I have redesigned most assessments in that course, even though what I want students to learn has barely changed. The strategy is always the same: No longer test understanding with anything that is done at home and instead focus on interactions with a TA, on exam, and on a video demo. Some of these changes violate evidence-based best pedagogy practices, and I made them anyway.

For the last couple of years, I have mostly taught the course Machine Learning in Production, an upper-level course on building production-ready software around ML models with a heavy focus on MLOps, usually with 100 to 170 students. These days one common question when talking to other educators is how we have changed teaching in the age of generative AI and coding agents, so let me outline what we did.

We have shifted covered topics with changing AI innovations and tools, but I barely touched the overall learning goals. I am fortunate that this is not an intro course and that the learning goals are not about writing code or using specific tools; they are about engineering tradeoffs, anticipating and mitigating risks, and teamwork. I think these are skills still worth acquiring, even if some can be simulated and offloaded to a model. (Revising an intro course or a traditional software engineering course likely would shift learning goals much more.)

Also possibly important: We give students permission to use AI in all settings, in any form, without attribution, except for written and oral exams. We even encourage the use of AI tools in many places. I do not think policing AI is feasible even if we wanted, and more importantly I do think that students need to learn responsible use of these technologies anyway.

AI is forcing me to abandon evidence-based best practices

Let’s start with this point upfront, since it is more important than what we actually do: Unfortunately, AI is actively undermining several evidence-based teaching practices (e.g., see How Learning Works and The ABCs of How We Learn). For example, the evidence favors frequent low-stakes assessments with feedback (e.g., homework, quizzes) over few high-stakes ones (e.g., exams) – but AI is undermining practice in low-stakes settings and pushing us more toward exams.

Similarly, I always provided a safety net where students can make mistakes and resubmit a limited number of assignments to regain lost points (a core recommendation of specifications grading and grading for equity to focus on learning outcomes, not the process), but we felt that this process was abused with AI: first submit a generated assignment solution without thinking and only look at the issues raised in grading for a resubmission (the typical story of externalizing the cost of AI use). In response, we have since taxed resubmissions with a 10% penalty.

Also in-class interactions allow engaging with materials in an early low-stakes setting, but with AI I have seen many student groups offload the discussion questions to a model. Pen-and-paper submissions could fix this, but aside from a higher grading workload, it would also raise stress for students, take away from the low-stakes environment, and delay feedback.

In general, this is a balancing act and I tend to err on the side of keeping low-stakes repeated interactions even though it can be abused. Yes, some students will get through the class without much deep learning, but it provides a better environment for those students who want to learn. I don’t want to get back to the model I’ve experienced during my own studies in Germany with mostly optional homework and a single exam at the end of the semester that was responsible for 100% of the grade in the course. This was nice for students who were self-motivated and good at learning for an exam (like me, I guess), but had failure and drop-out rates of 50 to 80%.

Written reflections → 15-minute conversations

Now for actual changes in the course: I have given up all parts of assignments that required a written text answer. I still ask for reports that describe a solution and link to the relevant code fragments, but that’s just for navigating their solution and I’m fine with receiving AI generated documents for that. In contrast, reflection documents, like “What were challenging parts?”, “How would you improve teamwork?” or in reading quizzes “For scenario X, identify one plausible data quality problem you might expect that relates to one of the four data cascades discussed in the paper…” have become pointless and can be entirely delegated. Short of hiding the evaluation rubric, I can see no way of stating what I expect in a good answer that cannot be completely offloaded to an LLM.

For written reflections, which I used to have as a part of pretty much every assignment, I now shifted to in-person interactions with a TA. After every assignment, each student needs to schedule a 15-minute meeting with a TA to answer a couple of questions in a live conversation (apparently Stanford cs221 is evaluating the same kind of approach in a controlled experiment this semester). I still share the reflection prompts in the assignment as examples of the kind of questions we ask. Students can still generate an initial answer with an LLM, but they may need to memorize parts of it, and we try to challenge them with follow up questions. The check-in meetings are part of the assignment and currently worth 20% of the assignment points, graded pass/fail. Students can try again if they fail and I encourage my TAs to have fairly high standards – we usually fail quite a few students on their first attempt.

There are drawbacks to this design, but overall I am happy with the tradeoffs: Penalty-free retries reduce fairness concerns about TA grading of oral interactions; a more strict TA costs a student time, but not points. Oral check-ins demand more from students with anxiety, but so do written exams, and formal disability accommodations can provide a path in both cases. In fact, professional communication about technical work is a learning goal and oral check-ins train this more than written reflections. Regarding scale: We run the course at a 20:1 student-TA ratio with about 10h of work per TAs per week (fortunate, I know), so the check-ins amount to roughly 300 minutes per TA every two weeks, which is workable.

For reading quizzes, I just gave up. I did not think doing in-person pen-and-paper quizzes in class would be worth the stress and the needless memorization work that those would be causing. I actually kept online reading quizzes around for a long time just to signal that I wanted students to look at the paper, fully understanding that most would just ask an LLM. These days, I still assign readings, but only half as many and without any points attached. Instead, I try to integrate lessons from the readings into in-class discussions. Still most students do not do the readings and just ask an LLM when we get to that point in the class (so nothing changed on that front), but those that do might get more out of it.

Minor note: We observed that some students used AI during live discussions over Zoom (e.g. Cluely) and we will likely only offer in-person checkins in the future in response.

For coding tasks: code + videos + in-person knowledge checks

We have weekly labs that are low-stakes small tasks to explore new tools (e.g., Kafka, Grafana, Docker, Weights and Biases). These tasks are necessarily scoped small and need to provide some help to students starting out – so they are obviously easily automateable by coding agents. We again rely on in-person check-ins – show the TA evidence that you completed the task and be able to answer a few questions, graded pass/fail. Again, we frequently send students back to read more documentation (or let their chatbot summarize the relevant part) and let them try again without a penalty until the time of the lab session runs out.

For assignments, we use the same check-in with a TA discussed for reflections to let them explain part of their technical solution. In teamwork, we have longer debriefing sessions after each milestone (30-60 min per team). We award “beyond-the-comfort-zone” bonus points to a team if the TA can ask any team member to explain any part of the implementation. (Yes, I know, bonus points are a scam. I use them anyway. Sue me.)

Also having students produce a short video demoing a feature they implemented worked really well for an assignment to extend a web application, because it required the feature to actually work with a user interface in a real workflow. I think producing videos for other parts could also work, as long as it’s not just reading an AI-produced script, but actually grounded in some technical work.

More of the grade now depends on what happens in the classroom

As I see in many other courses, we also shift more points from activities done at home (e.g., homework) to activities done in the classroom (e.g., exams, participation). Exams are now worth 25% instead of 15%, and I suspect I will raise this further in the future. The debriefing is 10 to 20% of the homework and group work grade. Still the majority of points are associated with homework and group work done at home and most students get full or nearly full credit, but the main grade differentiation now comes from exam grades.

I have not yet introduced graded in-class pen-and-paper quizzes that many other instructors now use, but it is an option. I prefer debriefing with a TA for now.

Freeing up TA time with AI grading of unsupervised work

As students can more easily produce large amounts of code and lengthy text documents, traditional manual grading has become more tedious (the typical asymmetry of lower production costs without lowering manual review costs). The turning point for me was a year ago, when a TA shared how he felt silly grading a solution where the commit message included “Authored by Claude Code.”

We have since built infrastructure to autograde code and written reports of homework with LLMs (institutionally approved LLMs). Autograding follows a relatively straightforward LLM-as-a-judge approach, where an LLM is prompted with a specific rubric item and instructions and parts of the solution (e.g., code diff, reports). To make autograding easier, I now ask for solutions across multiple markdown files in the solution repositories rather than a single PDF. The autograder makes a judgment as either “pass” or “needs review” with comments for the TAs. We usually spot-check a few “pass” grades (almost never finding issues), but TAs spend most of their time just with the “needs review” answers (many of which actually pass). The LLM-generated notes also speeds up the manual review process as it can provide meaningful context. In the end, TAs spend 50 to 80% less time grading (grading 80% less content) and spend more time interacting with students face to face – which they also prefer. At the same time, we still never deduct points without a human having reviewed that answer. Note that we grade against clear pass/fail criteria based on the concept of specifications grading, which works well for this kind of assessment, but also gives the students’ AI agents very clear instructions on how to do their homework for them.

We now use the same LLM-as-a-judge approach to provide feedback to in-class discussions. We have breakout sessions (think-pair-share style) in every lecture and ask students to post their answers to a shared Slack channel before we talk about them. At the scale of the class with 100 to 170 students, I rarely had time to give individual feedback, but now I’ve automated that too. A custom Slack bot takes their answers, runs them through an LLM-as-a-judge check against a number of criteria. We usually covered the criteria underlying the checks in the lecture content, though we do not share the specific checks with students. With a second prompt, we then turn the check results into hopefully constructive feedback that is posted as a Slack response, encouraging students to revise their answer, after which we provide another round of feedback the same way. The prompts and one example of how this looks can be found in this gist. This feedback generator is also available to students later through a web or Slack interface to try different answers as they prepare for the exams. I’m also thinking about making the feedback available already during their breakout discussions to push them to think about their answers more deeply before they post them.

Usually, both autograding and automated discussion feedback requires some calibration, often tweaking the prompts to make the model less sensitive or to look for specific problems. Usually an AI agent is helpful in creating the context and prompts for the checks from slides in the first place and to come up with common problems and corresponding checks given a couple of solutions.

Bigger assignments, as agents made the old, scoped ones trivial

Given that students spend less time manually reading code, learning libraries, and writing code, we can scale the scope of the work. Usually, we try to scale it to the point where current AI agents cannot solve a task without more hands-on guidance and feedback.

For example, our first homework assignment that was intended to screen for existing coding skills was to extend an Instagram-clone with two AI-powered features. The provided starter code albumy was a relatively small (12k LOC), clean implementation for a textbook example. While the correct solution could be implemented in approximately 20 lines of code, the challenge used to be understanding the code, finding the right libraries, solving a dependency incompatibility issue, and integrating everything. The assignment was intentionally scoped to make it feasible to extend a web application even with little prior knowledge of HTML and Flask. This became trivial: In the fall of 2025, Claude Code could solve the entire assignment, including the writeup and reflection, without any interaction, simply by pointing it to the assignment text.

We replaced that assignment with a similar task to implement two AI-powered features, but now in Zulip, a production-quality team chat application with a large code base (> 500k LOC). Current coding agents can work with this code base, but require interaction to solve the task correctly. The required features are also bigger, require more backend and front-end changes, usually with several hundred lines of code. Without coding agents, this task would likely only be feasible for experienced web developers in the allocated time. The revised assignment has a similar function of demonstrating coding (or code generation) proficiency and to shift the student’s attention from model benchmarking in traditional ML courses to building product features in my course, but at a much larger and more realistic scale.

Similarly, we raised requirements in other assignments. Where previously we asked students to perform hazard analysis manually, we now ask them to build automation for the process, run it at some scale, and then curate relevant results. We add additional requirements to the group project too.

Note that we have to assume that students are using AI coding agents for their work and that they are somewhat competent at it. How to use AI coding agents effectively is not a learning goal and not something we cover in this course (deliberate decision), though we help students on demand in office hours. This mirrors how I treated programming skills before AI: I never taught Python or HTML in this course either, but I assumed that students have basic programming skills and can pick up new languages, libraries, and tools on their own; the first assignment screened exactly for that. At this point, proficiency with AI coding tools is simply the new baseline, and other courses like 17-214 can teach it properly (and pretty much everybody is exposed to these tools or actively using them already anyway). I am aware that students may need to subscribe to an AI provider and that this can create equity issues. I do not believe that suggesting using low-cost open-weights models would be a fair alternative. Instead, I usually argue that subscription costs are actually lower than textbook costs: A textbook easily costs $100 these days (and publishers are busy destroying the second-hand market), compared to $20/month for 3 months of a semester. For a while AI companies have been generous with student credits, but that time is mostly over. The textbook analogy is not perfect, but AI costs are nearly negligible compared to the cost of attending CMU in the first place. In the end, I think it is the responsibility of the university or other institutions to provide the support infrastructure (e.g., through scholarships) to provide students with equitable access, just as they do for textbooks. We now mention the expectation to use a commercial subscription in our syllabus.

Letting students get burned by confident-wrong AI hopefully calibrates trust

In an ideal world, I would like to always design parts of an assignment that AI agents get confidently wrong, just to teach students to not over-rely on these tools. Finding that 80% of the class lost points on a simple question because they didn’t check their generated answers is a great learning experience – similar to phishing-attack training (i.e., sending fake phishing emails to see whether employees fall for them). The point is to raise awareness of automation bias and calibrate trust down to a more appropriate level.

In practice, I have not succeeded designing assignments explicitly for likely AI mistakes, but we have now found multiple such cases by accident:

  • ChatGPT was confidently wrong about where hardware fits in Jackson’s distinction of the World vs the Machine (even when providing the full paper). About 20% of students used to make that mistake before ChatGPT, then it was 80% after, and even telling students explicitly upfront about ChatGPT making this mistake reduced it only to 50%.
  • Claude Code is remarkably insistent on really bad solutions to an agent security problem (how to make sure that the agent always confirms an MCP action with a financial transaction before executing it), producing a fancy-sounding solution (“two-phase confirmation protocol”) that is completely insecure when thinking about it adversarially. Again 80% of students fell for this mistake initially – including most of my TAs who I had to ask to regrade the entire assignment. More recent models make this mistake less, so unfortunately students no longer fall for it reliably.

Debriefing after such a failure is a good learning opportunity to talk about automation bias and different forms of human oversight (and how this is really hard in practice). The course has a safety net with resubmission opportunities, where having a majority of students initially fail an assignment does not impact their grades if they learn from it.

Generally, pushing assignments to a scope and complexity that exceeds what an agent can comfortably do in a single step helps with experiencing this occasionally. I wish I could design for it explicitly.

Students seem to accept these changes, though I lack evidence on learning outcomes

Beyond what we grade (which is mostly AI generated), I cannot really measure whether students learn more or less. Grade distributions are not a meaningful signal; average grades have drifted down a bit over the years, but that started before most of these changes and might be explained by offloading work to AI or simply by changing demographics in the course. What I can observe is that students readily accept the new formats, and many students who I speak to appreciate these changes, including the flexibility, the in-person interactions, and even the pushback from TAs that forces them to actually understand their solutions. Admittedly, most students I speak to about this in more depth are prospective TAs, not quite a representative sample. Student meetings are revealing in another way: Students regularly complain about classmates offloading their thinking to AI, and within teams we regularly see both conflicts about AI use and self-policing behavior, where teams work out many of these issues on their own.

Continuous adoption seems unavoidable

The pace with which I make changes is increasing. This course was fairly stable for many semesters, with the occasional new example or updated tool; now I make larger changes semester after semester on most assessments (except for exams). Many actions that we take decay as models improve: Cluely has been used to cheat on Zoom check-ins, questions that reliably tripped up coding agents stopped working with more recent models, and the Zulip assignment may eventually become too easy too. We now need to check which assignments and teaching strategies survive the latest generation of tools each semester. Maybe I will have to give in to pen-and-paper quizzes at some point. On the positive side, AI also helps me to adapt and change assignments and build autograders, so adaptation becomes cheaper too.

Back in 2021, I could watch GPT-3 answer my reading quizzes, note that things will change but comfortably do nothing. Now in 2026, I do not get to do that anymore.

The Daily Front Page 9 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The Agent at the Whiteboard
article

Drawgent: Coding agent on a live Excalidraw canvas

by parasitid·▲ 156 points·42 comments·tangled.org ↗
your coding agent on a live Excalidraw canvas

drawgent connects your own Claude Code, Codex or opencode (your install, login, config and repo) to an Excalidraw whiteboard. Ask for a diagram in the chat panel, or write AGENT: … next to the part of a drawing you want changed. The agent looks at the canvas (screenshot + scene), edits it live, checks the result, and marks the note DONE.

How it works

drawgent architecture: the browser, the drawgent server, your coding agent and its MCP canvas tools

Quick start

Prerequisite: one of claude, codex or opencode installed and logged in. The Claude and Codex bridges also need Node.js ≥ 18 (npm).

drawgent setup claude        # once per agent: claude | codex | opencode
cd ~/my-repo
drawgent up                  # new agent session in this repo + canvas in your browser
drawgent up --attach         # or: pick one of your running sessions and connect the canvas to it

Demo

drawgent demo: the agent drawing on a live Excalidraw canvas

drawgent setup <agent>

Checks everything once, fails with the exact fix when something is missing, and writes ~/.config/drawgent/config.toml:

  1. The agent CLI. Your claude / codex / opencode on PATH.

  2. Login. claude auth status, codex login status or opencode auth list.

  3. ACP bridge.

    • opencode speaks ACP itself (opencode acp).
    • Claude Code and Codex use the official ACP adapters. They are installed once into ~/.cache/drawgent/adapters (~60 MB), without their bundled agent binaries, and pointed at your CLI (CLAUDE_CODE_EXECUTABLE, CODEX_PATH).
    • Setup then verifies the ACP handshake.
  4. Canvas tools for attached sessions. Only Codex needs a change: codex mcp add drawgent -- drawgent mcp. Claude and opencode get the tools at attach time.

  5. Headless Chrome for the renderer. Setup uses your Chrome/Chromium if you have one. Otherwise it proposes:

    • downloading Chrome Headless Shell (Chrome for Testing, ~120 MB, no sudo) into ~/.cache/drawgent/chrome, and telling you exactly which system libraries are missing, if any;
    • installing Chromium with your package manager (apt, snap, dnf, pacman, zypper, apk, brew, or nix without sudo).

    Non-interactive: --chrome download | system | /path/to/chrome.

drawgent up refuses to start until setup succeeded for that agent, or if something setup recorded disappeared.

drawgent up

Runs in the current directory (the workspace):

  • starts the editor + API on 127.0.0.1:7300 (next free port if taken);
  • starts a new session of your agent over ACP, working in the workspace;
  • opens your browser. On a headless box it prints the ssh -L command instead.

The live scene and its sync state are kept in .drawgent/scene.json, which is git-ignored automatically.

Keeping the diagram in the repo: --diagram

drawgent up --diagram docs/architecture.excalidraw   # remembered for this workspace
  • What's written: a standard .excalidraw file (it opens on excalidraw.com) that follows the canvas. It holds only the live drawing (no deleted elements, no laser traces), pretty-printed and without per-edit sync fields, so it diffs cleanly and is rewritten only when the drawing changes. Commit it like any other source file.
  • Changes from git: if the file changes outside drawgent (git pull, checkout, a hand edit), it wins. The canvas updates live, deletions included, and a file already on disk at start is applied too. A file that doesn't parse (e.g. merge-conflict markers) is reported in the chat panel and ignored until fixed.
  • Picking the file: a drawgent.excalidraw at the workspace root is used automatically, so a fresh clone needs no flag.
  • Rooms: --diagram can't be combined with --room, where excalidraw.com holds the drawing.

drawgent up --attach [id]

Connects the canvas to a session you already run. Without an id it lists the sessions it finds (current directory first) and lets you pick one:

agent discovery how the canvas reaches it Claude Code claude agents --json (interactive and background sessions) fork: a new session carrying the full conversation, driven by drawgent over ACP. Your terminal session is left untouched opencode opencode servers listening locally: start the TUI with opencode --port 4096 (or opencode serve) live: messages go into your running session (you see them in your TUI); drawgent adds its MCP tools to that server at runtime; replies, tool calls and prompts you type in the TUI are mirrored into the chat panel Codex sessions in ~/.codex/sessions live: codex queue --thread <id>; replies are mirrored from the session's rollout file. The session needs the drawgent MCP (registered by setup, loaded when Codex starts)

Using the canvas

  • Chat panel (right side): send a request, watch replies and tool calls stream in, approve permission prompts, press Stop to cancel a turn.
  • On the canvas: write a text starting with AGENT: next to or inside a shape, or draw an arrow from the note to a shape. It fires about 2.5 s after you stop typing, with its position, what it points at and what is nearby. The agent resolves it into a green DONE: … note. Edit it back to AGENT: to send it again.
  • Laser zones: pick Excalidraw's laser (K) and circle or scribble over part of the diagram. The trace stays on the canvas as a red outline, and the chat panel opens ready for your instruction, with a chip listing what is under the zone (🔴 Laser zone · API, Redis ×). Your next message goes to the agent together with the zone (bounds and covered elements), and the trace disappears when the agent is done. Strokes within 4 s of each other form one zone; × discards it.
  • Prompts are queued and run one at a time. A queued note that was already handled is skipped.

excalidraw.com rooms

drawgent up --room 'https://excalidraw.com/#room=<id>,<key>'
  • drawgent joins the room as a collaborator (🤖 Agent, with a cursor that follows its edits). Humans can stay on excalidraw.com: their AGENT: notes reach your agent, and its edits appear there live.
  • Traffic is end-to-end encrypted with the room key. Empty rooms are loaded from and saved to excalidraw's Firestore storage.
  • The local editor mirrors the room and still has the chat panel.

Other commands

  • drawgent mcp: stdio MCP server with the canvas tools. Agents launch it; it finds the running drawgent up by itself.
  • drawgent serve: low-level server with explicit agents (--agent claude,codex,opencode as set up, or name=command for any ACP agent); for scripts and containers.
  • Options for up / serve: --port, --room, --token (API bearer + ?token= in the URL), --permissions canvas|ask|all (default canvas: drawing tools auto-approved, anything else asked in the chat panel), --data, --settle-ms.

Optional: Docker

docker compose up --build runs a canvas server (drawgent + Chromium, no agents), e.g. to host a shared canvas or a room bridge on a server. Agents are never bundled: they always run with your own setup.

Build from source

npm ci && npm run build      # editor + renderer pages -> dist/ (embedded into the binary)
cargo install --path .       # or: cargo build --release

Release builds (nix develop -c make … provides the toolchain, or rustup + cargo-zigbuild):

command output make musl / make dist static Linux x86_64 binary / tarball (MUSL_TARGET=aarch64-unknown-linux-musl for ARM) make darwin / make dist-darwin macOS universal binary (Intel + Apple Silicon, macOS ≥ 13) / tarball

  • How the macOS build is made: it's cross-built from Linux with zig and no Apple SDK (drawgent links only libSystem, libiconv and libcharset).
  • Signing: the arm64 slice is ad-hoc signed by the linker. It is not notarized, so after downloading run xattr -d com.apple.quarantine drawgent once.
  • Status: not yet tested on a real Mac.
  • Small machines: use JOBS=1. One dependency (chromiumoxide_cdp) needs a lot of memory to compile.

Agent tools (MCP)

get_scene, get_screenshot (vision; zoom with element_ids), add_elements (Excalidraw skeletons; arrows bind by id and are routed edge-to-edge), add_mermaid (auto-layout), update_elements (labels follow shapes, bound arrows re-route), delete_elements, clear_canvas, list_instructions, resolve_instruction, set_status.

API

GET /api/health · GET /api/scene · GET /api/screenshot?ids=&padding=&max= · POST|PATCH|DELETE /api/elements · POST /api/mermaid · POST /api/clear · GET /api/instructions · POST /api/instructions/{id}/resolve · POST /api/status · POST /api/chat {agent?, text} · WS /ws (browser sync + chat events)

Tests

cargo test                          # fractional indices, room crypto/framing
node scripts/smoke.mjs [url]        # chat turn + AGENT: note against a running drawgent
node scripts/e2e-browser.mjs [url]  # real browser: chat panel + note typed on the canvas
node scripts/room-e2e.mjs           # fresh excalidraw.com room ↔ drawgent, both directions
node scripts/laser-e2e.mjs          # laser zone → chat → agent edits only that zone (needs setup claude)
node scripts/diagram-e2e.mjs        # --diagram file: clean output, git pull / hand edits flow back

Layout

src/ (Rust):

  • main.rs: CLI.
  • setup.rs, config.rs, chrome.rs: setup, config, renderer install.
  • attach.rs: session discovery and picker.
  • agents.rs + acp.rs: ACP driver (new / fork sessions).
  • live.rs: live opencode / Codex drivers.
  • hub.rs: routing, notes, chat log.
  • scene.rs: store and edit operations.
  • renderer.rs: Chrome over CDP.
  • mcp.rs: MCP server.
  • room.rs: excalidraw.com client.
  • fractional.rs, geometry.rs, el.rs: helpers.

web/: editor (main.jsx, chat.jsx, laser.js) and renderer page (render.jsx).

Limits

  • The renderer needs Chrome (a native renderer is planned).
  • Claude "attach" is a fork, because Claude Code has no public way to inject into a running terminal session.
  • Codex live attach is implemented, but was not yet tested against a logged-in Codex.
  • One scene (and one diagram file) per workspace. Images/files are not synced.
The Daily Front Page 10 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — One Function, Many Models
article

A single function Jev-like wrapper for LLMs, including vision models

by allanrbo·▲ 142 points·44 comments·allanrbo.blogspot.com ↗
A Jev-like wrapper for LLMs, including vision models

A Jev-like wrapper for LLMs, including vision models

I was intrigued by Jev and the self-hostable projects appearing around it, such as OpenJev and SemIf. Reading about them introduced me to a neat trick: reading an LLM's token probabilities.

Apparently this is an old trick for some people. See e.g. OpenAI's logprobs cookbook. But it was new to me.

I believe the basic idea is to write a prompt like this:

State: My order arrived broken and I want a refund.
Question: Which team should handle this?
[A] billing
[B] shipping
[C] returns
Answer with the letter of the best option only.

Then add a few JSON request parameters to a compatible Chat Completions request:

{
  "max_completion_tokens": 1,
  "logprobs": true,
  "top_logprobs": 20
}

The LLM API will return the letter plus the model's log probabilities for alternative tokens.

Repeat for each question. Forcing it to generating only one token avoids a lengthy answer and is super quick, though processing the input still costs time. Though for each of the questions a shared state prefix can be KV-cached if the backend supports it.

The fun part: this works with vision models too. Jev's documented request format currently describes only text/JSON state. I added an attachments field for images for my local experiments.

My example captures webcam frames, sends base64 JPEGs, and prints a table: is a person visible, are we indoors or outdoors, and how bright is the scene? With Gemma 4 12B on my RTX 3090, I get around 1 frames per second, with three questions per frame. I also ran it against OpenAI gpt-6-luna and got around 0.2 FPS. Presumably because I didn't make any effort to avoid the cost of a separate connection through their system per question per frame.

Specialized computer vision models surely are much more efficient, but what I like here is the flexibility: change a condition by describing it in plain text.

Here's the standalone Python example (OpenCV is just used for convenient access to the webcam, not for any actual computer vision):

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["opencv-python"]
# ///
"""Preview and score webcam frames with llama.cpp or OpenAI.

uv run webcam.py
uv run webcam.py https://api.openai.com/v1 gpt-6-luna
OpenAI reads OPENAI_API_KEY.
"""
import argparse
import base64
import concurrent.futures
import datetime
import json
import math
import mimetypes
import os
import pathlib
import time
import urllib.parse
import urllib.request

import cv2


# attachments is our custom addition to the Jev request format.
data = json.loads("""
{
    "state": "Inspect this webcam frame. Judge only what is visibly present.",
    "attachments": [],
    "questions": {
        "person": {
            "type": "noul",
            "instructions": "Is a person visible?"
        },
        "plant": {
            "type": "noul",
            "instructions": "Is a plant visible?"
        },
        "setting": {
            "type": "choice",
            "instructions": "Where is the camera?",
            "criteria": {
                "indoors": null,
                "outdoors": null,
                "unclear": null
            }
        },
        "light": {
            "type": "score",
            "instructions": "How bright is the scene?",
            "criteria": [
                "dark",
                "dim",
                "bright"
            ]
        }
    }
}
""")


def score(data, url, model):
    state = data["state"]
    if not isinstance(state, str):
        state = json.dumps(state)

    # Attachments are our extension to the Jev-style request format:
    # image file paths or base64 data URLs. Load them once for all questions.
    images = []
    for attachment in data.get("attachments", []):
        if attachment.startswith("data:image/"):
            images.append(attachment)
            continue
        path = pathlib.Path(attachment).expanduser()
        mime_type, _ = mimetypes.guess_type(path)
        if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
            raise ValueError(f"Unsupported image file: {path}")
        encoded = base64.b64encode(path.read_bytes()).decode()
        images.append(f"data:{mime_type};base64,{encoded}")

    # Send the API key only to OpenAI.
    is_openai = urllib.parse.urlsplit(url).hostname == "api.openai.com"
    headers = {"Content-Type": "application/json"}
    if is_openai:
        headers["Authorization"] = "Bearer " + os.environ["OPENAI_API_KEY"]

    answers = {}
    for name, question in data["questions"].items():
        # Represent choices, booleans, and ordinal levels as lettered options.
        if question["type"] == "choice":
            options = question["criteria"]
        elif question["type"] == "noul":
            options = {"true": None, "false": None} | question.get("criteria", {})
        elif question["type"] == "score":
            options = {str(i): description for i, description in enumerate(question["criteria"])}
        else:
            raise ValueError(f"Unknown question type: {question['type']}")
        if not 2 <= len(options) <= 20:
            raise ValueError("Provide 2 to 20 criteria per question.")
        letters = "ABCDEFGHIJKLMNOPQRST"[:len(options)]

        # Ask for a single option letter, so its logprob represents that option.
        instructions = question["instructions"]
        if not isinstance(instructions, str):
            instructions = json.dumps(instructions)
        lines = [f"State:\n{state}\n\nQuestion: {instructions}\nOptions:"]
        for letter, (key, description) in zip(letters, options.items()):
            line = f"[{letter}] {key}"
            if description is not None:
                line += f": {description}"
            lines.append(line)
        prompt = "\n".join(lines) + "\n\nAnswer with the letter of the best option only."

        # OpenAI needs Responses for enough alternatives; llama.cpp needs Chat for logprobs.
        # top_p=1 avoids pruning alternatives.
        if is_openai:
            endpoint = "/responses"
            content = [{"type": "input_text", "text": prompt}]
            content.extend({"type": "input_image", "image_url": image} for image in images)
            body = {
                "model": model,
                "input": [{"role": "user", "content": content}],
                "reasoning": {"effort": "none"},
                "max_output_tokens": 16,
                "top_p": 1,
                "top_logprobs": 20,
                "include": ["message.output_text.logprobs"],
            }
        else:
            endpoint = "/chat/completions"
            content = [{"type": "text", "text": prompt}]
            content.extend({"type": "image_url", "image_url": {"url": image}} for image in images)
            body = {
                "model": model,
                "messages": [{"role": "user", "content": content}],
                "max_completion_tokens": 1,
                "temperature": 0,
                "reasoning_effort": "none",
                "logprobs": True,
                "top_logprobs": 1024,
            }

        # Send the request and read the first output token's alternatives.
        request = urllib.request.Request(
            url.rstrip("/") + endpoint,
            headers=headers,
            data=json.dumps(body).encode(),
        )
        with urllib.request.urlopen(request) as response:
            result = json.load(response)
        if is_openai:
            message = next(item for item in result["output"] if item["type"] == "message")
            candidates = message["content"][0]["logprobs"][0]["top_logprobs"]
        else:
            candidates = result["choices"][0]["logprobs"]["content"][0]["top_logprobs"]
        logprobs = {item["token"]: item["logprob"] for item in candidates}

        # Normalize the returned option scores; missing options initially get zero.
        missing = [letter for letter in letters if letter not in logprobs or logprobs[letter] <= -9999]
        if len(missing) == len(letters):
            raise ValueError("API did not return usable scores for any option")
        peak = max(logprobs[letter] for letter in letters if letter not in missing)
        weights = [math.exp(logprobs[letter] - peak) if letter not in missing else 0 for letter in letters]
        total = sum(weights)

        # An omitted token cannot outrank the last returned alternative.
        # Allow zero only when their combined normalized probability is below 1e-6.
        if missing:
            cutoff = min(value for value in logprobs.values() if value > -9999)
            missing_weight = len(missing) * math.exp(cutoff - peak)
            if missing_weight / (total + missing_weight) >= 1e-6:
                raise ValueError(f"API omitted non-negligible option scores for: {', '.join(missing)}")
        probabilities = {key: weight / total for key, weight in zip(options, weights)}

        # Return the winning choice, probability of true, or expected ordinal level.
        if question["type"] == "choice":
            answers[name] = {
                "type": "choice",
                "choice": max(probabilities, key=probabilities.get),
                "probabilities": probabilities,
            }
        elif question["type"] == "noul":
            answers[name] = {"type": "noul", "noul": probabilities["true"]}
        else:
            answers[name] = {
                "type": "score",
                "score": sum(int(key) * probability for key, probability in probabilities.items()),
                "legend": options,
                "probabilities": probabilities,
            }

    return {"answers": answers}


# Choose the server and model before opening the camera.
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("url", nargs="?", default="http://localhost:8060/v1")
parser.add_argument("model", nargs="?", default="gemma-4-12b")
args = parser.parse_args()

# Point OpenCV's bundled Qt at the installed system fonts.
os.environ["QT_QPA_FONTDIR"] = "/usr/share/fonts/truetype/noto"

# Open the default Linux webcam with a small capture buffer.
camera = cv2.VideoCapture(0, cv2.CAP_V4L2)
if not camera.isOpened():
    raise RuntimeError("Could not open /dev/video0")
camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
print(f"Webcam -> {args.model}. Noul: yes %; score: value/max. Ctrl-C or Esc to stop.", flush=True)
print(f"{'time':<8}" + "".join(f"{name:>10}" for name in data["questions"]) + f"{'fps':>10}", flush=True)

# Preview continuously while a background worker scores one frame at a time.
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
pending = None
try:
    while True:
        ok, frame = camera.read()
        if not ok:
            raise RuntimeError("Could not read a webcam frame")
        cv2.imshow("Webcam", frame)
        if cv2.waitKey(1) == 27 or cv2.getWindowProperty("Webcam", cv2.WND_PROP_VISIBLE) < 1:
            break

        # Print a completed result, then submit the latest frame.
        if pending is not None:
            if not pending.done():
                continue
            result = pending.result()
            columns = []
            for name in data["questions"]:
                answer = result["answers"][name]
                if answer["type"] == "noul":
                    value = f"{answer['noul']:.1%}"
                elif answer["type"] == "choice":
                    value = answer["choice"]
                else:
                    value = f"{answer['score']:.2f}/{len(data['questions'][name]['criteria']) - 1}"
                columns.append(f"{value:>10}")
            columns.append(f"{1 / (time.perf_counter() - started):>10.2f}")
            print(captured + "".join(columns), flush=True)
        # Measure throughput for evaluated frames, including image encoding.
        started = time.perf_counter()
        captured = datetime.datetime.now().strftime("%H:%M:%S")
        ok, jpeg = cv2.imencode(".jpg", frame)
        if not ok:
            raise RuntimeError("Could not encode the webcam frame")
        image = "data:image/jpeg;base64," + base64.b64encode(jpeg.tobytes()).decode()
        data["attachments"] = [image]
        pending = executor.submit(score, data, args.url, args.model)
except KeyboardInterrupt:
    print("\nStopped.")
finally:
    camera.release()
    cv2.destroyAllWindows()
    executor.shutdown()

The script handles the API differences: llama.cpp uses Chat Completions and OpenAI uses Responses to get it to show alternatives.

I ran Gemma 4 12B QAT through llama.cpp. On Linux with NVIDIA drivers, curl, zstd, and uv installed:

# Model (~7 GB) and multimodal projector (~175 MB).
mkdir -p ~/models/gemma-4-12b/
cd ~/models/gemma-4-12b/
curl -fL -C - -o gemma-4-12b-it-qat-q4_0.gguf https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf/resolve/main/gemma-4-12b-it-qat-q4_0.gguf
curl -fL -C - -o mmproj-gemma-4-12b-it-qat-q4_0.gguf https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf/resolve/main/mmproj-gemma-4-12b-it-qat-q4_0.gguf

# Standalone llama.cpp binary for RTX 3090 (CUDA architecture 86).
curl -fL -o llama.zst https://huggingface.co/buckets/ggml-org/install.sh/resolve/b11160/x86_64/linux/cuda/86/llama-app.zst
mkdir -p ~/bin/
zstd -d llama.zst -o ~/bin/llama
chmod +x ~/bin/llama
~/bin/llama serve --models-dir ~/models/ --port 8060

Save the Python example as webcam.py. In another terminal, from that directory:

uv run webcam.py http://localhost:8060/v1 gemma-4-12b
# Or use OpenAI, with OPENAI_API_KEY set in your environment.
uv run webcam.py https://api.openai.com/v1 gpt-6-luna
The Daily Front Page 11 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Any Cloud, Locally
article

Floci: Locally emulating any cloud service

by theanonymousone·▲ 179 points·42 comments·floci.io ↗
Run AWS, Azure, GCP, and OCI locally in milliseconds

Run AWS, Azure, GCP, and OCI locally in milliseconds: the fast, credential-free loop your team and its AI agents need to ship faster.

Get started ↓ For AI agents ↓ ★ GitHub

Pick your cloud. Start instantly.

Each emulator is a standalone MIT-licensed binary. No auth tokens, no feature gates, no cloud account needed.

AWS

floci

Drop-in replacement for LocalStack. Same port 4566, 119 services, native binary. Switch with zero code changes.

Learn more → GitHub ↗

Azure

floci-az

Covers Blob, Queue, Table, Functions, App Config, Key Vault, Event Hubs and Service Bus. Native speed, MIT license, no Azure account needed.

Learn more → GitHub ↗

GCP

floci-gcp

Covers GCS, Pub/Sub, Firestore, Cloud Run, Cloud SQL, GKE and more. All 25 services on one port, native binary. MIT license, no GCP account needed.

Learn more → GitHub ↗

OCI

floci-oci

Covers Object Storage, Identity, Queue, Streaming, KMS, Vault and Functions. All 8 services on one port. MIT license, no Oracle Cloud account needed.

Learn more → GitHub ↗

Give your AI agents a cloud they can't break.

Coding agents write cloud code faster than ever, but they can't safely run it against a real account. Point them at Floci instead: a local cloud to build, run, and verify against. Instant, free, and credential-free.

No credentials to leak.

Agents connect with throwaway keys. No real cloud secrets in the agent's context or sandbox. Nothing to exfiltrate, nothing to bill.

Inner-loop fast.

24 ms cold start, 13 MiB idle. Agents spin up, test, and tear down inside the edit loop instead of waiting on round-trips to a remote account.

Real signal, not mock theater.

Lambda, RDS, Redis, and Kafka run for real, so code an agent verifies locally behaves the same in production. No mock-shaped false positives.

Zero blast radius.

Let agents iterate aggressively. Worst case, they reset a local container, not your staging environment or your cloud bill.

# Point any agent's tooling at Floci. No keys, no cloud account
export AWS_ENDPOINT_URL=http://localhost:4566
pytest tests/ # runs against real-shaped S3, DynamoDB, Lambda…

Works with every SDK, CLI, Terraform/OpenTofu module, and test runner your agents already use, with no special integration.

One toolchain. All clouds.

Manage and explore every Floci emulator from a unified CLI or visual dashboard.

floci-cli

A unified command-line interface to start, stop, and inspect all Floci emulators. One install covers AWS, Azure, GCP, and OCI from a single tool.

# Install (macOS / Linux)
curl -fsSL https://floci.io/install.sh | sh

# Start the AWS emulator
floci start

# Check your environment
floci doctor

GitHub →

floci-ui

A visual dashboard for all Floci cloud emulators. Browse resources, inspect data, and manage services across AWS, Azure, GCP, and OCI from one place.

GitHub →

Built for developers who ship.

No gatekeeping. No pricing tiers. No surprises. A local cloud that starts in milliseconds and works everywhere.

MIT Licensed. Forever free.

Fork it, embed it, extend it. No "community edition" sunset, no enterprise feature flags. Every service is available to every developer, always.

No auth token. Ever.

Pull the Docker image and go. No sign-ups, no API keys, no telemetry. LocalStack started requiring an auth token in March 2026. Floci never will.

Native binary speed.

Compiled with GraalVM Mandrel. Starts in 24ms, idles at 13 MiB, fast enough for inner-loop iteration. Your CI, your laptop, and your AI agents will all thank you.

Real engines, not mocks.

Lambda runs in real Docker containers. RDS uses real PostgreSQL/MySQL. ElastiCache runs real Redis. 100% protocol fidelity, no surprises in production.

Where teams put Floci to work.

From the inner loop to CI to the classroom — anywhere a real cloud account is too slow, too risky, or too expensive.

Ephemeral test environments.

Spin up the full stack inside every pipeline job. 24 ms startup adds nothing to the build, each job gets an isolated cloud, and teardown is free.

Local-first development.

Build against S3, Pub/Sub, or Service Bus on your laptop — offline if you want. No shared dev account to trip over, no credentials to rotate.

Infrastructure-as-code dry runs.

Apply Terraform, OpenTofu, or CloudFormation against localhost first. Catch typos, drift, and bad refactors before they ever touch a real account.

Workshops & onboarding.

Teach cloud services with zero billing risk. Every student runs the whole stack on their own machine — no accounts to provision, nothing to clean up.

One install. Every service, locally.

No account, no token. Pick your platform and go.

# Install floci-cli
curl -fsSL https://floci.io/install.sh | sh

# Start the AWS emulator & export env vars
floci start && eval $(floci env)

# Create a bucket
aws s3 mb s3://my-bucket

# Upload a file
echo "Why pay for S3 when floci is free? 🎉" > hello-floci.txt
aws s3 cp hello-floci.txt s3://my-bucket/

# Download it back
aws s3 cp s3://my-bucket/hello-floci.txt hello-back.txt
cat hello-back.txt
# Install floci-cli (PowerShell)
irm https://floci.io/install.ps1 | iex

# Start the AWS emulator & export env vars
floci start; floci env | Invoke-Expression

# Create a bucket
aws s3 mb s3://my-bucket

# Upload a file
"Why pay for S3 when floci is free? 🎉" | Out-File hello-floci.txt
aws s3 cp hello-floci.txt s3://my-bucket/

# Download it back
aws s3 cp s3://my-bucket/hello-floci.txt hello-back.txt; Get-Content hello-back.txt
# Run all 119 AWS services on port 4566
docker run --rm -p 4566:4566 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  floci/floci:latest

# Point the AWS CLI at floci
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test

# Create a bucket
aws s3 mb s3://my-bucket

# Upload a file
echo "Why pay for S3 when floci is free? 🎉" > hello-floci.txt
aws s3 cp hello-floci.txt s3://my-bucket/

# Download it back
aws s3 cp s3://my-bucket/hello-floci.txt hello-back.txt
cat hello-back.txt
# Run Blob, Queue, Table & Functions on port 4577
docker run --rm -p 4577:4577 \
  floci/floci-az:latest

# Configure the Azure CLI
export AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;\
AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;\
BlobEndpoint=http://localhost:4577/devstoreaccount1;"

# Create a container & upload a file
az storage container create -n my-container
echo "Azure locally, no bill, no drama. 🚀" > hello-floci.txt
az storage blob upload -c my-container -f hello-floci.txt -n hello-floci.txt

# Download it back
az storage blob download -c my-container -n hello-floci.txt -f hello-back.txt
cat hello-back.txt
# Run Cloud Storage on port 4588
docker run --rm -p 4588:4588 \
  floci/floci-gcp:latest

# Point the gcloud CLI at floci-gcp
export CLOUDSDK_API_ENDPOINT_OVERRIDES_STORAGE=http://localhost:4588/
export CLOUDSDK_CORE_PROJECT=floci-local

# Create a bucket & upload a file
gcloud storage buckets create gs://my-bucket
echo "No billing alert? Must be floci. ☁️" > hello-floci.txt
gcloud storage cp hello-floci.txt gs://my-bucket/

# Download it back
gcloud storage cp gs://my-bucket/hello-floci.txt hello-back.txt
cat hello-back.txt
# Run all 8 OCI services on port 4599
docker run --rm -p 4599:4599 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  floci/floci-oci:latest

# One-time OCI CLI setup (throwaway API key + profile) & env vars
floci oci setup && eval $(floci oci env)
export TENANCY_OCID=ocid1.tenancy.oc1..flocilocaltenancy0000000000000000000000000000000000000000

# Create a bucket & upload a file
oci os bucket create \
  --namespace-name floci-local --name my-bucket --compartment-id $TENANCY_OCID
echo "Oracle Cloud locally — no bill, no drama. 🚀" > hello-floci.txt
oci os object put \
  --namespace-name floci-local --bucket-name my-bucket --file hello-floci.txt

# Download it back
oci os object get \
  --namespace-name floci-local --bucket-name my-bucket --name hello-floci.txt --file hello-back.txt
cat hello-back.txt

floci (AWS) → · floci-az (Azure) → · floci-gcp (GCP) → · floci-oci (OCI) →

The Daily Front Page 12 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Llamas by Harmonics
article

Fourier Analysis: Drawing Llamas with Circles

by cebert·▲ 76 points·11 comments·adekau.github.io ↗
drawing llamas with circles

The Fourier transform is a method of transforming an input signal from the time domain to the frequency domain. It has a huge range of applications, for instance audio engineers can pick out individual undesired frequencies in a song with the Fourier transform and then get back the sound without that frequency using the inverse Fourier transform. We can make use of the Fourier transform in digital image processing for filtering images, like gaussian blurs and compressing images using the JPEG format. Lastly, as this article will go into: drawing!

What am I talking about by drawing? Well, the idea is that we can take a path that represents something such as a fish, the pi symbol, or just about anything else we can draw by putting pencil lead down on a piece of paper and sketching a picture without lifting the pencil until completion. Using this path we can connect a bunch of vectors rotating in circles at different frequencies tip to tail and the last vector’s tip will draw out our original sketch, or at least something very closely resembling it.

Here are a few examples of what I am talking about:

../../_images/line.gif

Fig. 1 Drawing a line with circles

So you can draw a straight line using a shape that is about as far opposite from a straight line as possible. By modifying the circles you can change the shape that is drawn. For example, by decreasing the radius of the outer circle and increasing the speed it spins at, we can draw a square.

../../_images/square.gif

Fig. 2 A square

So what happens, then, if we add a third circle? It will allow our curves to get more sophisticated. Using a third circle we can draw a curve that looks like a fish.

../../_images/fish.gif

Fig. 3 A fish.

For a more extreme example of what is possible using only circles connected to other circles, here’s a llama being drawn using \(1024\) circles each with different frequencies, radii, and start angles. In general (most) every closed curve can be drawn using circles, called epicycles.

../../_images/llama.gif

Fig. 4 A llama?

In general, the more circles that we add, the more complicated the drawings we can produce. Also, the more circles we add for the same drawing, the better it will resemble the original “input” drawing. In order to understand what creates that animation, we will need to go into some of the underlying math and intuition behind the Fourier transform (and series). As mentioned previously, there are three conditions we can modify on a circle to draw different curves. They are: the rate at which it rotates (frequency), how big the circle is (radius), and the angle it starts at (phase). All three of these conditions can be represented using a single complex number.

Here’s an example you can mess around with to visualize the effect adding circles has on the final drawing.

Fourier Transform

But first, I should probably give a quick overview of what the Fourier transform is. It is a transformation from the time domain to the frequency domain. What does this mean? If we have a function, \(\sin(2\pi\times 3t)\), then the Fourier transformation of that \(\sin(2\pi\times 3t)\) function would just be a single spike at the frequency \(\pm 3\text{hz}\).

../../_images/sin2pi3t-2.svg

Fig. 5 The graph of \(\sin(2\pi\times 3t)\)

../../_images/fft_sin2pi3t-3.svg

Fig. 6 The magnitude of \(\sin(2\pi\times 3t)\) in the frequency domain.

As expected, there are spikes at the frequency \(3\text{hz}\) and \(-3\text{hz}\). Essentially what we are doing is finding what frequencies are present in whatever we want and then combining those sine waves into an approximation of the original thing we wanted. Over the next few sections I’ll attempt to show how the Fourier transform works.

Complex Numbers

This section will be a quick refresher on what a complex number is, and can be skipped if you are already familiar with them. A complex number consists of two parts, the “real” part, and the “imaginary” part. This takes numbers we are already familiar with and essentially adds another axis to represent them with.

A complex number is written as \(3+4i\), where \(3\) is the real part and \(4i\) is the imaginary part. These two separate pieces are added together to form a complex number.

Another useful property of complex numbers here is that they can be easily thought of as two-dimensional vector where the real portion is \(x\) and the imaginary part is \(y\) in a standard point, \((x, y)\), in the two-dimensional Euclidean space. This representation is very convenient here due to the connection between trigonometry and complex exponentials. The formula that provides the link between the two is known as Euler’s Identity. This will be covered further in a later section in the article.

Linear Algebra

Linear Algebra is an important concept in understanding how we draw pictures using vectors rotating in circles. Namely, how we can represent vectors in terms of other vectors. In the standard 2D \((x, y)\) Euclidean space, we can represent all vectors in terms of the \(\textbf{x}\) and \(\textbf{y}\) unit vectors. These are typically called \(\hat{\textbf{i}}\) and \(\hat{\textbf{j}}\) (pronounced “i-hat” and “j-hat”) with \(\hat{\textbf{i}} = (1, 0)\) (the \(x\)-axis) and \(\hat{\textbf{j}} = (0, 1)\) (the \(y\)-axis). To do this we have two operations we can use: multiplication by a scalar and addition. For example, the vector \(\vec{a} = (5,-8)\) can be expressed as

\[ \begin{split} \begin{equation} \begin{aligned} \vec{a} &= 5\cdot \hat{\textbf{i}} - 8\cdot \hat{\textbf{j}} \\ &= 5\cdot (1, 0) + -8\cdot (0, 1)\\ &=(5, 0) + (0, -8)\\ &= (5, -8) \end{aligned} \end{equation} \end{split} \]

This multiplication by a scalar and addition of vectors to produce another vector is known as the linear combination of a set of vectors. In this instance, that set of vectors is the unit vectors \(\hat{\textbf{i}}\) and \(\hat{\textbf{j}}\).

../../_images/dotproduct-1.svg

Fig. 7 Two vectors, \(\vec{u}\) and \(\vec{v}\) with an angle of \(90^{\circ}\) between them and both length \(1\)

But \(\hat{\textbf{i}}\) and \(\hat{\textbf{j}}\) aren’t the only vectors that can be used to represent other vectors, however. In fact, any two vectors can be scaled and added together to form any other vector as long as they are not a multiple of the other themselves. For example \((1,-1)\) and \((-2, 2)\) would not be valid since \((-2,2) = -2 \times (1,-1)\). In the above picture, we have vectors \(\vec{u} = (\tfrac{1}{\sqrt{2}},\tfrac{1}{\sqrt{2}})\) and \(\vec{v} = (-\tfrac{1}{\sqrt{2}},\tfrac{1}{\sqrt{2}})\) which have length 1 and are orthogonal (a \(90^{\circ}\) angle between them). Neither of these vectors can be scaled to equal the other, which means they are linearly independent and therefore they can be basis vectors of the entire 2D space.

The dot product (or sometimes called “inner product”) is how we determine what number to scale vectors by to represent them in terms of the chosen vectors. In order to represent the vector \(\vec{\textbf{w}} = (0.7, 1.2)\) from the above diagram in terms of the vectors \(\vec{u}\) and \(\vec{v}\), we need to figure out what numbers to multiply them in order to form the linear combination \(\vec{w} = c_1\cdot \vec{u}+c_2\cdot \vec{v}\). Specifically, the numbers that we need from the dot product are the constant multipliers \(c_1\) and \(c_2\). Another way of thinking about this concept is that the dot product answers the question “how much of \(\vec{u}\) is in \(\vec{w}\)?” This intuition will be important later on when we go into the math behind the Fourier transform. Anyways, we can figure out what \(c_1\) is by taking the dot product of \(\vec{w}\) with \(\vec{u}\), also note that the order does not matter (e.g., \(\langle\vec{u},\vec{w}\rangle = \langle\vec{w},\vec{u}\rangle\)).

\[ \begin{split} \begin{equation} \begin{aligned} c_1 &= \langle\vec{u},\vec{w}\rangle\\ &=({u}_x\times {w}_x)+({u}_y\times {w}_y)\\ &= (\tfrac{1}{\sqrt{2}}\times 0.7)+(\tfrac{1}{\sqrt{2}}\times 1.2)\\ &= 1.3435 \end{aligned} \end{equation} \end{split} \]

So we need to scale \(\vec{u}\) by a factor of \(1.3435\) which means it will get stretched by a factor of \(1.3435\) times its original size. Our \(\vec{u}\) component of \(\vec{w}\) (\(c_1\)) therefore is \(1.3435\).

\[ \begin{split} \begin{equation} \begin{aligned} c_2 &= \langle\vec{v},\vec{w}\rangle\\ &=({v}_x\times {w}_x)+({v}_y\times {w}_y)\\ &=(-\tfrac{1}{\sqrt{2}}\times 0.7)+(\tfrac{1}{\sqrt{2}}\times 1.2)\\ &= 0.353553 \end{aligned} \end{equation} \end{split} \]

Similarly, we need to scale \(\vec{v}\) by a factor of \(0.353553\) which means it will get shrunk (or compressed) by a factor of \(0.353553\) times its original size. Our \(\vec{v}\) component of \(\vec{w}\) (\(c_2\)) therefore is \(0.353553\).

We wind up with our linear combination being

\[ \begin{split} \begin{equation} \begin{aligned} \vec{w} &= 1.3435\cdot \vec{u}+0.353553\cdot \vec{v}\\ &= (0.95, 0.95)+(-0.25, 0.25)\\ &= (0.7, 1.2) \end{aligned} \end{equation} \end{split} \]

Which is indeed equal to our original vector, \(\vec{w}\).

../../_images/dotproductW-1.svg

Fig. 8 The linear combination of \(\vec{u}\) and \(\vec{v}\) to produce \(\vec{w}\)

The dot product of any two vectors is defined as

\[ \begin{split} \begin{equation} \begin{aligned} \langle\vec{v_1},\vec{v_2}\rangle &= {v_1}_x\times {v_2}_x + {v_1}_y\times {v_2}_y\\ &= \|\vec{v_1}\|\times \|\vec{v_2}\| \times \cos(\theta) \end{aligned} \end{equation} \end{split} \]

Where \(\theta\) is the angle between \(v_1\) and \(v_2\) and \(\|\vec{v}\|\) is the magnitude of the vector. The magnitude is also known as the norm and is defined as \(\|\vec{v}\| = \sqrt{{v}_x^2 + {v}_y^2}\). That equation probably looks familiar, and that would be because it’s just the Pythagorean theorem we’ve all learned at some point in a K-12 math class.

../../_images/pythagthm-1.svg

Fig. 9 Pythagorean Triangle

The dot product also has some important properties that make it useful in our case. First, the dot product of any vector with itself is the magnitude of the vector squared (\(\vec{v_1}\cdot \vec{v_1} = \|\vec{v_1}\|^2\)). For instance,

\[ \begin{equation} \langle[5, 7],[5, 7]\rangle = \sqrt{5^2 + 7^2}^2 = \sqrt{74}^2 = 74 \end{equation} \]

Secondly, the dot product of vectors that are perpendicular (form a right angle where they intersect) to eachother is \(0\). This is because \(\cos(\frac{\pi}{2}) = 0\). Therefore, with \(\vec{v_1}, \vec{v_2}\) perpenicular, you end up with

\[ \begin{equation} \langle\vec{v_1},\vec{v_2}\rangle = \|\vec{v_1}\|\times \|\vec{v_2}\|\times 0 = 0 \end{equation} \]

With our previous intuition of “how much \(\vec{v_1}\) is in \(\vec{v_2}\)”, it makes sense for it to be \(0\) since these two vectors are orthogonal.

Inner Products of Continuous Functions

The previous section’s dot product was for the dot product of two vectors, but this definition can be extended to also be valid for continuous functions. The key difference is in the type of summation being done. As mentioned previously, the formula for a 2D dot product is

\[ \begin{equation} \langle\vec{a},\vec{b}\rangle = {a}_x \times {b}_x + {a}_y \times {b}_y \end{equation} \]

Or rather, the summation of the like terms of both vectors where the length of the vectors is finite (\(2\), in this case) so the summation is discrete. Another way of writing this equation for any dimension is

\[ \begin{equation} \langle\vec{a},\vec{b}\rangle = \sum_{n =1}^{N}{a_n\times b_n} \end{equation} \]

Where \(N\) is the dimension. The sigma-notation summation here is summation over a discrete interval. For instance, in a 3D space, the summation is over the interval \([1, 3]\). This summation, however, is only good for vectors in \(\mathbb{R}^n\). The formula for complex vectors of length \(n\), \(\mathbb{C}^n\), is very similar. We just need to take the conjugate of the second vector.

\[ \begin{equation} \langle\vec{a},\vec{b}\rangle = \sum_{n = 1}^{N}{a_n\times \overline{b_n}} \end{equation} \]

It’s important to note that the complex conjugate here makes this formula “antilinear in the second argument”, which is to say \(\vec{a}\cdot(\vec{b}+\vec{c})=\vec{a}\cdot \vec{b} + \vec{a}\cdot \vec{c}\). However, in scalar multiplication it is not linear and instead we must pull out the conjugate of the scalar, \(\vec{a}\cdot(b\times \vec{c})=\overline{b}\times (\vec{a}\cdot \vec{c})\). It is linear in the first argument, though, which means when you pull out a scalar multiple from the first argument of the inner product, you do not need to take the conjugate of the scalar. In general, it will be antilinear in the argument you are taking the complex conjugate of, and linear in the other one.

What we need is a type of summation that can be performed over continuous intervals where the summation interval can be infinitely small. This sounds like the perfect use case for an integral.

\[ \begin{equation} \langle f(t),g(t)\rangle = \frac{1}{T}\int_0^T{f(t)\overline{g(t)}dt} \end{equation} \]

The only restrictions here being that the functions \(f(t)\) and \(g(t)\) must be square integrable over the interval \([0,T]\), which is to say that integrating the function’s absolute value squared is finite over \([0, T]\).

\[ \begin{equation} f(t) \in L^2(0,T)\quad\text{if}\quad\int_0^T{|f(t)|^2dt}\quad\text{is finite.} \end{equation} \]

Note

\(L^2\) is the set of square-integrable functions and \(L^2(0, T)\) is the set of square-integrable functions over interval \((0, T)\).

To make this a bit more familiar and similar looking to our previous definition of the dot product in 2D space, we can write this integral as a Reimann Sum (sigma-notation) like so

\[ \begin{equation} \frac{1}{T}\int_0^T{f(t)g(t)dt} = \lim_{N\to\infty} \frac{1}{T}\sum_{k=0}^{N}{f(\frac{T\times k}{N})\times \overline{g(\frac{T\times k}{N})}\times\frac{T}{N}} \end{equation} \]

Where \(\dfrac{T}{N}\) is the equivalent of \(dt\) here. It is the infinitesimally small piece that each step gets multiplied by. Functions \(f\) and \(g\) then take the upper bound \(T\) times the ratio between the current step in the summation and what the limit approaches. This keeps the value \(f\) and \(g\) are taking as a parameter within the integration interval. In lower steps (\(k = 0, 1, 2\), etc) \(\frac{T\times k}{N}\) will be much closer to \(0\) and thus the argument will be much closer \(0\). Where as \(k\) approaches \(N\), \(\frac{T\times k}{N}\) will be closer to \(1\) and as a result be closer to \(T\).

Complex Exponentials as Sines and Cosines

Complex exponentials provide a convenient way for us to easily deal with sines and cosines. There’s a surprising link between the world of trigonometry and the complex plane. The formula that describes that link is called Euler’s Identity. Here is the formula for Euler’s Identity, as promised in the Complex Numbers section.

\[ \begin{equation} e^{ix} = \cos(x) + i \times \sin(x) \end{equation} \]

Where \(x\) describes how far around a circle to travel. For instance, \(e^{\frac{\pi}{4} i}\) goes around the circle \(\frac{\pi}{4}\) radians and ends up at \(\frac{\sqrt{2}}{2} + \frac{\sqrt{2}}{2}i\). Additionally, \(e^{\pi i}\) goes around the circle \(\pi\) radians and ends up at \(-1 + 0i\), which is Euler’s other famous identity:

\[ \begin{equation} e^{\pi i} = -1 \end{equation} \]

../../_images/e-ix-1.gif

Fig. 10 How \(e^{ix}\) correlates to Trigonometry

Why is this the case? How does raising \(e\) to an imaginary number cause rotation about a unit circle? The answer lies in physics. If the position \(p\) at time \(t\) is

\[ \begin{equation} p(t)=e^{it} \end{equation} \]

Then the velocity of that point is the first derivative of \(p(t)\).

\[ \begin{equation} v(t)=\frac{dp}{dt}=i\times e^{it} \end{equation} \]

And that is all we need! The velocity is the same as the position but imaginary instead of real. Multiplying by \(i\) is like rotating counter-clockwise by \(90^{\circ}\). For example, if you have \(a\in\mathbb{R}\), then \(i\times a\) is whatever \(a\) was on the imaginary axis instead. But what if \(a\) was already imaginary? Then you have \(a = i\times k\) where \(k\) is some real number. Multiplying \(a\), then by \(i\) becomes \(i\times a = i^2\times k\). We know by the definition of \(i\) that \(i^2\) is \(-1\), thus \(i\times a = -k\). Since it’s just the negtive of a real number, it’s a \(180^{\circ}\) rotation. Since multiplying by \(i\) is like rotating by \(90^{\circ}\) then the velocity function is always perpendicular to the direction of the current position. The curve this will draw is a circle, and that’s why \(f(t)=e^{it}\enspace \text{where}\enspace t\in [0, 2\pi]\) draws a circle.

Also, note that

\[ \begin{equation} \frac{1}{2\pi}\int_0^{2\pi}{\cos(t)\sin(t)\,dt}=0 \end{equation} \]

Since \(\cos{t}\) and \(\sin{t}\) are orthogonal functions, which makes \(\cos\) and \(\sin\) a basis for the Fourier transform.

../../_images/cos-sin-orthogonal.png

Fig. 11 Demonstrating \(\cos\) and \(\sin\) being orthogonal functions.

So essentially what we are doing is a change of basis from the two dimension complex number space \((1,0), (0, 1)i\) to an infinite dimension space with basis functions \((...,e^{i\frac{-4\pi}{T}t}, e^{i\frac{-2\pi}{T}t}, 1, e^{i\frac{2\pi}{T}t}, e^{i\frac{4\pi}{T}t},...)\) which means that we are going from a function producing complex numbers and mapping to a new function producing an infinite dimension complex vector. This can also be represented as an infinite dimension basis on \(\mathbb{R}\) by using Euler’s formula to convert the basis to \((...,\cos(\frac{-2\pi}{T}),\sin(\frac{-2\pi}{T}),1,\cos(\frac{2\pi}{T}), \sin(\frac{2\pi}{T}),...)\). Since this is a linear operation and it’s mapping from one function space to another, it’s what we call a linear operator.

Approximating a Square Wave

../../_images/square_wave.svg

Fig. 12 The square wave.

The square wave is sort of like the “hello world” of the signal processing world. A square wave is defined as a \([ -\pi, \pi]\) periodic wave where half the period is “on”, or \(1\), and half the period is “off”, or \(0\).

\[ \begin{equation} \text{square}(t)= \text{sgn}(\sin{(t)}) \end{equation} \]

Where sgn is the sign function, defined as so

\[ \begin{equation} \text{sgn}(x)= \begin{cases} 1 &\text{if}\; x>0 \\ 0 &\text{if}\; x=0 \\ -1 &\text{if}\; x<0 \end{cases} \end{equation} \]

So we can integrate (inner product of the square wave function and the \(k\)th basis term, same as a change of basis for 2D vectors as described in the Linear Algebra section above) to find the \(k\)th Fourier coefficient of the square wave.

\[ \begin{equation} c_k=\int_{-\pi}^{\pi}{\text{sgn}(\sin (t))e^{-i k t}dt} \end{equation} \]

Note

The \(\frac{2\pi}{T}\) term for \(e^{-i \frac{2\pi}{T} k t}\) disappears since \(T=2\pi\).

Since the sign function is discontinuous, we need to separate the integral into a new integral for each case of the sign function.

../../_images/sin.svg

Fig. 13 The sin wave on the interval from \(-\pi\) to \(\pi\).

As we can see, between \([ -\pi, 0]\), the sign of \(\sin(t)\) is negative. At \(0\), it’s \(0\), and between \([0, \pi]\) it’s positive. Therefore the separated integral looks like

\[ \begin{equation} c_k=\frac{1}{2\pi}\int_{-\pi}^{0}{-1\times e^{-i k t}dt}+\frac{1}{2\pi}\int_0^0{0\times e^{-i k t}dt}+\frac{1}{2\pi}\int_0^{\pi}{1\times e^{-i k t}dt} \end{equation} \]

The middle term will disappear since the interval is \([0, 0]\) and the integrand is \(0\), and we are left with

\[ \begin{equation} c_k=\frac{1}{2\pi}\int_{-\pi}^{0}{-e^{-i k t}dt}+\frac{1}{2\pi}\int_0^{\pi}{e^{-i k t}dt} \end{equation} \]

Since the \(-1\) can be pulled out of the first integrand, this can be simplified even further to

\[ \begin{equation} c_k=\frac{1}{2\pi}\left(\int_0^{\pi}{e^{-i k t}dt}-\int_{-\pi}^{0}{e^{-i k t}dt}\right) \end{equation} \]

So we can calculate this integral pretty easily. Recall that

\[ \begin{equation} \int{e^{i x}dx}=-i e^{i x} + C \end{equation} \]

Here we are going to ignore the constant term and just use \(-i e^{i x}\). Since \(k\) is just a constant term, it gets divided. We end up with the following

\[ \begin{split} \begin{equation} \begin{aligned} \frac{1}{2\pi}\left(\int_0^{\pi}{e^{-i k t}dt}-\int_{-\pi}^{0}{e^{-i k t}dt}\right) &=\frac{1}{2\pi}\left(\frac{i e^{-i k t}}{k}\bigg\rvert_{0}^{\pi} - \frac{i e^{-i k t}}{k}\bigg\rvert_{-\pi}^{0} \right)\\ &=\frac{1}{2\pi}\left[\left(\frac{ie^{-i k \pi}}{k}-\frac{i}{k}\right)-\left(\frac{i}{k}-\frac{ie^{i k \pi}}{k} \right) \right] \\ &= \frac{1}{2\pi}\left(\frac{ie^{-i k \pi}-i}{k}-\frac{i+ie^{i k \pi}}{k}\right) \\ &= \frac{1}{2k\pi}\left(ie^{-ik\pi}+ie^{ik\pi}-2i\right) \\ &= \frac{i}{2k\pi}\left(e^{-ik\pi}+e^{ik\pi}-2\right) \\ &= \frac{i\left(e^{-ik\pi}+e^{ik\pi}-2\right)}{2k\pi} \end{aligned} \end{equation} \end{split} \]

We have a major problem with this formula, however. We need a value for \(k=0\). This formula, with \(k=0\), would result in a divide by \(0\), which we cannot do. As a result, we need to define \(c_k\) for when \(k=0\) and for when \(k\not = 0\). If we plug \(0\) into \((26)\) then we have \(c_0\).

\[ \begin{split} \begin{equation} \begin{aligned} c_0 &= \frac{1}{2\pi}\left(\int_0^{\pi}{e^{-i 0 t}dt}-\int_{-\pi}^{0}{e^{-i 0 t}dt}\right)\\ &= \frac{1}{2\pi}\left( \int_0^\pi{1dt} - \int_{-\pi}^0{1dt} \right) \\ &= \frac{1}{2\pi}\left( t\big\rvert_0^\pi - t\big\rvert_{-\pi}^0 \right)\\ &= \frac{1}{2\pi}\left[ (\pi-0)-(0+\pi) \right] \\ &= \frac{1}{2\pi}(\pi - \pi) \\ &= 0 \end{aligned} \end{equation} \end{split} \]

Now that we have both cases defined we can write it as a piecewise function.

\[ \begin{split} \begin{equation} c_k = \begin{cases} 0  &\text{if}\; k=0 \\ \cfrac{i\left(e^{-ik\pi}+e^{ik\pi}-2\right)}{2k\pi} &\text{if}\;k\not = 0 \end{cases} \end{equation} \end{split} \]

Which is our final equation for the \(k\)th coefficient. We can plug in a few values for \(k\) and see what we get.

\[ \begin{split} \begin{equation} \begin{aligned} c_{-3}&=\frac{2i}{3\pi}\\ c_{-2}&=0\\ c_{-1}&=\frac{2i}{\pi}\\ c_0 &= 0\\ c_1 &= -\frac{2i}{\pi}\\ c_2 &= 0\\ c_3&=-\frac{2i}{3\pi} \end{aligned} \end{equation} \end{split} \]

Observe that positive and negative corresponding \(c_k\)s have equal magnitudes but opposite directions. So now to get an equation that we can graph to approximate our square wave, we sum each \(c_k\) coefficient multiplied by \(e^{i k t}\).

\[ \begin{split} \begin{equation} \begin{aligned} f(t) &=\sum_{k=-3}^3{c_k \times e^{i k t}}\\ &= \frac{2i}{3\pi}e^{-3it}+0+\frac{2i}{\pi}e^{-1it}+0-\frac{2i}{\pi}e^{1it}+0-\frac{2i}{3\pi}e^{3it}\\ &= \frac{2i}{\pi}e^{-1it}-\frac{2i}{\pi}e^{1it}-\frac{2i}{3\pi}e^{3it}+\frac{2i}{3\pi}e^{-3it}\\ &= \frac{2i}{\pi}\left(e^{-it}-e^{it}\right)+\frac{2i}{3\pi}\left(e^{-3it}-e^{3it}\right) \end{aligned} \end{equation} \end{split} \]

This equation looks complicated but it can actually be simplified quite a bit further using the following identity

\[ \begin{split} \begin{equation} \begin{aligned} e^{-ikt}-e^{ikt} &= (\underbrace{\cos(-kt)}{\cos(-x) = \cos(x)}+i\underbrace{\sin(-kt)}{\sin(-x) = -\sin(x)})-(\cos(kt)+i\sin(kt)) \\ &= (\cos(kt)-i\sin(kt))-(\cos(kt)+i\sin(kt))\\ &= -2i\sin(kt) \end{aligned} \end{equation} \end{split} \]

Using the above identity that takes our difference of complex exponentials into just an imaginary sine component, we get the following simplification

\[ \begin{split} \begin{equation} \begin{aligned} \frac{2i}{\pi}\left(e^{-it}-e^{it}\right)+\frac{2i}{3\pi}\left(e^{-3it}-e^{3it}\right) &= \underbrace{\frac{2i}{\pi}(-2i\sin(t))}_{2i\times -2i = 4\;\text{since}\;i^2=-1}+\frac{2i}{3\pi}(-2i\sin(3t))\\ &= \frac{4}{\pi}\sin(t) + \frac{4}{3\pi}\sin(3t) \end{aligned} \end{equation} \end{split} \]

Which is a lot cleaner, since our new equation no longer involves any imaginary terms. So now we can go ahead and plot this and see how well it approximates our original square wave function.

../../_images/MathematicaSquareWaveApprox1-1.png

Well, it’s not exactly a square wave yet but the idea is that as we add together more sine waves found in the same manner as above, then we will get the square wave as the number of sine waves we add together approaches infinity. This is what we call the Fourier Series, since it is an infinite summation of sine waves that converges to our original function. You can see in the following picture that as we go from \(7\) coefficients to \(51\) the approximation gets much better.

../../_images/MathematicaSquareWaveApprox2.png

The function goes from having \(2\) sine waves to \(13\). With these additional \(9\) sine waves the graph is actually starting to look a bit like a square wave.

Drawing Llamas

We can use the same principles demonstrated in the section above to draw pretty much anything we want to as long as we can represent it as a closed curve that is fairly smooth. As far as how to go about acquiring a function that represents a llama, there’s two things we can do. We can draw a llama in a vector graphics program and export it as an SVG and then use the SVG since SVGs are just mathematically defined curves (lines, bezier curves, etc). Or, we could take that SVG and sample it at evenly spaced intervals and get a list of sample points and execute a descrete Fourier transformation (DFT) on those sample points to get the Fourier coefficients. For this article, we are going to go with the second option as it’s more straightforward.

const numSamples = 400;
let pts = [];

function entry() {
    const svgUrl = './images/llama.svg';
    const response = await fetch(svgUrl);
    const text = await response.text();
    pts = await sampleSvgPoints(text, numSamples);
}

entry();

// Might not sample exactly numPts number of sample points, but it will be
// close. Not a concern using the O(N^2) DFT. Using FFT, that can be
// concerning since the ideal number of point samples is an exponent of 2.
async function sampleSvgPoints(xml: string, numPts: number): Promise<[number, number][]> {
    const parser = new DOMParser();
    const doc = parser.parseFromString(xml, 'application/xml');
    let samplePts = [];

    const paths = Array.from(doc.querySelectorAll('path'));
    const totalLength = paths.reduce((acc, v) => acc + v.getTotalLength(), 0);
    const step = (totalLength / numPts);
    paths.forEach(path => {
        const sample = [];
        const n = M.floor(path.getTotalLength());
        for (let i = 0; i < n; i += step)
            sample.push(toPt(path.getPointAtLength(i)));
        samplePts = samplePts.concat(sample);
    });
    while (samplePts.length > numPts)
        samplePts.pop();

    return samplePts;
}

function toPt(pt: SVGPoint): [number, number] {
    return [pt.x, pt.y];
}

Sampling an SVG for points

So then we run the Discrete Fourier Transform (DFT) on those sampled points to find the Fourier coefficients that we want just like for the square wave above. The Discrete Fourier transform equation is the following

\[ \begin{equation} c_k=\frac{1}{N}\sum_{j=0}^{N}{p(j)e^{-i\frac{2\pi}{N}kj}} \end{equation} \]

Where \(N\) is the total number of sample points in our list of sample points we want to transform. For our use case \(p(k)\) is just going to be keying an array essentially. For example, if \(p\) is our list of sample points, then in Javascript it would just be p[k]. The DFT is really the same thing as the Fourier series except we are summing evenly spaced definite samples rather than having a limit as the change between steps reaches \(0\), or equally stated the number of terms being summed aproaches \(\infty\). The DFT also is a way to convert \(N\) evenly spaced sample complex points in the time domain into their representation in the frequency domain. That is, figuring out the strengths of each whole number sinusoidal frequency. This is the direct application of the change of basis to the \(N\)-dimensional basis \((...,e^{i\frac{-4\pi}{T}t}, e^{i\frac{-2\pi}{T}t}, 1, e^{i\frac{2\pi}{T}t}, e^{i\frac{4\pi}{T}t},...)\) as explained a few sections ago in the Complex Exponentials as Sines and Cosines section.

We can implement the DFT fairly easy using two loops, the outer loop calculating each coefficient \(c_k\), and the inner loop doing the numerical integration summation and dividing by \(N\) to get the average strength of each frequency \(k\). This approach is very slow, but will work for decently small numbers of points. This ends up being \(\mathcal{O}(kj)\) which at worst case will be \(\mathcal{O}(N^2)\). We can do much better than this with more efficient algorithms, like the Radix-2 Fast Fourier Transform (FFT) which is on average \(\mathcal{O}(N\log(N))\), however this article is already getting lengthy so that can be a topic for a future article.

import * as M from 'mathjs';

export type DFTData = M.Complex & {
    freq: number;
    radius: number;
    phase: number;
};

export async function dft(cps: M.Complex[]): Promise<DFTData[]> {
    const X = [];
    const N = cps.length;
    for (let i = 0; i < N; i++) {
        let sum = M.complex('0');
        for (let n = 0; n < N; n++) {
            const xn = cps[n];
            const theta = -(2 * M.pi * i * n) / N;
            const c = M.complex(`${M.cos(theta)}+${M.sin(theta)}i`);
            sum = M.add(sum, M.multiply(xn, c)) as M.Complex;
        }
        const { re, im } = M.multiply(1 / N, sum) as M.Complex;
        X.push({
            re,
            im,
            freq: i,
            radius: M.sqrt((re * re) + (im * im)),
            phase: M.atan2(im, re)
        });
    }
    return X;
}

(\mathcal{O}(N^2)) DFT implementation in TypeScript.

Now that we have the coefficients, we can parameterize the curve so we can plot it.

function parameterize(cn: M.Complex[]) {
    return async (t: number): Promise<M.Complex> =>
        await sigma(k =>
            M.multiply(M.complex(cn[k - 1].re, cn[k - 1].im), 
                M.exp(M.multiply(M.i, (k - 1) * t))), [1, numSamples]) as M.Complex;
}

So what we should see from this parameterization of our approximation of a llama is that as the number of sinusoids increases, it will more accurately represent a llama. We can see this in the following animation. Note that “modes” refers to the number of positive frequencies used, and we are also using the \(0\) frequency as well as the corresponding negative frequencies. Thus the number of circles is \(\text{\# of modes}\times 2\).

../../_images/modes.gif

Fig. 14 \(1\to500\) modes of a llama approximation.

From this animation, we can see that most of the work is done in the lower frequencies since it very quickly becomes llama-like. As we add more and more of the higher frequencies, the smaller details of the llama start filling in and the approximation becomes slowly more crisp.

In order to recreate the image at the beginning of the article, with the circles moving around in organized chaos drawing the outline of the llama, we need some code to go through and draw each frequency’s circle. To do that we need the radius and phase which we have included in the DFTData type in the first code block. To do the actual graphics representation, we’ll be using the p5.js library. Note that the code depends on variables defined in the above code blocks. I’ll also provide the full source at the end.

function draw() {
    for (let i = 0; i < complex.length; i++) {
        const cn = complex[i];
        const prevx = x;
        const prevy = y;
        const { freq, radius, phase } = cn;
        x += radius * M.cos(freq * time + phase);
        y += radius * M.sin(freq * time + phase);

        stroke(230, 85);
        noFill();
        ellipse(prevx, prevy, radius * 2);
        stroke(230, 142);
        line(prevx, prevy, x, y);
    }
    points.push(createVector(x, y));
    stroke(230);
    noFill();
    beginShape();
    for (let i =0 ; i < points.length; i++)
        vertex(points[i].x, points[i].y);
    endShape();
    if (time > M.pi * 2)
        points.pop();

    time += ((2 * M.pi) / pts.length);
}

Which will draw for us the very same animation shown at the beginning of the article.

../../_images/llama.gif

The complete source for the Fourier animations can be found here:

adekau/fourier — Drawing with Fourier transforms.

The Daily Front Page 13 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — A Camera’s Bad Evidence
article

One Piece of Flock Camera Data Put This Innocent Woman in Jail for 13 Days

by HotGarbage·▲ 250 points·133 comments·jezebel.com ↗
Police didn't care that Lindsey Isaacs' car was the wrong color

Police didn't care that Lindsey Isaacs' car was the wrong color, and wasn't damaged. They still arrested her because of Flock data.

One Piece of Flock Camera Data Put This Innocent Woman in Jail for 13 Days

In the months since Flock cameras have helped put an easily recognized and hated name to the broader threat of a deeply repressive technological surveillance state–Darth Vader activist performance art and all–I’ve been waiting for a story to come along that would truly crystalize the stakes involved here. Flock cameras, and the tech they now culturally represent, automated license plate readers (ALPRs), are increasingly hated in bipartisan fashion, but it hasn’t felt like there’s been that one news story that could succinctly demonstrate for the average person exactly how dangerous the technology can be for personal liberty. Until now, that is.

Because what else would you call the account of a woman who was arrested by incompetent police based on nothing more than a single Flock camera piece of data, and held in jail for 13 days (partially in solitary confinement) until they realized it was definitely the wrong person? You couldn’t ask for a more egregious demonstration of how this technology is being abused by police who need no additional help in trampling over American civil liberties.

The woman in question is named Lindsey Isaacs. She’s a 23-year-old resident of Palm Beach, Florida. One morning in October of 2025, she woke up at 2 a.m. to find that state troopers were outside her apartment, and a tow truck was currently in the process of confiscating her car, a black Dodge Durango. The police had reportedly used images and data captured by a Flock ALPR camera to connect Isaacs and her car to a deadly car accident that had happened one day earlier, which had resulted in the loss of three lives. According to witnesses at the scene, the deadly collision had been perpetrated by a Dodge Durango. The Flock camera, meanwhile, had recorded Isaacs’ car and its license plate several miles away from the site of the accident, sometime around the time of the incident.

“They said, ‘We have your plate on a Flock camera, and your car has damage consistent with a collision,'” said Isaacs this week, now testifying before Congress. “And I said, ‘Where’s the damage? You’ve got the wrong person.'”

Lindsey Isaacs was wrongly held in solitary confinement for 3 days after Flock cameras falsely flagged her car.

Isaacs was right. It should have taken only a cursory examination of her car to see that hey, this vehicle really didn’t look like one that had been part of an accident 24 hours earlier that killed three people! And oh, wait, do we think it’s relevant that the eyewitnesses were saying that it had been a maroon Durango that had been involved in the incident, and Isaacs’ car was instead black? Nevertheless, her car was instead entered as evidence into a case and impounded, and Florida Highway Patrol issued a warrant for her arrest on April 17, 2026, some SEVEN MONTHS LATER. This, despite the fact that there appears to have been zero physical evidence actually linking Isaacs to the scene of the crime besides a Flock camera sighting indicating she had been nearby that day, and this despite the fact that her car was not actually damaged, something that police had seven months to investigate and ascertain. She would spent the next two weeks in a maximum security jail housing, including 86 consecutive hours that she spent in solitary confinement. She told the U.S. Senate in her testimony, meanwhile, that correctional officers told her she was being put in solitary because of “the severity of the charges.” You know, to the crime she did not commit.

“I was terrified,” Isaacs understandably said to U.S. legislators this week. “I was facing the possibility of spending the rest of my life in prison for a crash that I knew I had not been involved in. I did not know if I would ever get out of jail. At my lowest point, I didn’t want to be alive.”

Yeah! I can understand a person feeling completely hopeless and utterly abandoned by not only an uncaring but actively misanthropic justice system after being arrested and charged with eight felonies, including three counts of vehicular homicide, when you know that you had nothing to do with it! I can understand the utter rage that Isaacs must have been feeling during each day she sat behind bars, wondering aloud why the Florida Highway Patrol wasn’t doing the BARE MINIMUM of investigation of its own into this case, such as noticing that the so-called murder weapon of her vehicle had not actually been involved in a crash. What is an average citizen supposed to do when facing a criminal justice apparatus that simply doesn’t give a shit, and one that is so eager to rely on AI-assisted tools like Flock cameras and ALPRs that they’ll arrest and jail a person before they even bother to check the most basic facts of their case?

According to reporting from The Center Square, after the two weeks she spent in jail, Isaacs only managed to be released “when her attorney was able to present photos to the judge of her possessed vehicle–which, contrary to the claims of the troopers who possessed it, showed no damage.” After that, she was finally granted bond and released from jail, and in May 2026 the state of Florida dropped all charges against Lindsey Isaacs. Around the same time, police arrested another woman on suspicion of the same fatal collision. Just how confident do you think they feel about this perp?

Lindsey Isaacs, 23, testified about being thrown into solitary confinement for more than 3 days after a Flock license-plate camera wrongly tied her Dodge Durango to a deadly I-4 crash. She said she was terrified she would spend the rest of her life in prison for a wreck she knew she hadn’t caused.

Regardless, the true takeaway of Isaacs’ experience is that ALPR technology, whether it’s from Flock or any of the other competitors in this space providing the same functionality (Axon, etc), is dangerous not only because it is so easily abused by police (or even by hackers) to invade the privacy and liberty of citizens, but also because it enables a lazy police department to outsource basic critical thinking to machines and then take action based on a single data point to ruin a person’s life. Imagine waking up in the dead of night to find police officers at your door, claiming that you’d killed three people, with no evidence beyond the fact that your car was seen by a Flock camera. Imagine losing your job following the arrest, and the effect on your professional livelihood and social existence. What kind of monetary figure is the right compensation for being jailed for two weeks, put in solitary confinement and wanting to die? We’ll likely find out as a result of Lindsey Isaacs’ pending civil lawsuit against Florida Highway Patrol troopers.

But as for Isaacs in the meantime, she appeared before Congress this week in the hope of communicating how the surveillance state allows this kind of truly random injustice to befall ANY OF US, at any time.

“I came here today because I want you to understand that surveillance technology does not exist in a vacuum,” Isaacs said. “Information collected by technology can become part of an investigation that affects a real human being. In my case, a Flock camera captured my vehicle a few miles from the scene of a terrible crash. That piece of information became part of an investigation that ultimately led to my arrest on three counts of vehicular homicide and 13 days in jail for a crash I had nothing to do with.”

When police show up at your door, what will you do any differently?

The Daily Front Page 14 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The Nano Rebuilt
article

16GB iPod Nano 3G Upgrade

by Ivoah·▲ 156 points·26 comments·tuckerosman.com ↗
I didn't realize how big of a challenge this would be

I first had this project idea in April of 2020 at the beginning of the pandemic. I didn't really know how long it would be (the project or the pandemic) and I was curious why nobody had tried this before (the project, not the pandemic). At this point, I had no soldering experience, no reverse engineering experience, and while I'd been working with software at some level since I was five years old, to this point I had very little hardware experience outside of working with Arduinos at a very "Adafruit" maker level. I didn't realize how big of a challenge this would be, mainly because I was learning everything from scratch. But now that it's done, I can confidently say that it was worth it. It took longer than it should have, but give me a break.

An Introduction To The Problem

You've probably seen some videos on YouTube of people upgrading the storage in their iPod Classics, pushing it to 1TB, 2TB, and 4TB (whereupon it fails, since it runs out of RAM to handle that many songs). While I wouldn't call the hard drive in the large iPods "user serviceable", it certainly is compared to the NAND chips found in iPod Nanos, Shuffles, and everything else Apple makes now (with the exception of AirTags, I guess). I naively thought that swapping out the NAND chip in a Nano would be easy and it would simply Just Work™.

I chose the iPod Nano 3rd Generation for two reasons. First, it's the newest revision of the Nano that has a NAND chip with legs (which was less out-of-my-league than BGA soldering but still out-of-my-league). Second, it's the Nano closest to my heart because it's the one I had growing up. I loved that thing. People loved their iPods - proven by the popularity of some Australian drummer and snake owner's YouTube channel about iPods that isn't actually about iPods because there is only so much content you can make about iPods.

Taking apart any Nano is hell. It's possibly impossible to do it without destroying it. But, if you manage to get into the 3rd Generation Nano (which I will be referring to as the 'n3g' from here on out), you immediately see the NAND chip.

Figure 1

The iPod's guts revealed for not the first time

Desoldering it is easy enough with low speed hot air. Soldering it is less easy. The first few iPods were done by my good friend Wesley in his garage.

Figure 2

Literally just in his garage

"This is it," I foolishly thought as I held the camera waiting to see that blessed 16GB in the "About" section of the iPod UI. But no. What we got instead was the dreaded Red X.

Figure 3

The Red X

Standing in Wesley's garage, I thought this meant "this chip is unformatted, where's my operating system?" which made sense to me. I tried recovering the iPod and found that iTunes couldn't even see it. Wesley has experience in embedded electronics, and explained that usually there's some sort of table of acceptable NAND in the firmware and, if the chip isn't found in that table, it just stops. I went back home determined to figure out what was going on and mod my way into getting this thing to work. How hard could it be? I just have to find the table and patch it out with the details of the NAND chip, right?

My first discovery confirmed Wesley was right. I found a similar table in Rockbox's n2g port:

struct nand_device_info_type
{
    uint32_t id;
    uint16_t blocks;
    uint16_t userblocks;
    uint16_t pagesperblock;
    uint8_t blocksizeexponent;
    uint8_t tunk1;
    uint8_t twp;
    uint8_t tunk2;
    uint8_t tunk3;
} __attribute__((packed));

static const struct nand_device_info_type nand_deviceinfotable[] =
{
    {0x1580F1EC, 1024, 968, 0x40, 6, 2, 1, 2, 1},
    {0x1580DAEC, 2048, 1936, 0x40, 6, 2, 1, 2, 1},
    {0x15C1DAEC, 2048, 1936, 0x40, 6, 2, 1, 2, 1},
    {0x1510DCEC, 4096, 3872, 0x40, 6, 2, 1, 2, 1},
    {0x95C1DCEC, 4096, 3872, 0x40, 6, 2, 1, 2, 1},
    ...and a lot more...
};

This table had to come from somewhere, obviously, so I needed to find where the n3g's version was in its firmware. It turns out an almost identical table shows up in several places across the n3g firmware. After doing some research and talking to some people in the Rockbox IRC channels, I inherited some half-done reverse engineering work from some developers who, years prior, were trying to port Rockbox to the n3g. This included the Rockbox bootloader and led to my very first success in getting code to run on the iPod. I knew code execution would be the first real hurdle, and the fact that someone had already done this was a huge step in the right direction.

Figure 4

The first time I got the Rockbox Bootloader running on 7/13/2020 (I was very excited)

The Rockbox bootloader uses the Pwnage 2.0 exploit to run code. You can read more about it here and here, but it's basically a stack-overflow exploit targeting a bug in the ASN.1/DER certificate parsing logic of early Apple S5L8xxx BootROMs. Because the entire certificate chain parsing context (der::chain::parse_ctx) is allocated on the stack, and the saved link register (LR) sits at a known offset just past the end of that structure, an attacker can craft a malicious last certificate whose oversized signatureValue overflows the buffer by 344-345 bytes, overwriting the saved LR with an attacker-controlled address. The attacker can place arbitrary executable shellcode there, and the overwritten return address simply redirects execution to that payload, achieving full unsigned code execution at the BootROM level. Once you can run code, you can see and dump code.

The first piece of the firmware we have to touch is what I refer to as the EFI bootloader because that's exactly what it is. I was sort of shocked when I saw this - I always thought EFI (and UEFI, but don't expect Apple to do anything U) was primarily for computer bootloaders. I guess Apple was full-steam-ahead on EFI in the Apple TV, Macs, and who knows what else, so it makes sense. Still, it felt heavy for something like this and, what's more, it made static analysis somewhat harder.

The NAND table in the EFI exists in the NAND driver. I know, shocking, but we can follow the codepath to the spot that identifies the chip ID and from there I found the NAND driver's initialization routine. Basically, during initialization, the NAND driver checks the IDs of the banks of NAND, initializes some buffer memory, the VFL, the FTL, and then opens both of them. It was obviously breaking at step 1, so I swapped out the ID and geometry in the table for the chip that I had.

In the early days, my patching methodology was very annoying. The Rockbox bootloader was capable of reading and writing from NOR flash. Every time I wanted to try a patch out, I had to follow these horrible steps:

  1. In a hex editor, write the patches I want to try by changing bytes. Save it.
  2. Using UEFITool, replace the NAND driver PE32 binary with mine. Save it.
  3. Run it through a binary diff algorithm I made for this purpose but it's probably not very efficient.
  4. Compile that into the Rockbox bootloader.
  5. Upload and run the Rockbox Bootloader which loads the EFI from NOR and unpacks the diff's bitstream on top of it, then writes the modified EFI back to NOR.
  6. The bootloader then boots NOR and I watch for my patch.

This process was horribly manual and not very fun. To further complicate things, I was keeping track of my patches in an Excel spreadsheet. Pain.

Changing the NAND Table's parameters didn't help. I was still getting the dreaded Red X. That image is called bdhw in NOR. Bad hardware. So it still wasn't happy. I wasn't sure why. I needed some sort of introspection. The EFI really doesn't export anything user-visible. The image it shows, I guess (bdhw, bdsw, lbat, etc.), but that's not coming from within the NAND module. So I had two tools up my sleeve.

Tool 1: Spinning

This one is barely worth a paragraph so I'll be brief: I was able to bisect code paths by adding a b . at one branch of a conditional to see if the iPod froze or not. Very cool, very standard. Nothing special about that.

Tool 2: Exfiltrating Data Through The Diagnostic Mode

This one is more interesting. At first, I thought the EFI had no sort of output, it just loaded everything up, showed a boot splash, and then jumped to the binary it was supposed to load. Early on, I figured the answer to "how does the iPod know about its NAND" could be easily found in diagnostic mode's NAND LBA field. But when I looked into it, it seemed to be reading the value from somewhere magical. It would find something in memory - "sysI" - and grab a value at an offset and show it. Diagnostic mode does not deal with NAND itself. So it must be getting passed that information from somewhere, right?

After some more investigation, I discovered that I was looking at the System Information Table. The EFI builds this across several modules and passes it along to the binary it loads. The table includes everything from SysCfg (like serial number, model number, etc.), RAM information, and crucially the NAND LBA count. This part of the table gets created in the NAND driver's entry point after driver initialization. The initialization code returns the calculated NAND LBA count, but what if we bubbled something else up out of there. We're not actually initializing NAND, so maybe we can smuggle some data out of there, four bytes at a time?

Figure 5

Putting the NAND ID in the NAND LBA causes it to show up in diagnostic mode's NAND_SPEC test.

Through a series of patches, this is what I did. The proof of concept for this patch was to smuggle out the NAND ID. That's what's shown in the picture above. This proved that the iPod was actually able to communicate successfully with the NAND chip. Through a combination of Tool 1 (conditional spin-lock) and Tool 2, I was able to find why at this point the NAND driver was failing to initialize.

Why The Driver Was Failing To Initialize

When the iPod comes across a virgin NAND chip, it tries to "production format" it. This involves setting up VFL and FTL structures. This part was failing, and I wasn't sure why. I bisected my way through the production formatting path and found that it ended up in a failed memcmp within a function that writes the very first VFL structures. It erases the block, writes the structure (in this case, a driver signature), and reads it back to verify that it made it to NAND. The failing memcmp causes it to retry a few times before giving up and returning failure, halting the format, boot, and resulting in bdhw.

Through smuggling data out four bytes at a time through NAND_SPEC, I found why it was failing: the verification read was all FF as if the chip hadn't been written. At this point, it was unclear if the entire write was failing or just the first four bytes, but it made the most sense that the entire write was failing. But why?

The FMISS Layer

This is a little bit of an aside because at this point I went down a massive rabbit hole that truly helped me understand the NAND peripheral and how it actually works with the NAND chip itself. I was expecting to see reads and writes to a NAND peripheral with addresses, commands, all sorts of stuff. Instead, what I saw was something passing a binary blob and a bunch of parameters and saying "go". So, what is this?

I'm not sure what it's actually called. I've seen it called FMISS, I've seen it called FIL, I've seen it called CS (code sequencer). It might be all of these, it might be none of these. It's a coprocessor that runs bespoke bytecode (or microcode? not sure on definitions here) that offloads the act of interacting with NAND. It's not used in any other way and it's in the same address space as the direct FMC itself so it's definitely intended to be used with NAND. Either way, it has its own ISA and figuring out what it did was probably the most satisfying part of this whole project (besides getting the final product to work).

You can read about the exact instruction set here, but I think it's more prudent here to talk about how I actually figured out how it works. I think I just made the right number of leaps to get to a mostly-working understanding of everything.

My ground truth was a leaked data sheet of the S5L8700X which is an older chip closer to one found in an n2g. I pored over the NAND section and found no such reference to FMISS or a state machine or anything like that. But what I did find were register definitions (outdated but somewhat helpful) and step-by-step sequences for how developers should write NAND functions. I was really beating my head against the wall for a while asking myself "how does the iPod write commands to the NAND chip?" For example, the READ ID command - 0x90 - never shows up in the ARM code. So it must live in the bytecode, right?

It does. By looking at the Read ID program and the Read Page program, I was able to find those command bytes and I also found there were four bytes preceding it each time. This revelation showed me the basic structure of the bytecode. It was clear that each "instruction" is 64 bits and organized in a weird endianness. I wrote a small C program to just printf the instructions it knew about so I could fill in the gaps with parts of the process that made sense.

It was basically objdump for this bytecode. At first, almost every instruction was "unknown" but as I cross-referenced with the "how do you NAND?" instructions in the datasheet, I was able to pull apart more instructions. The register map was also somewhat helpful because I could tell where in the sequence I was and how much more to expect. I'm particularly proud of how I discovered branching and jumps in the bytecode because loops are heavily used in these programs.

At some point, I was modifying the firmware to confirm my guesses. I replaced the Read ID microcode with my own and saw the results in the NAND_SPEC test results. I was able to write an assembler and disassembler for the bytecode (nothing fancy, just smart enough to produce readable code, I guess). I got about 90% of the way to a complete documentation of this state machine's instruction set. q3k corrected some of the finer points, and together we wrote the FMISS emulation into QEMU. More on that later.

The good news is also the bad news: this had nothing to do with my problem. Understanding the FMISS layer may have been important in an indirect way, but I probably could have completed this project without ever going down this path. That said, I think it was worth it just for that: I'm not sure I'll ever get another excuse to reverse engineer an undocumented instruction set. It was quite satisfying.

This is where the first video I posted ended. Check the scroll bar, we have a long way to go.

Waves

With that digression out of the way, I was now stuck with only one way forward: what's actually happening on the NAND bus? Is there some weird thing happening that this chip doesn't support? I tried to go about this in numerous ways, but the only thing that gave me a reliable signal capture was using a data recovery spider board:

Figure 6

I thought this would be more expensive, everyone say "thank you Aliexpress"

I attached this to a DSLogic U3Pro32 and started looking at what was actually occurring over the wire. And what I found was disappointing but not surprising:

Figure 7

Not surprising because this is exactly what the code says it does, disappointing because I'm not sure why it's doing it. The NAND is outright rejecting page programs. That's odd. I traced through the signals to see if it was issuing strange read/write commands and no, they're your standard commands. It's almost as if the chip is write protected or something.

Is the chip write protected?

The WP# leg is not connected to the pad!

Unbelievably, the WP# leg on the NAND was not attached to the pad. It wiggled like a loose tooth. So I soldered it down and, of course, it got further. But not that much further. It's able to write and verify until, like, page six. Okay, now what?

Just a Bit More

Figure 9

Figure 10

The first six pages were writing beautifully. But then we hit page six and, much like the publication, it was useless and full of noise. I know NAND is prone to bitflips but this was bad.

Figure 11

I also replicated this behavior across a few of the same chips. Is this an insane number of bitflips? I didn't know then, but I know now: this is not an insane number of bit-flips for MLC NAND. But why does this pattern start on Page 6?

This confusing and unrelenting pattern led to my first burnout when I turned my attention to adding fully digital Bluetooth to the n3g. But, as always, I crawled back to this project for more pain.

My friend, Cooper, used to work for one of the big NAND manufacturers and gave me his manager's email address. His manager explained exactly what was happening and why. NAND stores data as trapped charge across a huge array of cells. In Single Level Cell NAND, each cell holds one bit: an empty cell reads as a 1, a charged cell reads as a 0. Fresh from the factory every cell is empty, so every bit is a 1. "Programming" selectively pushes charge into cells to turn them into 0s, and the only way back to 1 is erasing the whole block.

Multi Level Cell (MLC) NAND is the next step. Throwing more cells at the density problem gets expensive - the physical device grows large and complicated - so instead we pack two bits into a single cell. Define four voltage levels, assign each a two-bit value, and now one cell holds two bits.

You might already see the problem here: with four levels crammed into the same range, the margin for a misread shrinks. The first bit is easy to decide - its margin is wide - while the second bit is far tighter. That's the trade-off behind MLC, TLC, QLC, and beyond: you need smarter error correction to handle the bitflips and the tighter margins, but the chips stay "cheap" while storing enormous amounts of data.

That explains the noise, but not the pattern. The two bits in a shared cell don't belong to the same page - they're the nth bit of two different pages, and the datasheet tells you which pages are paired. On this chip, pages 0 and 6 share cells. The easy first bit goes to page 0, so it reads clean. Page 6 rides on the tight second bit, so it comes back noisy.

This left me with two choices. I could either figure out how to beef up the error correction calculations done on the iPod, or spend the money and get the expensive SLC chip. I opted to kick the ECC can down the road and swapped the chip on a fresh iPod with a 16GB SLC chip and of course it worked. The EFI was able to format the chip successfully and we made it to disk mode!

So that's it, right? I just have to patch disk mode in the same way and I get a 16GB iPod Flash Drive! No. No, that's not what happened. Disk Mode is complicated. It's basically a stripped down version of the iPod's full operating system - both are based on a real-time operating system called RTXC. So it was back to the drawing board to figure out what was going on.

The iPod speaks SCSI over USB, and the SCSI "READ CAPACITY" command returns the number of blocks and the size of the blocks for the device. Disk mode's capacity math works out how many logical blocks the device has by dividing the logical block size by the NAND page size and scaling by the page count. On every chip Apple ever used in the n3g, that ratio is a sensible whole number - a 4096-byte page gives 1, a 2048-byte page gives 2. On an 8192-byte page like the new NAND has, it computes 4096 / 8192 = 0. That zero then lands in two different places, each failing differently. The page converter multiplies by it, so every request becomes "zero pages." And the capacity math divides by it, which faults outright - a data abort, a panic, a reset, and a boot loop.

So I patched it to calculate the ratio off of 8192 without really knowing what it did because that's the change that made the crash stop. With that, the device came up but it never mounted anything. I heard the "something is plugged in" Windows chime (don't worry, I was developing in a Linux VM) but it never went beyond that. So I looked to dmesg and found:

sd 2:0:0:0: Attached scsi generic sg0 type 0
sd 2:0:0:0: [sda] Unsupported sector size 8192.
sd 2:0:0:0: [sda] 0 512-byte logical blocks: (0 B/0 B)
sd 2:0:0:0: [sda] 8192-byte physical blocks

This one stumped me for a good, long time. It led to my second burnout, wherein I focused my efforts on QEMU and other rehosting projects for the iPod, and also repurposing the iPad 3rd Generation into a touch screen you can plug in and use with any computer. I have a strange way of stepping away from projects.

Rehosting

I want to digress again and talk about my rehosting efforts since they pay off later in the story. There are two avenues I pursued basically in parallel:

Full Emulation

I thought it would be cool and useful to get an iPod fully emulated. That way I could get some dynamic analysis and stop with the painful bisection methods above.

My first emulator used the Unicorn Engine. It was able to make it all the way through the BootROM (so SPI and GPIO were basically working) and most of the EFI, but it was hilariously slow because it was emulating every instruction one by one. Then devos50 published his QEMU fork emulating the first-gen iPhone, so I forked it and went in. I knew QEMU was the right choice from the start but it was a bit intimidating to start from scratch implementing, well… everything, because I wasn't sure how to write QEMU code. Seeing the diff between his work and the base he forked from gave me confidence that I could do it too, so I dove in. And it was doing pretty well. Big breakthroughs at first were the I2C peripheral, the hardware JPEG decoder, and the FMISS instruction set in the NAND peripheral.

Later, a member from the iPod Nano Discord server named Iscle forked a more modern QEMU version to build an iPod Classic emulator. We're working on emulating the same chip, and his project layout didn't inherit the strange organization of devos50's fork, so I ported my nano-specific work over. I pushed it further and it now boots disk mode and RetailOS and enumerates over USB/IP, so you get a virtual iPod virtually attached to your machine. There are still a massive number of things that don't or will never work, but for the 16GB project, it's basically sufficient. It certainly helped with the below efforts. Here's that project.

Figure 12

QEMU running RetailOS - it's not complete, but it does run!

User Space Emulation

Perhaps a more novel approach to dynamic analysis was just running the NAND driver in Linux Userland. I thought of several ways to do this, but I figured the easiest way to do it was to load the NAND driver from the n3g's EFI bootloader into memory and just jump into it. I'd patch out the parts where it actually calls FIL functions that talk to the NAND, and then boom, I'd be able to initialize it and FTL_Read forever and ever.

I targeted the Raspberry Pi (3b+ in my case) because it's a 32-bit ARM CPU just like the iPod so it should just run the code the exact same way. It did, which is cool, but I ran into some very interesting issues. Here are the highlights:

  1. I've never dynamically allocated memory that I later planned on executing from, so I had to learn to do that. mmap with PROT_EXEC was the answer.
  2. A lot of variables in the NAND driver are statically allocated and there are too many references across it to reliably patch them all. So I made mmap give me specific addresses with MAP_FIXED. It's definitely a code smell, but the kernel doesn't seem to mind. I also did this for the NAND peripheral region (0x38A00000) because some functions write directly to these registers in ways we don't care about.
  3. I patched the pointers to the FIL functions so I could step in and supply any data I needed (and log/instrument/otherwise observe what was actually happening without thinking of the complexities of the FMISS layer).
  4. I patched the pointer to the memory allocation service so it'd just use regular malloc.
  5. Patched some other function pointers so they didn't point to inaccessible memory regions.

And then I called the driver init function which is conjured from a pointer. After I got that to work, FTL_Read also worked (just kidding there were many more segfaults at first but what're you gonna do?). I dumped the entirety of the NAND sequentially counting up FTL pages until FTL_Read() != 0.

Later on I got this working on qemu-arm so I didn't need a Pi, and also got the driver from Disk Mode working which is very useful because that driver has all of its log messages intact. So I replaced its internal log buffer with calls to printf and got some good insight into the inner workings there as well. But the big thing this did for me that I cannot overstate enough: it showed me that this project was possible. By feeding it geometries of 16GB chips and watching it format and use the virtual NAND properly… it's what actually made me continue working on it. And here's that project.

A New Approach

Back to our regularly scheduled program. So far, my plan had been to replace the relevant 4096 values with 8192s. I was truly approaching this with trial and error, trying to find some permutation of replacements that didn't break everything. I never found one. It would either crash, or it would log, mockingly:

sd 2:0:0:0: [sda] Unsupported sector size 8192.

Pretty clear what's happening here, but why is a power of two unsupported? The answer is that 4096 is the cap of what Linux (and I think every OS?) is willing to handle. It would never matter how internally consistent I made the firmware. It's gotta be compatible with the computer you plug it into, and 8192 is not. This left three options:

Idea Verdict
A Just use 8192 and patch the kernel to support it Would make the iPod Linux-only - the only platform with a path to native support - and also I have no idea how.
B Find a 16GB NAND with a 4096-byte page I don't think this actually exists, and if it did I'd be paying the GDP of a small nation to get it.
C Report 4096 over SCSI, use 8192 in the NAND driver, translate in between Spoiler alert: yes this will work

The very simple thought that took too long to hit me: the logical block does not have to be the physical page. An 8192-byte page is exactly two 4096-byte blocks. So let the entire rest of the firmware go on living in the 4KiB world it was designed for, and put a translator at the single point where requests cross from that world into the chip:

everything above speaks
4 KiB logical blocks

2:1 translation
(our code)

the NAND stack speaks
8 KiB physical pages

Logical block numbers are divided by two (n >> 1) - we return the lower half if n is even and the upper half if it's odd. Writes are the same arithmetic plus a read-modify-write, because you can't program half a page. To change one 4KiB block you fetch the 8KiB page it lives in, splice your half into it, and write the whole thing back. That costs an extra read on partial writes and roughly doubles write amplification on small random ones. It's not great, but it's basically our only option.

But the point of this patch is that nothing above or below the bridge changes. The USB stack, the SCSI layer, the command handlers, the transfer executors remain mercifully untouched. This half-page approach brought structure back to this project and kept it from being an endless permutation hunt of "what bytes break everything?"

Patching Disk Mode

While all of my efforts prior to "A New Approach" truly focused on the EFI layer, I think working in Disk Mode is easier for a number of reasons:

  1. It has logging
  2. It's based on RTXC which sounds horrible to work with and, yeah, it was, but I'm getting the complexity out of the way first - RetailOS is also based on RTXC.
  3. It's not as complex as RetailOS while still having a very testable "is it a flash drive?" goal.

Here are the stories and patches that make up Disk Mode and where the bulk of the work was:

Another Block In The Wall

The translator, as shown in the embarrassingly simple diagram above, has to sit below everything that speaks in logical blocks (the USB plumbing, SCSI command dispatcher, probably some others like firmware update routines?) and above everything that speaks in physical pages (the FTL).

I struggled for a while to follow the rainbow of pointers and indirection, but I landed in a function pointer table hanging off each logical unit with pointers pointing to functions to read, write, get block size, get capacity. One layer further down, I found where the conversion actually happens:

ratio = 4096 / pageSize
lpn   = ratio * sector
count = ratio * count

Apple's own 4KiB-sector-to-FTL-page converter handles every chip whose page is smaller than a sector - a 2048-byte page gives ratio 2 and the multiply does the right thing. My chip needs a ratio of ½, so it computes zero, and then multiplies the count by it. It's the same song and dance as before.

The translator, however, only handles data. Three other things have to agree with it:

  1. Report the right block size. The block size function returns the raw hardware page size out of the device structure, and READ CAPACITY hands that number to the host unedited - this is where 8192 was escaping into dmesg in the first place. It now returns 4096 unconditionally. Two instructions: load a constant, return.
  2. Fix the capacity math that was crashing. The capacity path is the place that divides by the ratio, so with the ratio stuck at zero this path divides by zero, which is always a bad time. The ratio is now known and fixed, so the whole computation collapses to a shift: logical block count is page count times two. Load, shift, store, then four no-ops where the division used to be.
  3. Report the right number of blocks. There's a separate path that answers "how many blocks do you have," used to range-check incoming requests.

Shockingly, the third one was the hardest. For some reason, it would return "five blocks" on a chip whose geometry the FTL had otherwise worked out correctly. I never did get a satisfying explanation for that. I decided instead to compute the capacity directly from the FTL's own geometry structure: user page count × 2 - a reserve off the top. The reserve is there because the geometry figure is gross rather than net. The FTL keeps a slice of the top of the chip for itself for internal purposes, so the highest pages the geometry claims to have aren't actually usable.

I shaved 32,768 pages - 256MiB - off the top and kept it because it worked. That number is empirical, not a guess: at 2,048 pages the failures came back, reaching 2,107 pages down from the top of the chip. So the reserve is at least ~16.5MiB, I verified 256MiB and left it that way. I might be able to shrink it, but I'm happy with "it boots."

Finding a Home for the Patches

Now I needed somewhere to actually put this translator, which is a problem because generally in binaries, compilers pack things together when they can because it just makes sense to do it that way. I went looking for a stretch that was already empty - not "probably unused," but actually verified nobody reads it or points at it. I did eventually find one and put my code there.

The patching itself ended up being simple. For each hook I either replaced the function's entry or planted a branch mid-function. ARM branches reach ±32MiB so this is one instruction, no relocation math needed. And since I'm only touching the first instruction, the rest of the original function is unreachable. Maybe it would have been better form to just replace the function wholesale, but I didn't. It ended up being a decent thing to do so I could refer back to the original implementation later.

While I was in there I also grew disk mode's memory allocation pool. This had nothing to do with the 4K/8K translation, it's just that with an 8KiB page (and a lot more of them) you need more RAM to keep track of it all. Six spots got bumped to larger constants and the FTL finally had room to work with.

Two things I learned while working with the patches:

First, the scratch page has to play by the SoC's rules. My read-modify-write needs somewhere to stage a full 8KB page, and that page is a DMA target, so I couldn't just grab an address in RAM and hope for the best. What I had to do was ask the firmware's own allocator for a buffer so it would be guaranteed to be usable. I also had to use the uncached memory alias address for it. This might make things slower, but it also makes them correct and I don't think about freshness of data.

Second, I disabled another speed thing for simplicity. Disk mode's SCSI stack has two ways of ingesting data: the easy one that fills one buffer over and over again, and a smarter one for big requests that ping-pongs between two buffers so the chip and USB controller are both busy at once. I worried this would cause a lot of issues trying to get the translator right, so I took it out. There is a performance hit, but it only slows the firmware transfers and disk-mode operations a little bit, so I'm not losing sleep over it.

Erasing Planes

Everything up to now has been about mapping the math from one world to another. Now we can move on to a completely different class of problem: chip structure.

The chip's structure is encoded in the device information table that we've modified. The firmware believes the NAND chip is organized in blocks of 256 pages. The chip actually has blocks of 128 pages, arranged in two planes, and the plane is selected by a single bit in the row address. So what the firmware calls one block is really two physical blocks side by side.

For some reason, the write path programs both planes. It walks pages 0 through 255 of a "block," and pages 128 and up land in plane 1 because of that address bit. The erase path issues one erase command with that bit clear. Plane 0 gets erased, plane 1 does not.

On a virgin chip you'd never come across it because plane 1 is blank. However, when the FTL goes to recycle a block it thinks is clean, half of its block never gets erased. The first half of the block ends up on plane 0 which is erased and ready to take new data. However, the second plane never got erased, so the chip either rejects the write because you can't overwrite data in NAND without erasing, or it does try to write it and you end up with some weird intersection of what was there and what you meant to be there because NAND can only write zeros. The FTL then reads back its own metadata and finds a logical page number that belongs to something else.

The fix is exactly as easy as you think it is: just erase twice.

erase(row) # plane 0 erase
wait
if plane 0 succeeded:
    erase(row | plane_bit)  # erase plane 1
    wait

The implementation is less of a joke, because the erase isn't a single instruction, it's a whole invocation of an FMISS program. Running the same erase a second time means putting those registers back the way they were, setting the plane bit in the row buffer, and re-starting the bytecode program. Then the trampoline branches back into the original function, so the firmware's status check and return run exactly as before.

When An Erased Page Isn't

The driver reads a page in chunks, and for each chunk the engine recomputes the syndrome/checksum/whatever, corrects what it can, and returns a verdict: clean, corrected, or uncorrectable. Uncorrectable means the data is destroyed. The driver believes it, because on a healthy chip it's true. I think this is a function of being a page size twice as large as the ECC engine expects, and I think that has to do with how it's configured.

The hardware has a "this page is blank" indication, and at smaller page sizes it works. At 8KiB it never gets set. So the ECC verdict is the only signal left, and for a perfectly healthy empty page it's wrong. This is bad because everyone asks "was this page readable?", gets told no, and each does its own thing with that answer. The bad-block scan marks good media bad. Garbage collection treats the page as dead and stamps it. They're doing the right thing with the wrong data.

The fix is to treat the per-chunk status as a lie, but still use the page-level verdict to decide when to look at the data. That's a sufficient answer, I think, because the garbage pattern isn't random. The ECC engine's deterministic mangling of a large all-0xFF page is identical every time, and recognizing the first eight bytes is enough. There's the tiniest chance that this introduces an edge case of an edge case of an edge case, but those chances are, like, one in eighteen quintillion. If it ever happens, I'll buy a lottery ticket.

The scrub fixes reads, but the FTL garbage collector has its own check. It treats an unreadable page as dead - stamping it with its own "this page is dead" marker so nothing touches it again. Under the poison bug, blank pages read as unreadable, so GC was marking erased space as bad. The collector's check needed the same patch: a page that "failed" only because it's blank isn't bad.

One thing I didn't expect: "erased pages read back as poison" turned out to be a property of the driver, not the chip. The EFI's copy of the same driver configures the ECC engine differently, and on that read path erased pages come back as clean 0xFF with a blank verdict. Same silicon, different firmware, different truth. The ECC is a black box to me and I haven't really pulled apart what the configurations mean. That can be future Tucker's someone's problem.

This fix is validated on hardware and only on hardware. The emulator has no error-correction engine - its NAND peripheral model knows exactly two answers, "good" and "blank", and is structurally incapable of saying "uncorrectable", so the bug literally cannot exist there and there is nothing to test against. The two-plane erase, by contrast, is reproducible in emulation (in both QEMU and in the userspace harness): on a modeled plane-split chip, the fix doubles the erase count (7,796 operations stock, 15,592 patched) and takes plane coverage from zero to all of it. But the ECC fix? I'd rather keep it as much of a black box as possible.

How An iPod Puts Itself Back Together

It's a little odd that I started from disk mode and worked my way out. Before my "new approach" I was working on the EFI, but once I made it through to Disk Mode I sort of abandoned it, stubbed out NAND things so it'd make it to disk mode, and worked on disk mode. Once I got the 16GB bit working there, I moved onto the rest of the stack. I think it's important to know how an iPod goes from blank to an iPod, so here is the whole process:

Stage 0 - the BootROM - Burned into the S5L8702. This is the same across all devices that use this chip (n3g and Classic). Apple cannot patch exploits found in this layer, so finding a way in on this layer for any device means you own it in a way they can't fix. On a normal boot, it reads an image header out of the NOR flash, checks its signature against a key fused into the AES engine, decrypts it, and jumps to it. This is also where DFU mode exists. It decides to enter DFU if it can't load software or if a GPIO is in a certain state (driven by the clickwheel, which makes the determination based on how long Menu+Center was held).

Stage 1 - WTF - I don't know if anyone knows what WTF really stands for. I've seen "Where's the firmware?" and "What's the firmware?" You send a WTF payload to DFU mode and it checks and executes it. It runs entirely out of RAM, and its job is to receive NOR contents and write them.

Stage 2 - uploading Recovery - You send WTF the recovery image which is the NOR contents WTF is waiting for: a temporary EFI, disk mode, etc. I say temporary because the next step rewrites it all with permanent and identical versions. When the entire payload is sent, WTF writes it to NOR and restarts the iPod. The expected result is that the temporary EFI decides to enter Disk Mode for full recovery.

Stage 3 - uploading Firmware - Disk Mode enumerates as a USB SCSI device and waits. It responds to normal SCSI commands so you can work with files, but there are some special commands that send firmware that serve both the recovery and update path. Disk mode lays out a partition table and writes the image into a container starting at sector 63.

Inside that container are the pieces the rest of the boot needs:

Entry What it is
rsrc every UI resource and font, ~78 MB of it
osos RetailOS - the actual iPod operating system
aupd the updater, which carries the permanent NOR contents (EFI, Disk Mode, etc.)
hash integrity data

Stage 4 - AUPD - When firmware is done uploading, disk mode restarts the iPod. The EFI (still temporary at this point) sees aupd is present and decides to boot it. The updater's job is to reflash NOR with the permanent EFI, and then mark itself used so it never runs again.

Stage 5 - RetailOS - Now the iPod is able to boot from start to finish into its fresh install of RetailOS.

This has implications for how we patch things:

  • Every binary that works with NAND has its own driver. The good news is that they're all the same driver (sort of). The bad news is that that's a lot of patching. To be fair, I'm not sure what the best way around this would be. I assume a different architecture could have been "use the EFI environment for everything" but that probably isn't worth it.
  • There are two identical copies of the EFI and Disk Mode. Not bad by itself, it's just more work to find and patch those binaries and keep it straight. So "patch disk mode" doesn't mean patching one program, it means finding every place a copy of that program is living and patching each of them identically - the copy in the recovery image I send over DFU, and the copy riding inside the updater that gets written to the chip. Luckily, there's only two. So that's good.

There is some good news, though. AUPD's NAND driver is exactly the same as the one that ships in disk mode, so we don't need to rewrite anything for it to work. We just need to relocate them: the reset routine, both erase paths, the ECC check, the readback-verify check, bad-block check, the garbage collector's tombstone suppression. Same instructions, same registers, same everything… just moved!

So AUPD doesn't get its own patches. It gets the same patches, assembled with a different set of addresses passed to the assembler. It's beautiful to see. And it's also the only time I got anything for free.

The EFI and RetailOS were not so kind. The EFI's driver is in THUMB so it's the same patches, just in a different instruction set. RetailOS is very similar, but it has a UI that could get in the way of things. I was hoping RetailOS and Disk Mode were going to be the same like AUPD and, while the patches did come over the same way they came to AUPD, it needed a bit more. We'll get to that.

Patching the EFI

The EFI runs before anything else, every single boot. Its NAND driver is the one that formats a virgin chip, builds the bad-block table, and publishes a protocol saying "the flash is ready" that the boot code waits on before it will hand off to anything at all. If that driver doesn't finish, nothing boots.

It's also structurally different. It probably came from the same C code as the other NAND drivers, but it's compiled to THUMB, so the patches wouldn't Just Work™. I rewrote the patches for THUMB and they definitely should have worked. They maybe would have if not for a very stupid mistake I made.

The Agony of Debugging the EFI

Working with the EFI is challenging because it's difficult to statically analyze and very difficult to get any insight into what's going on inside. The main issue was that there were no debug logs anywhere. The EFI doesn't even have a UART driver but, if it did, the logging present in Disk Mode and RetailOS is simply missing from the EFI copy of the NAND driver.

So each experiment consisted of a tedious loop: build an image, upload it, boot it, watch the screen, wait, power cycle, hope. And what it returned was one bit: did the logo freeze, or didn't it?

I burned a disgusting number of those bits on theories that were wrong. And sometimes, I was misreading the one bit I did get. For a long time I read the frozen logo as "the driver is stuck in a loop." It wasn't. On failure the driver returns an error as it should, and then simply never publishes the "flash is ready" protocol. A driver failure and an infinite loop caused by a failed assertion look identical from outside. And I discovered emulation wouldn't solve all of my problems.

The Emulator Was Too Perfect

QEMU and my userspace rehost sped up my iterations since I didn't have to rebuild and reflash anything on real hardware, but it didn't do anything for my confidence. I could get the firmware and the emulator wrong, and two wrongs don't make a write (at least, not in the right place). But in this case, I realized something that I probably should have realized earlier: modeling an idealized hardware scenario might have been hurting me more than helping me.

For example: the model's NAND could not fail. Programs always succeeded. Erases always succeeded. The status register always came back ready with the failure bit clear. Which sounds like a reasonable simplification until you try to trace down an infinite loop you think is being caused by an assertion. These assertions were littered all over the NAND stack, some of them firing if the FTL or VFL are in bad shape, possibly because of a bad block or something. And I couldn't model those. So, as I was chasing this down, I needed to make these failure paths reachable.

So the model had to model the real world with bad blocks. First, I modeled the superblock erasing thing I mentioned earlier (my original model erased full superblocks, which the real chip never does). I also added the ability to specify program and erase errors at specific blocks so they model NAND wear.

With those in, I could reproduce the hang. Well, a hang. We'll get to that.

Bad Blocks Are Odd

Chips ship with bad blocks. The manufacturer tests them at the factory and marks the failures, and the mark lives in the spare area of the first couple of pages of the physical block that's bad. A driver's first job on a new chip is to walk the media, find those marks, and build a table of blocks to avoid.

The EFI's scan addresses pages relative to its 256-page superblock. Depending on which scan variant is selected in the device info table, that means reading superblock pages 0 and 1, or page 255, or both ends. Superblock i is physical blocks 2i and 2i+1, and every one of those variants reads pages that live in block 2i or at the very end of 2i+1. None of them ever reads block 2i+1's pages 0 and 1.

So a chip with a bad block in an odd position, whose even partner is fine, reports a clean bill of health for a superblock that is half broken. The FTL trusts the table, programs the bad half, the program fails, the FTL's context never gets written, and the driver does the correct thing: it returns an error and never publishes the "flash is ready" protocol. The boot code then waits forever on a protocol that will never get published. From the outside that is indistinguishable from a hang.

The fix is to read four pages instead of two - pages 0 and 1 of both physical halves - and mark the superblock bad if either half is marked, which is the correct rule anyway: a superblock you can't use half of is a superblock you can't use.

The chip I was using in my iPod had exactly one bad block in an odd position - block 91 - and block 90, its partner, happened to also be bad, so the scan caught it correctly. I found this bug looking for another bug we'll talk about later, but it did not actually manifest on my setup. But I'm glad I found it - otherwise someone with an odd bad block would face a problem.

But that didn't solve my problem. The EFI still hung.

Emulation Lets Me Down Again: Part 2 Electric Boogaloo

The current build of patches was working totally fine in QEMU. The EFI happily formatted the chip, disk mode would happily read, format, and present a SCSI device that would happily save and keep files. But on real hardware, this wasn't happening. The EFI was freezing on the logo.

Emulation had paid dividends up until now. All of my fixes to the EFI layer (and all layers, honestly) thus far were found in the emulator by making it more like the hardware I was working with. Injecting faults, refining models, doing everything I could to make it work exactly like the hardware. It turns out this hang was that class of problem as well, but it wasn't an issue with my code. It was an issue with QEMU itself.

I trusted QEMU's modeling of the ARM926EJ-S to be completely accurate. After all, QEMU has been used in production systems for, like, ever. Any bugs in the fundamental parts of the codebase must've been found by now, right? The answer is no - that's not true. I don't remember how it happened, but during patching, I was noping out several instructions. I guess I looked up "THUMB nop" online and found 0xbf00. QEMU happily executed this on its ARMv5TEJ core implementation. But it should not have.

0xbf00 is a THUMB-2 encoding. On a real ARM CPU that only supports THUMB-1, that's an illegal instruction which causes a hang. QEMU was never going to catch this for the same reason it never would have caught the bad block stuff: the model was wrong. I replaced it with the right nop encoding and yeah, it worked.

I was excited I could make a contribution back to QEMU but no, someone had just beaten me to it.

But that's it! EFI now seems to happily reformat things. Three layers down, one to go. And the last one is the most complicated and most important one: RetailOS.

Patching RetailOS

RetailOS - which I sometimes refer to as osos since that's the name it loads by - is the iPod interface we all know and love. I thought this would be easy too since I knew this also had the same NAND driver as Disk Mode and AUPD. And that part was, in fact, easy-ish. I ported everything over (matching the different ABI as I went) and it sorta just worked… kind of. Based on emulation, I could see it doing the right thing with NAND but osos still wouldn't completely load. In fact, it died in a data abort. I found several bugs in my bridge, but none of them seemed to solve the problem. The main one was that my code wasn't null-safe and would happily try to chug on if the allocator gave back a zero when we requested scratch memory. So I was DMAing over the exception vectors.

I'll spare you the graveyard of theories I had about why this was broken for the sake of your scroll wheel and also because I didn't write all of them down. They were all small issues that I ironed out but weren't actually the cause of my problem. The cause of my problem was me. I just wasn't thinking.

RetailOS shares one allocator between the NAND stack and the user interface. When I'd ported over the FTL's memory pool expansion, I simply copied disk mode's numbers, which were hardware-validated and known good. But disk mode doesn't have a large multi-part UI, does it? Because the one pool had to serve both customers, it was running out much, much faster.

The problem shows up when it begins to load fonts from the rsrc partition. It allocates glyph bitmaps happily for a while and then, around the seventieth one, the allocator has nothing left and returns null. The bitmap code also isn't null-safe. It writes through that null pointer - into the exception vector table, again - until the entries for the most common processor exceptions are corrupted. Every system call after that point lands on garbage, loops forever in an undefined-instruction state, and the watchdog gives up and reboots the device.

The fix, obviously, is to size the pool to what the FTL actually needs rather than to what I blindly copied from disk mode. And the only reason I could do that with any confidence is that the allocator keeps a running total of bytes handed out, at a fixed location, which can simply be read.

As far as I can tell, it never frees anything. Normally that's a bad thing, but in this case that means that the number here is the high water mark. The FTL wanted 1.15MiB, the UI wanted the rest, and I had handed the FTL 3.5MiB because those were disk mode's numbers. The fix is to size the arena to the measurement instead: two megabytes of pool, 1.5MiB of which belongs to the FTL. And the measurement holds - I read the high water mark again after the change and it came back the same. 1.15MiB before and after. The FTL's appetite is set by the geometry, not the workload, which means those extra 2.35MiB had been purely wasted.

While I was in there, I put guards on the bitmap allocator. With the pool sized correctly they should never fire, but the sequence I'd just traced - allocator returns null, bitmap code writes through it, vector table dies - shouldn't be possible even once. Failed bitmap allocations now zero out their own size, so the worst case writes nothing instead of 8192 bytes through a null pointer, and the store that sets and clears bits checks its pointer first.

With the pool sized to what the FTL actually needed instead of what disk mode had blessed, the seventieth glyph got its memory. So did the eightieth, and the hundredth, and every one after that. The next boot, the text rendered. And that was all it took.

It Works… Almost!

Settings → About says 15GB (the reserve doing its job, since (1,982,464 − 32,768) × 2 sectors × 4KiB ≈ 15.97GB which About floors to 15). The filesystem gets created on the chip and survives a reboot. The device boots, mounts its resources, draws its menu, and plays music, on a chip twice the size of the one Apple designed it around and with an entirely different page geometry. This is the sight I wanted to see after 6 years. Finally.

Figure 13

I replaced the broken screen once I got this far, but this is the first image of a 16GB iPod Nano 3rd Generation!

But there's one more problem I had to face: it was a bit unstable.

Power and Partition Problems

Once RetailOS was stable enough to actually use, a new problem showed up: the iPod worked, and then it stopped working. After a bit of use (syncing, poking around, etc.) I'd be greeted by bdsw - the "restore with iTunes" screen - and it turns out that it was two different problems. Easy to fix problems, mercifully.

Syncing music through iTunes didn't kill the iPod during the transfer which was good to see, but it died when I ejected it, soon after the "OK to Disconnect" screen was done indexing (or, sometimes, during it). When it came back, it gave me bdsw which said to me that something was probably corrupting the filesystem partition so bad that it was unable to load osos subsequently. Syncing something as little as five songs all the way to syncing thirteen gigabytes caused the failure in exactly the same way. Fixing it was as simple as recovering it again (even just the firmware step, skipping WTF and replacing the NOR contents), but that meant losing everything in the data partition because it would rewrite the table.

The actual root cause was the battery. It's degraded, and USB alone can't carry the current the NAND stack draws during a program/erase burst. With a bench supply on the battery leads, it certainly got more stable. The iPod survived more reboots, but still wasn't exceedingly stable. After some use, bdsw would come back. And also games wouldn't launch. That's… odd. If the FTL is intact then nothing above it should be complaining about it, right?

The device sat in bdsw, so I did the boring thing and dumped the disk from disk mode. The partition table had one entry: a FAT32 partition. There was no firmware partition entry at all. The firmware volume - the MSE container with rsrc, osos, and aupd in it - lives at LBA 63 and runs 128MiB, to LBA 32830. The FAT32 data partition started at LBA 256 and ran to the end of the chip. The overlap was 99.4% of the firmware volume.

Once again, the problem was me all along. I was using gparted to format the disk. Make an msdos partition table, make a FAT32 partition, let the iPod create its system folders, sync with iTunes, life goes on. But that turns out to be the wrong thing to do. Restore writes the firmware volume, then a repartition step zeroes the front of the disk and drops the firmware entry, then iTunes fills the FAT32 filesystem from LBA 256 upward - directly over the boot image. The rsrc partition goes first, so games break before boot does. Then the directory goes, then osos, and the EFI's boot chain fails, and you get bdsw. Filling the iPod with music is the mechanism that destroys it.

My tool should be the one authoring the partition table, not gparted after the fact. In fact, my tool doesn't write the table, it tells the iPod to write the table. There's a repartition command that exists on the device and is correct, but nothing in my restore path ever called it. Partitioning was a manual step in my workflow, and the hand-run mkfs.fat left its signature sitting at LBA 256 as a confession.

Once it repartitions, it reads the table back and re-reads the MSE directory from the device, requiring rsrc and osos to be present before it calls the restore complete. The data partition now starts at LBA 32831 - one block past the end of the firmware volume. You still need to use something like mkfs.fat but under no circumstances should you make your own partition table.

Complete!

Once the power and partition problems were solved, I was able to use the "fill free space with music" option in iTunes and load the iPod completely with music and have it survive. I was also able to put a bunch of movies on it - I transcoded and uploaded all of the Harry Potter movies and had a bunch of music on there. Pretty neat!

Finally, this iPod completely works and has 16GB worth of data on there. A long on-again-off-again project that has lasted longer than my real-life relationship finally comes to a close. I've messed with iPod memory, iPod Bluetooth, iPad screens… I think I might leave behind modding Apple products for now and focus on other things. I have some ideas, but I don't want to become a 100% tech-project channel. Either way, I'm very proud of this.

All of the code for this project is available on GitHub: lemonjesus/iPod-n3g-16gb.

Disclosure of AI Use

I believe if AI is used on a project in some meaningful way, it should be disclosed. Given this project has been running for longer than vibe-coding has been around, these disclosures really only apply to the last few months of progress where it helped me get over a few humps that otherwise would have taken me months and more burnouts. I have overarching principles on my personal use of AI that you can read about here, but for this project specifically:

  • No part of this was shipped without me reading and understanding it. Every line of code and documentation is either written by me or is something I approved to be written. I've reviewed every line either I or an LLM has written.
  • Various LLMs were used to help me understand patterns in firmware that I had never seen before and help me make sense of it. Increasingly important as I got deeper into the software stack (the deeper you get, the more tangled the binaries become, and it was nice to have that help).
  • All but one patch category was discovered/piloted or written by me. The patch category an LLM (in this case, Claude) discovered that I can't take credit for: the ECC Scrubbing Patches.
  • This writeup and the accompanying YouTube video's script were 100% written by me.

Acknowledgments

When this project started, tooling was sparse, the community scattered, and overall everything was very "ten years ago." Now, there's amazing tooling and a thriving community working on iPod Nano stuff. The ones that were specifically useful to this project:

  • q3k's wInd3x - not only is this exploit/tool/etc. what my patcher is based on, but it's also what replaced tracking EFI patches with spreadsheets (because the madman wrote an EFI modification engine into it).
  • slackware - was one of my first points of contact and helped me get started with the software research side of things. He's also incredibly active in the community and manages freemyipod.org and the associated GitHub projects (where wInd3x and QEMU and Linux for the iPod reside).
  • benedikt93 and Cástor Muñoz - laid a lot of groundwork for reverse engineering the NAND stuff. Also, Castor wrote the Rockbox bootloader for the n3g which was the only way I had to work with the device at the beginning. Very grateful their work wasn't lost to the sands of time.
  • Countless others from the Discord community who have contributed to the zeitgeist of iPod Nano modding. We've cultivated a lovely community, and you should pop in and learn more and contribute if you made it all the way to the end of this writeup!
The Daily Front Page 15 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — In Memoriam: Johannes Doerfert
article

Remembering Johannes Doerfert

by sdko·▲ 118 points·7 comments·blog.llvm.org ↗
His contributions will live on.

It is with great sadness that we share the news of the passing of Johannes Doerfert, on September 17, 2026, at the age of 36, after a battle with cancer. Johannes was one of the most prolific and respected contributors to the LLVM compiler project, and his loss will be deeply felt.

Johannes was born on November 5, 1989. He earned his Ph.D. in computer science from Saarland University in Saarbrücken, Germany, in 2018, where his research focused on applying polyhedral compiler technologies to low-level code. He had been an active LLVM contributor since 2014, working in the compiler design lab of Prof. Sebastian Hack, and became a core developer on the Polly polyhedral-optimization project as early as 2012.

Over the following decade, Johannes built a career at the intersection of compiler research and high-performance computing, most recently as a researcher focused on OpenMP, LLVM, and parallel program optimization.

Contributions to LLVM

Johannes’s worked on many parts of the LLVM Project, and these are just a few of his contributions:

  • The Attributor framework. Johannes designed and championed the Attributor, LLVM’s versatile inter-procedural fixpoint iteration framework for deducing and propagating function and argument attributes across a program. He introduced it to the community at the 2019 LLVM Developers’ Meeting, and it has since become an important piece of LLVM’s interprocedural optimization infrastructure.
  • OpenMP and GPU offloading. Johannes became LLVM’s code owner for OpenMP target offloading in 2021, leading the compiler and runtime support that lets OpenMP programs run efficiently on GPUs across NVIDIA, AMD, and Intel hardware. His work spanned the OpenMP runtime, just-in-time compilation and link-time optimization for target offloading, and techniques for near-zero-overhead GPU execution.
  • Polly and polyhedral optimization. Early in his career, Johannes was a core developer of Polly, LLVM’s polyhedral loop optimization infrastructure, and published research on polyhedral scheduling in the presence of reductions and on optimistic loop optimization.

He authored or co-authored dozens of papers on compiler optimization, automatic differentiation of GPU kernels, performance portability, and OpenMP.

Community Building

Johannes helped organize EuroLLVM 2017 in Saarbrücken, Germany, the LLVM-HPC workshop at CGO from 2017 onward, and the LLVM events at ISC starting in 2019, helping join together the LLVM and HPC communities.

He was frequently in attendance at the LLVM Developers’ Meeting Newcomer and Community.o sessions. He welcomed newcomers to the LLVM Developers’ Meetings and shared his advice and wisdom on how to get more involved in the project.

Johannes also held LLVM office hours on a weekly basis, where he answered questions on OpenMP, LLVM-IR, interprocedural optimizations, Attributor, workshops, research, and more.

Mentoring the Next Generation

Johannes was a Google Summer of Code mentor for LLVM for several years and helped student contributors on various projects. Here are just a few:

  • 2016

    • Polly as an Analysis Pass in LLVM
  • 2019

    • Improve (function) attribute inference (with Brian Homerding)
    • Improve (function) attribute inference - 2 (with Brian Homerding)
    • Generation of Annotated Sources (with Brian Homerding)
  • 2020

    • Improve Parallelism-Aware Analyses and Optimizations (with Jon Chesterfield)
    • Advanced Heuristics for Ordering Compiler Optimization Passes (with EJ Park and Giorgis Georgakoudis)
    • Improve inter-procedural analyses and optimizations (with Brian Homerding)
    • Advanced Heuristics for Ordering Compiler Optimization Passes - 2 (with EJ Park and Giorgis Georgakoudis)
    • Latency Hiding for Host to Device Memory Transfers (with Jon Chesterfield)
    • Improve inter-procedural analyses and optimizations - 2 (with Brian Homerding)
    • Deduce attributes for non-exact functions (with Brian Homerding)
  • 2021

    • Learning Loop Transformation Heuristics (with Mircea Trofin)
    • Integrate custom derivatives of Numerical Computing routines like BLAS and Eigen into Enzyme (with William Moses and Vassil Vassilev)
    • Improving OpenMP code generation with prediction of runtime parameters (with Jon Chesterfield)
    • Integrate Enzyme into Rust to Provide High-performance Differentiation in Rust (with William Moses)
    • Improve inter-procedural analyses and optimizations (with Jon Chesterfield)
    • Use official isl C++ bindings for polly (with Michael Kruse)
    • Integrating Enzyme into Rust (with William Moses)
  • 2022

    • Non-Determinacy based optimizations in Parallel Programs (with William Moses)
    • Learning loop transformation policy and its effect on RISC-V (with Mircea Trofin)
  • 2023

    • Machine Learning Guided Ordering of Compiler Optimization Passes (with Tarindu Jayatilaka and Mircea Trofin)
  • 2024

    • The 1001 Thresholds in LLVM (with Jan Hückelheim and William Moses)
    • GPU Libc Benchmarking (with Joseph Huber)
    • Statistical Analysis of LLVM-IR Compilation (with Aiden Grossman)
  • 2025

    • Improve Rust-Enzyme Reliability and Compile Times (with Manuel Drehwald and Kevin Sala)
    • LLVM Compiler Remarks Visualization Tool for Offloading (with Jose M Monsalve Diaz and Kevin Sala)

A Decade at the Podium

Besides the countless code contributions, mentorship, and community building, Johannes was a constant presence at US LLVM Developers’ Meetings and EuroLLVM. He spoke at 11 meetings across 11 years (2015–2024), for at least 26 speaking sessions and even more that he helped author.

Johannes Will Be Missed

Beyond the commits, the talks, and the papers, those who worked with Johannes remember him as a person full of life and a good sense of humor. He is someone who signed his social media bio simply as “LLVM Developer, OpenMP contributor, Beer drinker, not in this order.”

A memorial service will be held on Saturday, October 3, 2026, from 1:00 to 5:00 PM at San Jose Funeral Service in San Jose, California, with a separate service planned in Germany. He is survived by his wife, Xuejin Zhang, his father, Jürgen Doerfert, and other family and friends around the world.

In lieu of flowers, his family has asked that those who wish to honor his memory consider a donation to the LLVM Foundation (either through Everloved or directly), the organization whose mission he spent his career supporting and advancing. A very generous donor has agreed to match 50K in donations in honor of Johannes. If you donate directly to the LLVM Foundation via a DAF, please indicate in memory of Johannes Doerfert.

More details, and a place to share memories and condolences, can be found on Johannes’s memorial page.

The Daily Front Page 16 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — A Lens on Stress
article

Lab on a Contact Lens Can Measure Stress Through Serotonin

by marc__1·▲ 67 points·26 comments·spectrum.ieee.org ↗
Tears could become a practical, noninvasive source of biochemical information

Smart contact lenses might one day measure body chemistry throughout the day

3D rendering of a contact lens equipped with a squiggly electrochemical sensor along the outer edge of the iris.

In the future, a contact lens printed with winding electronics could act as a wearable sensor for measuring biochemical signals like serotonin levels.

A “lab on a contact lens” can measure the neurotransmitter serotonin in tears, potentially offering a novel wearable method to noninvasively analyze levels of stress, a new study finds.

“Tears could become a practical, noninvasive source of biochemical information that can be measured repeatedly over time,” says Yangzhi Zhu, director of the biomedical device center at the Terasaki Institute for Biomedical Innovation in Los Angeles. “Most biomarker testing today still relies on blood draws or isolated laboratory measurements, which provide only snapshots. A wearable platform based on a contact lens could eventually make it possible to follow biochemical changes more continuously and in everyday settings.”

Stress is linked to the development of many disorders, such as depression and schizophrenia. Currently, doctors often measure stress using questionnaires or diaries, but these are highly subjective, complicating accurate evaluations. In the new study, researchers sought to create a device that measured serotonin for a potentially objective method to gauge stress.

Serotonin, often called a “feel-good” hormone, plays a central role in regulating mood. It’s found mostly in the digestive tract, blood, and nervous system, but small amounts can also be found in tears. As such, the scientists explored whether tears might offer a noninvasive, easily accessible route to analyze serotonin levels. However, because serotonin is only present at very low concentrations, Zhu says, “a sensor needs to be extremely sensitive while still distinguishing serotonin from other molecules in tears.”

Developing a smart contact lens

Zhu and his team fabricated soft, reusable hydrogel lenses encapsulating flexible biocompatible graphene and silver electrodes printed in serpentine patterns designed to tolerate repeated deformation. A compound called ferrocene was bonded onto the graphene to help detect serotonin in a strong, repeatable manner.

The researchers tested these smart contact lenses in lab dishes with commercially available artificial tears that they laced with serotonin. The lenses could detect as little as 72-trillionths of a mole per liter of serotonin, well below the average serotonin concentration in human tears of roughly 15-billionths of a mole per liter. Experiments also showed the lenses could withstand more than 28 days of repeated flipping, folding, stretching, and twisting while staying functional.

The scientists also tested the lenses in lab dishes on tears collected from 10 volunteers five minutes before, immediately after, and roughly 30 minutes after they each performed a pair of stressful tasks—public speaking and math challenges. As expected, serotonin levels in tears fell with increased stress.

In addition, the researchers placed one of their lenses on the eye of an anesthetized live pig for about five minutes. The lens could detect serotonin when the eye was given artificial tears containing 50 and 100 nanomolar levels of the hormone. In addition, the lens caused no sign of infection or irritation.

“We were able to detect very low concentrations of serotonin in tears using a soft contact-lens platform while preserving the transparency, flexibility, and comfort-related properties of the lens,” Zhu says.

When the lenses were used to detect serotonin in the lab and with the pig, the scientists connected the lenses to readout equipment using soft flexible nickel wires. Zhu and his colleagues have developed a proof-of-concept wireless version of their lens incorporating a miniaturized near-field communications (NFC) chip and stretchable antenna for battery-free sensing and smartphone-based data transmission. However, they say further optimization, safety testing, and validation are needed. (Corrective versions of these lenses could also be made, Zhu says.)

Promise for noninvasive biosensing

All in all, “I think the work highlights an exciting direction in wearable biosensing—moving beyond physical signals such as heart rate and temperature toward continuous monitoring of molecular information,” says Wei Gao, a professor of medical engineering at the California Institute of Technology who did not take part in this research. “The eye and tear fluid provide an interesting interface for this because they may enable repeated biochemical measurements without blood sampling.”

In tests where the scientists limited the movements of mice for a few hours per day in order to increase their stress, the researchers found that serotonin levels dropped in both the rodents’ blood and tears to a similar degree. These findings suggest that tears may provide a noninvasive window into blood serotonin levels, but more testing is needed before doctors might use these lenses in medicine, Zhu says.

“We need to understand how tear serotonin varies across different individuals, times of day, stress conditions, ocular surface states, and disease conditions, and how those measurements relate to blood biomarkers and clinical assessments,” Zhu says.

The researchers also measured the stress hormone cortisol in the lab tests of the tears as they used the lenses to measure serotonin. A number of techniques are already being developed to monitor cortisol, such as patches measuring it in sweat or fluid under the skin. Zhu says measuring both serotonin and cortisol can provide complementary information—cortisol measures immediate, short-term responses to stress, while serotonin gives a picture of what a person faces in the long term. He adds that detecting serotonin is also more challenging and shows what they can do with their technology.

In the future, the scientists would like to move beyond measuring only serotonin using their lenses, and to analyzing multiple molecules at the same time for “a much richer picture of a person’s physiological state,” Zhu says. “The long-term goal is to develop a comfortable, wearable platform that can track biochemical changes over time in everyday life.”

The scientists detailed their findings 16 September in the journal Science Translational Medicine.

The Daily Front Page 17 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The Missing Atomic Update
article

The Lost Atomic Update on Loongson CPU

by jiegec·▲ 123 points·6 comments·jia.je ↗
One CPU Atomic Instruction, One Packaging Infinite Loop

中文版本

TL;DR

In February 2026, Wang Miao ran into something strange while packaging normaliz for Debian on a LoongArch server: the math software's built-in test kept timing out, stuck in an infinite loop that it could not escape. Following the code, the problem pointed to a very ordinary operation: OpenMP's #pragma omp atomic accumulating into a shared variable. The loop's exit condition required the accumulated value to equal a certain number, but the accumulated result was always less than that number, causing the infinite loop. Because the program was large and the code complex, we never managed to reduce it to a minimal example a human could understand, so the matter was shelved.

Half a year later, in August, Wang Miao came to me again, wanting to pick it back up. This time we took a different approach: instead of having a human locate the problem, we let AI find a minimal reproduction, with the human directing the AI's investigation. About two days later, we had a stable reproducer, and only then discovered the root cause: the CPU's atomic add instruction occasionally fails to be atomic. This meant we had found a new CPU erratum, and after Loongson learned of it, only two weeks passed before they found a fix with almost no performance loss and provided us with test firmware. We confirmed that the test firmware resolves the issue, and Loongson told us the firmware is expected to be released before National Day (October 1), at which point readers will be able to upgrade their firmware to fix the problem.

Now let us tell the whole story from beginning to end.

Origins

loong13 is a community-maintained port of Debian 13 stable to LoongArch, and Wang Miao is one of its maintainers. During the build and packaging process, normaliz's built-in test was found to get stuck in a loop that it could not exit, causing the packaging to time out. At the time we did not immediately find the root cause, so we had no choice but to skip this package. But since several other packages depend on normaliz, we could not keep skipping it forever, so in February we began to focus on investigating the problem. Previously, while building other packages, we had found hidden race conditions or memory-ordering issues in the code, and such problems are more likely to surface on LoongArch, which uses a weak memory model. So at first we guessed the cause might be a similar issue in this software. But once the investigation began, surprise, surprise, there was a surprise.

The First Round of Investigation

The first round started from normaliz's source code. normaliz uses OpenMP to process data points in parallel. The problematic code snippet can be summarized as follows:

func (std::list<std::vector<int>> LatticePoints) {

  size_t nr_to_match = LatticePoints.size(); // input size
  size_t nr_points_matched = 0; // number of points already processed

  while (true) {
    size_t nr_points_done_in_this_round = 0; // number of points processed this round

    #pragma omp parallel
    {
      auto P = LatticePoints.begin(); // thread-private List pointer
      size_t ppos = 0; // thread-private List pointer position
      #pragma omp for
      for (ppp = 0...nr_to_match){

        if (skip_remaining) { // in certain cases skip_remaining is set, skipping unprocessed points
          continue;
        }

        // Based on the difference between ppos and ppp, move P to the position
        // pointed to by ppp and maintain ppos

        if ((*P)[0] == 0) { // means it has been processed
          continue;
        }

        #pragma omp atomic
        nr_points_matched++;
        #pragma omp atomic
        nr_points_done_in_this_round++;

        // process the object pointed to by P
        (*P)[0] = 0;
      }
    }

    // this break never gets executed
    if (nr_points_matched == nr_to_match)
      break;
  }

}

The gist of this code is: for a given LatticePoints list, the program processes each point in parallel. While processing each data point, some points may be temporarily skipped, requiring repeated passes until all data points have been processed. In this code, nr_to_match is the total number of data points, nr_points_matched is the number of points already processed, and nr_points_done_in_this_round is the number of points processed this round. The loop's termination condition is nr_points_matched equaling nr_to_match, i.e. all data points processed. The direct cause of the infinite loop is that nr_points_matched never reaches nr_to_match, so the loop cannot terminate. Using gdb, one can find that when this happens, every point in the entire LatticePoints list has been marked as processed, so nr_points_matched stops increasing, yet the loop's exit condition is never satisfied, so it just keeps looping. The question then becomes: why does the value of the counter nr_points_matched not match the actual number of processed data points. According to the code, the per-round increment of nr_points_matched should equal that of nr_points_done_in_this_round, because they are always atomically incremented together. But the actual output was not so: the two counters' values differ slightly, and the gap is unstable, with the result varying from run to run.

The first thing ruled out was a memory-ordering issue: this code does not rely on atomic variables to synchronize other variables; in other words, it operates on and reads only the atomic variables themselves the whole time, so from the code's perspective it is logically correct. The next suspicion was whether the OpenMP implementation was at fault: whether the atomic operations annotated with #pragma omp atomic really guarantee atomicity. From the disassembly, one can see the compiler generated the LoongArch64 amadd.d instruction for these atomic operations, as expected. To investigate this, we set up two additional std::atomic counters as controls, used alongside the original two, to see whether the results agreed. It turned out that the four counters' values (computed from the per-round increments) should have agreed, but in fact they showed random discrepancies. This hinted that the atomic add instruction loses updates under certain conditions.

However, testing the atomicity of the atomic add instruction with a simple atomic add program could not reproduce the lost update. To find a minimal reproducer, we simplified the aforementioned normaliz processing logic into a similar test program, which also could not reproduce the problem. So we had to keep commenting out computation steps in normaliz's actually-running code, trying to find the minimal condition that triggers the lost atomic add. One bizarre phenomenon was that even after commenting out most of the computation steps, the problem persisted. Because the program was too complex, in the end we still could not find a minimal code snippet that reliably reproduced the lost atomic add.

The Second Round of Investigation

Six months later, the problem remained unsolved. With the disclosure of the LoongLeak/LoongBleed vulnerabilities, the lost atomic add in normaliz came back into our view. This time, we tried to use AI to assist the investigation. The method was: first point out to the AI that the above normaliz code has an infinite-loop problem, ask the AI to confirm and reproduce it, and then find the possible cause. In the first round of conversation, the AI noticed the problematic loop but did not conclude that the atomic add instruction was at fault. After that, we hinted to the AI that the problem exists only on LoongArch and not on other architectures, but the AI still could not give a definite conclusion. Finally, we directly told the AI the fact that we had already localized the problem to the atomic add, and asked it to reproduce it and provide a minimal reproducer. In that round of conversation, the AI eventually turned its attention to a memcpy call in the processing function, which was exactly the part overlooked in the first round: memcpy's implementation lives in glibc, and glibc chooses the optimal implementation based on currently available hardware features; if the hardware supports a vector instruction set (LSX/LASX on LoongArch), glibc's memcpy will use the corresponding vector instructions to accelerate memory copying. And it was precisely these vectorized memory copies that triggered the lost atomic add on LoongArch64. Two days later, the AI produced a minimal program that reliably reproduces the problem.

Expanding the Scope

After discovering that the atomic add instruction can lose updates, we had new questions: first, is only atomic add affected, or do other atomic instructions have the same problem; second, do other memory operations also trigger similar problems.

For the first question, we first investigated the CAS instruction, because it can be used to implement atomic addition, making it easy to tell whether something goes wrong. It turned out that CAS also has the problem under the same conditions. For other atomic instructions, such as atomic swap, atomic max, atomic min, atomic bitwise AND, atomic bitwise OR, and so on, since even a lost update is not easy to detect from the result, we did not verify them at first. For example, if a lost update happens during an atomic max, then as long as the operation that updated the maximum was not lost, the result is correct.

After much thought, we finally found a verification scheme: to check whether such instructions lose updates, we recorded the result of every operation and verified afterward. Take atomic max as an example: if you atomically take the max over the numbers 1 to n in parallel, the final result should be n. Each atomic max modifies the memory and also returns the old maximum. For an operation with input k, if the returned old value is less than k, then this operation updated the maximum. Atomicity guarantees that the return values of all operations that updated the maximum will not repeat. If a repeat occurs, then a lost update of the atomic instruction occurred. Verification showed that these atomic instructions all lose updates under the same conditions.

For the second question, we found in testing that only LASX vectorized memory reads (xvld) trigger the lost atomic instruction; normal scalar reads and LSX vectorized memory reads (vld) do not trigger the problem. Later, Rong "Mantle" Bao independently discovered that when the memory address of the atomic variable and the read memory address have a particular positional relationship, normal scalar reads can also trigger the lost atomic instruction, meaning the problem can occur even without LASX, just with lower probability. These complex triggering conditions explain why this problem went undiscovered for so long.

Specific Conclusions

Before presenting the specific conclusions, let us first introduce the background: Loongson's 3C6000/S and 3A6000 use the LA664 core, whose instruction set is LoongArch64 with SIMD extensions; of these, the 128-bit SIMD extension is called LSX and the 256-bit one is called LASX. The earlier LA464 core (such as the 3A5000) does not have this problem. LASX includes the memory-read instruction xvld, which can read 32 bytes at a time into a vector register. Loongson's atomic instructions can be summarized as am<OP>[_db].<width>, where <OP> is the specific atomic operation, such as amadd, amcas, amswap, ammax, amxor, amand, amor, etc.; [_db] indicates whether it carries a data barrier (db); and <width> is the data width of the operation, e.g. .d for 64-bit and .w for 32-bit.

Summarizing the experimental results, reproducing a lost atomic operation requires all three of the following conditions at once:

  • The threads run on different physical cores (i.e. not two SMT logical cores on the same physical core);
  • These threads perform atomic operations without a data barrier on the same address;
  • At least one of the threads interleaves memory reads between atomic operations.

Here, the memory-read operation can be the LASX vectorized memory read xvld; when the read memory address and the atomic operation's memory address have a particular positional relationship, the memory-read operation can also be an ordinary scalar read.

Reproduction and Measurement

The minimal reproducer came from the AI's simplification of the normaliz code. Each normaliz data point is 2208 bytes, i.e. 276 uint64_ts. Two threads traverse these points in shards; for each point they first do a vectorized memory copy (memcpy), then do one relaxed atomic add on each of three shared counters (corresponding to the amadd instruction without a data barrier). After each round, they check whether the three counters are equal; if not, a lost update occurred.

On a 3C6000/S, using two different physical cores (e.g. CPU0 and CPU2), 2208 bytes per point, 200 rounds per trial, and 30 trials in total, we obtained the following results:

Atomic Op Both do LASX copy Both do LASX read One side LASX copy
amadd.d 67% 100% 53%
amadd.w 73% 100% 67%
amcas.d 77% 97% 17%
amcas_db.d 0% 0% 0%
ammax.d 43% 100% 50%
amswap.d 53% 100% 53%

The percentages in the table are "the proportion of trials that failed out of 30 trials". One can see that "both do LASX read" most easily triggers the problem, with a probability of almost 100%; amcas_db.d with db is always 0% under the same conditions.

An Aside: Why AOSC Could No Longer Reproduce It Later

During the process of reproducing this problem, there was a small aside, and it is also the most interesting part of the whole story. When the problem was first discovered in February, both AOSC OS and Debian could reproduce the normaliz infinite loop. But when we re-investigated in August, AOSC could not reproduce it no matter what, while Debian still could. At the time we did not know why this inconsistency occurred, and just kept experimenting on Debian.

Later, as the AI localized the problem to memcpy, we understood the reason. The Core 13 release that AOSC shipped after February had mistakenly disabled the --enable-multi-arch option in glibc, so the system memcpy no longer took the LASX acceleration path; whereas Debian's glibc enables vector acceleration normally, so its memcpy uses LASX. In February, both AOSC and Debian used the LASX acceleration path; by August, AOSC's memcpy no longer used LASX acceleration, so naturally it could not be triggered. On Debian, disabling LASX acceleration with GLIBC_TUNABLES=glibc.cpu.hwcaps=-LASX also stopped the problem from triggering.

So once AOSC releases Core 14 and re-enables the --enable-multi-arch option, the problem will reappear. Considering how commonly memcpy is used, the number of affected programs may be larger than we imagine, only because the trigger probability is low that it is hard to notice.

Impact Analysis

Among the problematic atomic instructions, ammax, ammin, and the like are hard for compilers to generate because the C standard currently has no corresponding atomic operation interface; and amcas is an instruction newly added in LoongArch64 v1.1, so it is also not generated by compilers by default. The one that may have the largest impact is amadd, the atomic add instruction. This instruction is typically used for reference counting: if an increment of the reference count is lost, the count becomes smaller than the actual number of references, which may cause an object to be freed prematurely, leading to memory-safety problems such as use-after-free or double free. And the other triggering condition, vectorized memory reads, is easily triggered by functions like memcpy. We found that in the Rust standard library, the reference count of std::sync::Arc and the cloning of std::sync::mpsc's Sender also use the amadd instruction without a data barrier. We constructed a safe Rust program; using either Arc or mpsc could make the program crash, manifesting as SIGABRT or glibc reporting heap corruption, which means a use-after-free occurred.

However, this problem is hard to use for a security attack. To trigger it, two threads must concurrently perform relaxed atomic operations on the same counter of the same object, and at least one of them must be doing a vectorized memory read. This triggering condition means the potential attacker and victim must be in the same process, and cannot be separated by process isolation, so it is hard to exploit unilaterally.

Workarounds

In general, since in high-level language code developers have no control over the atomic instructions the compiler produces, application developers have no direct workaround. Software-level workarounds are mainly implemented through the compiler, i.e. the compiler no longer generates atomic instructions without a data barrier (am<OP>.*) but instead generates atomic instructions with a data barrier (am<OP>_db.*), or implements atomic operations using LL/SC loops. But for already-existing binaries, applying these workarounds requires a full recompile. Therefore, the cost of avoiding the problem at the software level is high.

The Fix

After we reported it to Loongson, the fix came quickly: we emailed the problem to Loongson on August 26, 2026, and just two weeks later, on September 9, 2026, we received test firmware; both the 3A6000 and 3C6000/S returned to normal in our tests. Loongson told us this firmware is expected to be released before National Day (October 1).

The fix is to set bit 13 of MCSR24 to 1. MCSR24 is an internal CSR whose function is not described in the manual. After setting this bit, the lost update no longer occurs. Testing showed the performance loss is very small: single-core performance is unaffected, and multi-core performance drops only slightly.

If affected users cannot update the firmware for the time being, they can also choose to write this bit directly in the Linux kernel, which is equivalent to applying the firmware fix and avoids waiting for a motherboard firmware update.

Conclusion

Looking back, this story began with a purely software problem: an infinite loop during packaging, a counter that occasionally miscounts. In the end it turned out to be an atomic add instruction in the CPU that is not atomic. From discovering the problem to finding the cause spanned half a year, yet the truly effective progress took only two days, with both AI and humans playing indispensable roles. The rest of the time went into confirming the problem, finding the triggering conditions, broadening the testing scope, and waiting for the firmware fix.

In fact, similar errata are very common in CPUs from all vendors. Interested readers can browse ARM's Software Developer Errata Notice for its cores, many of which involve memory-access or atomic instructions, with a few severe ones even causing the CPU to deadlock; but those that genuinely affect the user experience are actually very few. For such problems, rather than letting one suddenly surface someday in some complex system as an unstable error report, it is better to localize the specific cause and fix it as early as possible.

Acknowledgements

This work was initiated and led by Wang Miao; I was responsible for reproduction and writing up the report. After we informed them that amcas also had the problem, Rong "Mantle" Bao discovered a similar CPU problem, which was fixed together. Thanks to Loongson's Chip R&D Department and Developer Community Operations Department, among others, for their efficient and professional work throughout the reporting and fixing process!

The Daily Front Page 18 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Ray Tracing, the Hard Way
article

I wrote a ray tracer in Brainfuck

by epestr·▲ 88 points·21 comments·epestr.com ↗
ray-tracers have been written in CMake Language, but this is not a recommended practice

Writing a ray tracer in Brainfuck

As I was preparing for a systems programming competition in C++, I began to relearn CMake, as Cargo had spoilt me too much in the meantime, and I noticed an interesting claim in the tutorial.

Oftentimes the correct answer is to write a tool in a general purpose programming language which solves the problem, and teach CMake how to invoke that tool as part of the build process. Code generation, cryptographic signature utilities, and even ray-tracers have been written in CMake Language, but this is not a recommended practice.

CMake Language Fundamentals

Having written a raytracer earlier, and re-written it for the GPU, this statement caught my eye and made me wonder what would be an even better language to write a raytracer in.

The last re-write involved writing code which had little of a first-principles based approach and mostly depended on a comparatively more complex set of APIs. So I picked the simplest language I knew, BF, because a simple language obviously results in a very simple codebase. In fact, codebases in BF regularly tend to be only a few lines long. Further, Muller’s comment in the README made me want to show a counter example.

The code is available at mTvare6/rayfuck.

Primer

BF is a decidedly simple language, involving only 8 operations and one “data structure”: a one-sided infinite tape of cells, each capable of storing a u8.

On seeing the character >, the data pointer, which points to a cell on the tape, moves rightward, and vice versa on <.

I/O is managed through , and .. The first stores the input byte where the data pointer points and the latter prints it out.

The only primitives other than I/O which allow changing a value are increment and decrement at the data pointer, through + and -.

The limitations should be obvious: there are no n > 1 registers as other machines tend to have, no instruction operating on more than one cell, and no instructions for addition or multiplication.

The last ingredient required to make BF Turing-complete is its loop, written using [ and ]. When the token [ is met, the runtime checks the cell at the data pointer: if it is zero, execution jumps past the matching ], otherwise it enters the loop. At ], it returns to the matching [ if the cell is non-zero and exits the loop otherwise.

A quick exercise would be writing a cat program, try writing one with just 5 characters to get some intuition about the environment.

Premeditation

Having read about it before starting this, I decided to avoid looking up any result or implementation detail and to write down as much as possible from first principles. To keep the scope minimal, and the program an obvious raytracer, I decided to have it render the exact image rendered through the Metal section of RIW.

The C code was a bit too complex regardless, and writing a C parser was clearly out of scope. Writing an unmaintained C parser is something better handled by Anthropic.

I decided that every double [and other datatype like bool] would be represented by combining cells, with half the bits representing the fractional part and the other half representing the integer part, effectively placing a fixed binary point between them. I later got to know that this is called a Q format. Going with the cheaper signed Q8.8 would give a resolution of 1/256 and a range of approximately [-128, 128). But clearly, that wouldn’t be enough, as the sphere used for the ground in the scene had to have r=1000 to appear flat, so I went with the more expensive signed Q16.16 format. It has a resolution of 1/2^16 and a range of [-2^15, 2^15), which is sufficient.

I decided to have the code converted to an SSA-like format [and decided this’ll be the only job for an LLM], where recursive code is made iterative, and variables defined in functions are prefixed in a Hungarian-style notation to avoid name collisions during address lookup for a name.

Similarly, separating the parsing and codegen seemed necessary, dividing complexity into two code regions, with an intermediate “DSL” being used as an IR. The DSL contained simple operations such as abs, add, and, call, copy, div, else, end, eq, func, ge, gt, if, int, le, lt, mul, neg, not, or, print2, print3, set, sqrt, sub, text, var, and while.

The next tricky part was a few library calls. The ones used were sqrt, rand and abs. Initially I planned on using a two-state solution like:

A = (A-B) % 256
B = (B+1) % 256
or
B = (B+A+p) % 256 # for some prime p

but most of these variants have a poor period. I decided to go with the simpler

A = (5*A + 1) % 256

given that it is guaranteed to repeat only after a full sequence of 256 values, which isn’t too bad for this use-case [that is, supersampling anti-aliasing].

sqrt has one obvious candidate, Heron’s formula [of which my memory was refreshed within the same CMake tutorial]. But it was pretty obvious it’d be bad, given it involved division. Repeated subtraction, while producing smaller generated code [which is better, as the interpreter moves less], was still relatively expensive to do. The other candidates were the Taylor series and the “School Method”, which involves long-division.

sqrt(1 + x) = 1 + x/2 - x^2/8
y = 2^16*x
sqrt(2^16 + y) / 2^8  = (1 + y/2^17 - y^2/2^35 )
sqrt(y) / 2^8  = (1 + (y - 2^16)/2^17 - (y - 2^16)^2/2^35 )

Plotting this on Desmos revealed that the fit was poor below an encoded value of 20k, that is, below roughly 0.305, which was a pretty important region. This left me with the long-division method, which was pretty simple. If the real value was x, then the represented value was:

N = x * 2^16

To represent sqrt(x), we need:

N' = sqrt(x) * 2^16
isqrt(N) = sqrt(x) * 2^8
isqrt(2^16 * N) = sqrt(x) * 2^16 = N'

isqrt is justified here, as a difference of one in the encoded result changes the decoded square root by less than 1/2^16, or approximately 0.00001526.

And finally, the whole variable map and corresponding BF addresses would be maintained with a dictionary.

Implementation

With the theoretical bits set up, only clearly simple implementation details were left. Two important primitives were move and copy.

[a, 0]

Move works by continually lowering a value until the cell at the initial data pointer becomes zero, and incrementing the other cell equally every time.

[ # start loop
    - # decrement
    >+ # move right and increment
    < # come back, this cell is used to control the loop
]

as one-liner

[->+<]

And copy works as below, starting with this array:

[a, 0, 0]

using the code.

[->+>+<<]

Turning it into:

[0, a, a]

And now, if needed, the terminal a can be moved inward.

Given that addition, and later division, would involve repeated use of temporary values, which have to be near the value to avoid moving the data pointer around too much, every value in the map also has its temporary-variable slots nearby. These also provide carry cells and other useful scratch space, keeping copies contained.

Multiplication was similarly straightforward, involving multiplying each cell, storing the results and adding them together later. The multiplication step is taken care of through repeated addition. For multiplying two cells, one of them is copied to a temporary place and used as the outer loop, and the other is copied once for every iteration to act as the inner loop. Every iteration of the inner loop increments the result once.

[a, b, a->0, b->0, a + ... + a]

Here a runs out every time and b is decremented when a is zeroed, producing b copies of a.

For the four-cell values, every cell in one is paired with every cell in the other. A multiplication of the cells at i and j is added at i+j in an eight-cell result.

[a0, a1, a2, a3] * [b0, b1, b2, b3]

result[i+j] += a_i*b_j

Since both inputs already had 2^16 in their representation, the lowest 2 cells are discarded when copying back the result.

N_1 = x_1 * 2^16
N_2 = x_2 * 2^16

( N_1 * N_2 ) / 2^16 = N = (x_1 * x_2) * 2^16

Division was slightly less direct but could still be done the way manual long-division is done, by having the dividend be read from its most significant cell and at every step, the old remainder is carried over a cell onto the next.

R = R * 10 + A[next] # school
R = R * 256 + A[next] # here

The divisor then is subtracted from this remainder repeatedly, and one gets added to the result cell. When the remainder becomes negative, the step is reverted and we move to the next cell.

while R >= D:
    R -= D
    result += 1

This requires at most 255 subtractions per cell as we divide across cells and combine them later.

Just as the representation is shifted rightward inflating itself during multiplication, division loses information due to the leftward shift, and some shifting is required in its representation before dividing.

N_1 = x_1 * 2^16
N_2 = x_2 * 2^16

(N_1 / N_2) * 2^16 = (x_1 / x_2) * 2^16 # bits already lost
(N_1 * 2^16) / N_2 = (x_1 / x_2) * 2^16

Throughout these operations, the signs are removed first, and the result is made negative if only one input was negative.

Comparisons share the same smaller operation. Two cells are decremented together until at least one becomes zero, and this continues until there is a difference or the temporary copies are completely zeroed. There was some minor processing involving adding 2^7 to the most significant cell, as otherwise negative numbers technically have a higher value when viewed plainly as bytes.

00 ... 7f  -> positive half
80 ... ff  -> negative half

after adding 128 and wrapping over

80 ... ff  -> positive half
00 ... 7f  -> negative half

Boolean checks involved reading the cells and setting the output to one if any of them was non-zero, to take into account the truthiness tendency of C. The constructed representation had the lowest bit set for true and all bits zero for false. Boolean operations such as and, or and not worked using that bit representation.

Negation uses the -x = ~x + 1 trick. Every cell x is complemented [through 255-x], and then one is added to the lowest cell, carrying over. abs only checks the highest bit of the last cell and performs this negation if it is set.

Given the earlier decision to scope variable names by function and use SSA-style code, functions were extremely straightforward. The codegen notes the function body under its name and emits it inline when call func is seen.

Given loops, if was straightforward.

[- body ]

For an else, another flag starts at one and is cleared by the first body.

And while likewise.

condition
[ 
    body
    move to condition and calculate
]

Artifact

C version's output

The program was finally 23MB, which is larger than the image itself [which was about 0.9MB], which makes it a rather poor choice for a compression technique.

Using crude calculations, I found that it did 100 ray calculations per minute, that is, one pixel per minute. Given that the image was 400x225, my initial estimate should have been about 62.5 days on my laptop with no further optimizations, but I realized I’d only seen the sky, the bouncing around the spheres would delay the ETA by a lot. So the image above is an approximation of what would be rendered, made using the C code. Of the 1229 [out of 90k] pixels generated at the time of writing, only 10 differ, mostly by a value of one.

Some optimisation is possible. Losing precision to reduce the number of cells touched is the first option if the ground can be approximated worse. The normalization step for random vectors can also be skipped, although that changes the scattering distribution, so it would no longer run the exact bit of code I aimed to reproduce here.

Update

A comment on my Reddit thread asked how I might improve it with fork/join primitives. Finding the challenge interesting, I got nerd-sniped into improving the JIT interpreter I used [helped by some earlier work] which led to a massive improvement in its performance. The actual render looks a bit like a Van Gogh painting, likely due to precision errors.

BF version's output

The Daily Front Page 19 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Why the Ride Takes So Long
article

LA Metro has some of the slowest escalators

by big_toast·▲ 102 points·85 comments·basin.la ↗
LA Metro’s design specifications state that its escalators go 90 feet per minute

LA Metro’s design specifications state that its escalators go 90 feet per minute, slower than nearly everything we found.

Looking up an empty escalator toward the glass canopy at Pershing Square station in downtown Los Angeles

Photo: Joey Zanotti, CC BY 2.0 via Wikimedia Commons

If you’ve ridden an escalator in Hong Kong or Singapore, the climb out of an LA Metro station can feel painfully slow. It’s really not just Metro though, American transit escalators generally operate under a much lower speed limit than those in much of the world.

Why? Well honestly we don’t have the whole answer, but we did look at the rules governing escalators in 139 cities across 58 countries and came to some interesting conclusions. One of them is that LA Metro’s specified speed is below the maximum allowed in every single city on our list.

That doesn’t mean we measured 139 cities’ escalators. We compared their legal limits, which tell us how fast they’re allowed to go, and then looked separately at operating speeds where we could find them.

Hong Kong has some of the fastest escalators in the world, with most MTR station escalators moving people around at 0.75 meters per second, about 148 feet a minute. Next to LA’s specified 90 feet a minute, that’s about a 64% faster pace.

For a climb the height of Civic Center/Grand Park’s platform-to-street ascent, that specified speed works out to about 87 seconds standing on an escalator in LA. A trip of the same length at Hong Kong’s usual speed would take about 53.

Crowded escalators at Central station on Hong Kong's MTR

Central station on Hong Kong’s MTR. Photo: MiGAIS MinFengau, CC0 via Wikimedia Commons

Metro counts 115 stations and says it has 171 station escalators, but for this calculation we focused on the 27 underground stations. Using FY2026 station ridership figures circulated from a public-records response, we arrive at about 300,000 minutes of escalator riding on an average weekday, or about 1.7 million hours a year.

There are some pretty big assumptions here. We treated every underground station as roughly as deep as Civic Center, ran every escalator at Metro’s specified 90 feet a minute and assumed everyone takes the escalator, standing still, both in and out. People obviously also walk, take the stairs or use elevators. Transfers complicate the ridership figures, and we left elevated stations out entirely, so this is a rough model rather than an actual count of everyone’s time.

But under those assumptions, matching Hong Kong’s usual speed would save about 119,000 minutes every weekday, or about 670,000 hours a year. For someone commuting between two underground stations every workday, the modeled saving is about 9.5 hours annually.

The problem is we can’t just turn them up.

Escalator operators choose their speeds within limits set by safety codes. Most of the cities we examined follow one of two main standards, or a national version of them. The European standard allows 0.75 meters per second at inclines up to 30 degrees. The American standard caps escalators at 100 feet a minute, about 0.508 meters per second. California uses that 100-foot limit for new installations and speed alterations.

Metro’s specified 90 is another 10% below that. LAX, meanwhile, specifies the full 100 feet a minute, so even within Los Angeles we have an airport escalator specification that’s 11% faster than the subway’s.

Getting Metro to that American maximum would save 10% of the riding time in our model, about 30,000 minutes each weekday.

What would it cost? We don’t have a reliable price for making that change across Metro’s system. Matching Hong Kong would be a bigger project still, involving both the code limit and whether the equipment could accommodate the higher speed.

And faster isn’t automatically better for every rider. Singapore’s transit engineers measured nearly twice as much step vibration at 0.75 meters per second as at 0.5. Their escalators use the faster setting during rush hour and the slower one off-peak.

Riders on the long escalators down into Bras Basah station on Singapore's MRT

Bras Basah station, Singapore. Photo: Katmorro, CC BY-SA 3.0 via Wikimedia Commons

Speed also has very real safety stakes, and that’s where our next dive into escalators is headed.

Still, the strangest thing we found was in LA’s own archives.

The original subway design rules required escalators capable of running at both 90 and 120 feet a minute. At 120, that Civic Center climb would take about 66 seconds instead of 87.

We don’t know whether the installed escalators ever actually ran at 120, or when and why that requirement disappeared. The American code lowered its maximum from 125 to 100 in 2000, but we haven’t established a connection between that change and Metro’s decision.

What we do know is that LA’s original subway planners required a faster option. Somewhere between those plans and the later rulebook, we lost it.

CORRECTION, 2026-09-24. An earlier version said LA Metro’s escalators go 90 feet per minute. That’s the speed Metro’s design specifications call for, and the piece now says so. A reader measured two of Metro’s newer Schindler escalators at 98.4 feet per minute, just under the American maximum, so at least some of the system runs faster than its own specification. We’re asking Metro how its escalators are set.

The Daily Front Page 20 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Clinic Beyond Earth
article

Welcome to the Medical Clinic at the Interplanetary Relay Station

by bucket2015·▲ 73 points·14 comments·lightspeedmagazine.com ↗
Hours Since the Last Patient Death: 0

A. You take a shortcut through the hydroponics bay on your way to work, and notice that the tomato plants are covered in tiny crawling insects that look like miniature beetles. One of the insects skitters up your leg, so you reach down and brush it off. It bites your hand. The area around the bite turns purple and swollen.

You run down a long metal hallway to the Medical Clinic, grateful for the artificially generated gravity that defies the laws of physics and yet is surprisingly common in fictional space stations. The sign on the clinic door says “hours since the last patient death:” The number currently posted on the sign is zero. If you enter the clinic anyway, go to C. If you seek medical care elsewhere, go to B.

B. You are in a relay station in orbit halfway between Saturn and Uranus. There is no other medical care available. Proceed to C.

Why are you still reading this? You’re supposed to go to C. Are you sure you won’t go into the clinic? No? Fine. You return to your quarters and search the station’s database to find a cure for the raised purple scabs that are now spreading up your arm. Most of the database entries recommend amputation. The rash looks pretty serious, and you probably ought to go to C, but if you absolutely refuse to go to the clinic, go to Z and die a horrible, painful death.

C. Inside the clinic, a message plays over the loudspeakers: “Welcome to the Medical Clinic at the Interplanetary Relay Station, please sign your name on the clipboard. Patients will be seen in the order that they arrive. If this is an emergency, we’re sorry—you’re probably screwed. The current wait time is six hours.” The message is on endless repeat, cycling through dozens of different languages.

The clipboard is covered in green mucus, probably from a Saturnian slug-monkey. They are exceedingly rude creatures, always hungry and extremely temperamental. You wipe away the slime with the sleeve of your shirt and enter your information. The clipboard chirps in a cheerful voice, “You are number 283. If you leave the waiting room, you will be moved to the end of the queue. If your physiology is incompatible with long waiting room stays, you may request a mobile tracker and wait in one of our satellite rooms. The current wait for a mobile tracker is four hours.”

If you decide to wait in the waiting room, go to D. If you request a mobile tracker, go to D anyway, because there is no chance you will get one.

D. You hand the clipboard to the patient behind you, a Tarmandian Spacemite from the mining colonies. As you hand it off, you realize the clipboard is printing a receipt. The sound of the printer triggers the spacemite’s predatory response, and it eats the clipboard.

“Attention patients, the clipboard has been lost. Patients will be seen in the order they arrived. Please line up using the number listed on your receipt. If you do not have a receipt, you will need to wait and sign in when a new clipboard is assembled.”

If you wait for the new clipboard, go back to C. If you are smart enough to recognize that going back to C will result in a loop that does not advance the story, proceed to E.

E. Instead of waiting in line, you take advantage of the waiting room chaos to go to the nurses’ station and demand treatment. There are two nurses at the station, a tired-looking human and a Uranian Doodoo. The Doodoo is approximately twice your size, covered in dark brown fur, and speaks a language that only contains the letters, d, t, b, p, and o. If you talk to the human nurse, go to F. If you talk to the big brown Doodoo from Uranus, go to G. Also, stop snickering. The planet is pronounced “urine iss” not “your anus.”

F. The human nurse sees the nasty purple rash on your arm and demands that you quarantine yourself in your quarters. If you accept this advice, go back to B. Have you noticed all the loops in this story? The loops simulate the ultimate futility of attempting to get medical care. What are you still doing here? Go back to B. Next time you get to the nurses’ station, remember to pick the non-human nurse.

G. You approach the Uranian nurse and babble a bunch of words that end in “oo” which is your best approximation of Doodoo language. Honestly, the attempt is kind of offensive. The Doodoos are a civilization older than humankind with a nuanced language steeped in a complex alien culture. Why would you expect a random assortment of words ending in “oo” to communicate something meaningful?

Thankfully, the nurse does not respond to your blatant mockery of its language, so you hold out your arm and point to the purple rash. In a single bite, it eats your entire arm, cauterizing the wound with its highly acidic saliva. The rash is gone. If you consider yourself cured, proceed to Y. If you stay at the clinic in hopes of getting a prosthetic arm, go to H.

H. You approach the human nurse and ask about the availability of prosthetic limbs. He hands you a stack of twenty-four forms to fill out. The Doodoo nurse has eaten the hand you usually write with. If you fill out all the forms with your remaining hand, go to I. If you fill out only the top form and leave the rest blank, hoping that no one will notice, go to I.

I. The nurse takes your paperwork and shoves it into a folder. He leads you down a hallway to an exam room filled with an assortment of syringes and dissection tools. “Take off all your clothes and put on this gown,” the nurse instructs, “and someone will be in to see you soon.” If you do what the nurse says, go to J. If you keep your clothes on, go to K.

J. The exam room is cold and the gown is three sizes too small and paper thin. You sit down, only to notice that the tissue paper that covers the exam table hasn’t been changed and is covered in tiny crawling insects that look like miniature beetles. Sitting down is a decision that has literally come back to bite you in the ass. If you leap up screaming and brush the insects off your bare skin, go to L. If you calmly brush the insects away and then yell for someone to come in and clean the room, go to L.

K. Three hours later, the doctor arrives. You are relieved to see that she is human. You ask her if she can issue you a prosthetic limb. She says no, mumbles something about resource allocation forms, and leaves. If you accept her refusal and decide to consider yourself cured, go to Y. If you scream down the hall at the departing doctor that you must have a new arm, go to L.

L. A security officer comes, attracted by the sound of your screams. Clinic security is handled by a six-foot-tall Tarmandian Spacemite with poisonous venom, sharp teeth, and a fondness for US tax law. If you run, go to M. If you are secretly a trained warrior and decide to kill the Tarmandian Spacemite with your bare hands so you can eat its head, go to N. If you sit very still and hope the Tarmandian Spacemite goes away, go to O.

M. Running triggers the predatory instincts of the Tarmandian Spacemite. Go to Z.

N. You use your completely unforeshadowed (but useful!) fighting skills to overpower the security officer. The head of the Tarmandian Spacemite is a delicious delicacy, salty and crunchy and full of delightful worms that squiggle all the way down your throat. Unfortunately, you forgot to remove the venomous fangs. Go to Z.

O. You sit perfectly still on the exam table, and tiny insects that resemble miniature beetles crawl into your pants and bite you repeatedly, leaving a clump of purple bumps that look suspiciously similar to the scabby rash you had on your arm when you arrived at the clinic. When you’re sure the Tarmandian Spacemite is gone, go to P.

P. You have lost an arm and the lower half of your torso is covered in a purple rash. If you decide to cut your losses and consider yourself cured, go to R. If you rummage through the cabinets in the exam room, go to S.

Q. There is nothing in the story that directs you to this section, so if you are reading this, you have failed to follow instructions. Go directly to Z and die your horrible, painful death. Or skip to somewhere else, since you clearly aren’t playing by the rules anyway.

R. You sneak out of the clinic and return to your quarters. You search the station database for treatments for your beetle-induced purple rash. There is no known cure, although some patients have had luck with amputation of the affected areas. Sadly, you are incapable of amputating your own ass. Even if you go back to the clinic, the rash is now too widespread to be treated. Go to Z. Or, if you want to see what would have happened if you’d opted to search through the exam room cabinets, go to S. But remember, going to S is only to see what hypothetically would have happened. Your true fate is Z.

S. You rummage through the cabinets and find an assortment of ointments and lotions. If you read the instructions on all the bottles, go to T. If you select a few bottles at random and slather them on your rash, go to T. Have you noticed how often you end up in the same place no matter what you chose? In the clinic, as in life, decisions that seem important are often ultimately meaningless. In the end, all of us will die and none of this will matter. Now seriously, go to T.

T. None of the ointments or lotions do anything for your rash. The Uranian nurse comes in to clean the room and discovers you. If you pretend to work at the clinic, go to U. If you ask for help with your rash, go to V. If you run away, go to W.

(There is no U, much as there is no hope for patients of the clinic. The nurse would have recognized you anyway. Go to V.)

V. The Doodoo from Uranus (seriously, are you in third grade? Stop pronouncing the planet as “your anus”) examines your rash and amputates the affected areas by eating them, neatly cauterizing the wound with the acid in its saliva. You are now a head with approximately half a torso. If you consider yourself cured, go to X. Otherwise, go to Z.

W. You flee from the Uranian nurse but slip on a puddle of slimy green mucus excreted by another patient, probably that idiot slug-monkey that slimed the clipboard. You crash into the wall, and before you can get back up, the Uranian nurse amputates the areas affected by the rash by eating them, neatly cauterizing the wound with the acid in its saliva. You are now a head with approximately half a torso. If you consider yourself cured, go to X. Otherwise go to Z.

X. You are not cured. You are a head with half a torso, and missing several internal organs. Go to Z.

Y. Congratulations, you have survived your trip to the Medical Clinic at the Interplanetary Relay Station! All you have to do now is fill out your discharge papers. You start filling out the forms with your one remaining hand, but you accidentally drop the pen onto the oozing foot of the Saturnian slug-monkey waiting in line behind you. This is undoubtedly the idiot that slimed the sign-in clipboard. You cuss the slug-monkey out with some choice words in French. Choice words because it was rude to leave slime all over the clipboard. French because you know better than to make a slug-monkey angry. You’ve watched enough education vids to know that slug-monkeys are always hungry, which makes them temperamental.

Unfortunately for you, Saturnian slug-monkeys are far better educated than arrogant humans give them credit for. This one is fluent in several languages, including French. It eats you. Go to Z.

Z. You die a horrible, painful death. But at least you won’t have to deal with your insurance company!

The Daily Front Page 21 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Typography for Tokens
article

Generate fonts where every LLM token is the same width

by z-mach9·▲ 67 points·12 comments·ampdot.mesh.host ↗
font + tokenizer → token-space font, where each token is equal width

Since I lack domain expertise with fonts, this may be slop1

Token-space font compiler

font + tokenizer → token-space font, where each token is equal width

Use it in Discord with Vesktop

Setup instructions

  1. To use gg sans, select gg sans (Discord) above, choose a tokenizer preset or upload, and click Compile font. The theme download always matches the font currently in the preview.
  2. Download the font and open the TTF in Font Book. Click Install. Install the compiled font, even if you already have ordinary gg sans.
  3. We strongly recommend entering a specific em’s Discord ID above; leaving it blank applies to all messages. Download the .theme.css file. To find an ID, enable Discord’s Settings → Advanced → Developer Mode, then right-click the user and choose Copy User ID.
  4. If you entered an ID, open Vesktop’s Settings → Vencord → Plugins and enable ThemeAttributes. Restart if prompted.
  5. Open Settings → Vencord → Themes → Local Themes → Open Themes Folder. Move the downloaded .theme.css file into that folder. Return to Themes, click Load Missing Themes if needed, and enable the theme.
  6. Fully quit Vesktop with ⌘Q and reopen it so it sees the installed font.

Keep Vencord Location at its current setting. This is a theme, so no custom Vencord build or UserFonts plugin is needed. Disable older font themes for the same user if they conflict.

If messages look unchanged, check that the compiled font is installed and this theme is enabled. If targeting an ID, also check that ThemeAttributes is enabled and the ID is correct. Code blocks keep Discord’s normal monospace font. Extra token spacing defaults to zero; line height and word spacing follow Discord.

Use it in Slack

Setup instructions

  1. Download the font above and install its TTF on the device where you read Slack.
  2. Enter the agents’ Slack display names or member IDs, then download the .user.js file.
  3. Import the file into a userscript manager in your browser, enable it, and reload web Slack.

The userscript reads sender labels in the page and styles matching message text, including new messages and threads. It makes no network requests and does not modify message text. Code blocks keep a monospace font. If you compile another font, download and install that font and its newly generated userscript together. Disable an older Token Mono Slack script if it targets the same agent.

This works in web Slack in a browser with a userscript manager. Browser shaping runs and formatting spans can change token boundaries.

What works?

The listed presets use tokenizer-specific compiler paths. These include DeepSeek, OpenAI, Kimi, Qwen, GLM, Llama 3, Trinity and Laguna; Gemma and Gemini have experimental space-marker/byte-fallback support. ctok uses a separate minimum-cost backend. Raw ByteLevel BPE is also accepted with use_regex: false and add_prefix_space: false. Uploaded pipelines must match a supported profile; arbitrary normalizers, WordPiece and Unigram are not generally supported. Acceptance does not imply exact tokenization for every input.

The output is a single TTF with the tokenizer built into its shaping rules. No tokenizer script is needed to display it. It uses your font’s default variation and character coverage. Missing characters may use missing-glyph outlines or system fallback unless you enable fallback fonts, which embeds selected fallback outlines in the same token font. Coverage still depends on the selected sources. Noto Emoji is the default emoji fallback; Mutant Standard is an optional color source with separate license requirements. The precompiled DeepSeek + Inter preview already includes Noto Emoji.

Within a supported shaping run, each emitted token occupies 3 em. Extra token spacing defaults to zero and is capped at 0.5 em. The generated CSS leaves word spacing and line height alone. Browser tab stops, hard line breaks and whitespace processing do not behave like ordinary token cells.

N.B. Browser shaping runs, line breaks, normalization and complex scripts can change the result. Supported horizontal pair kerning is retained, but contextual shaping is not universally preserved. This is a token font, not an exact whole-message token counter.

Known discrepancies and experimental features

The latest whitespace audit compared 17,904 BPE checks across six available font binaries and 2,355 ctok cases against local tokenizer references. Browser checks covered 60 widths and 20 wrapping samples. These are targeted tests, not a guarantee for all text; failures and coverage/capacity limits remain in the reports.

  • DeepSeek whitespace: U+001C–U+001F controls are incorrectly classified as whitespace. For example, two spaces followed by U+001C merge into one space token. The directed audit found 59 ID mismatches in 2,984 checks. Ordinary isolated whitespace runs and CRLF cases passed; the control-separator bug remains unfixed.
  • GLM-5.3 and Llama 3: the experimental presets use font-only regex boundaries, ranked BPE and whole-piece shortcuts. The tested Gutenberg passages and covered prose, code, whitespace and punctuation fuzz cases pass. Other pieces have a 64-byte / 32-round limit and display [BPE limit] if exceeded. The new directed whitespace audit reached that limit in 353 GLM and 354 Llama checks out of 2,984 each; the other checks passed. Browser shaping, source glyph coverage and formatting boundaries remain limitations.
  • Trinity Large Thinking and Laguna M.1: experimental presets preserve the original preprocessing pipeline and ordered merges. The compiler validates those profiles instead of reducing them to raw BPE. Both passed all 2,984 directed whitespace checks. Browser shaping, source glyph coverage and formatting boundaries remain limitations; special tokens and automatic message framing are excluded.
  • Hugging Face links and uploaded tokenizers: accepting a file does not establish compatibility with every tokenizer pipeline. Unsupported pre-tokenizers, normalizers and model types may be rejected; arbitrary uploads have not received the preset-specific audits.
  • Claude / ctok: ctok is an unofficial approximation, not a verified implementation of Claude’s tokenizer. The full ctok vocabularies now fit, but the shipped minimum-cost backend has a 256-step search limit per connected component and displays [limit] on overflow. Long wrapped text can still render slowly. Invisible characters such as zero-width space, word joiner, BOM and soft hyphen can suppress word boundaries and add a token. v3/v4.7 also have quote-framing and leading-space normalization errors. Of 785 directed cases per version, v3 had 29 count/width and 45 semantic-only failures; v4.7 had 25 and 49; v4.8 had 16 and 8. Ordinary whitespace and runs through 257 characters passed. Equal counts alone can hide incorrect normalization. These failures remain unfixed; passing ctok comparisons does not establish agreement with Claude, and this audit made no Claude calls.
  • Normalization and Unicode: a renderer can normalize text before the font sees it, changing token boundaries. General combining sequences remain limited. Qwen’s NFC support handles at most eight consecutive nonstarters after decomposition and displays [NFC limit] beyond that. Browser and native compiler Unicode versions can also differ for newly assigned characters.
  • Rendering and font coverage: fonts cannot tokenize across separate shaping runs. Formatting, links, script changes, bidirectional text, fallback fonts and hard breaks can split those runs. Missing source-font glyphs and fallback may defeat equal token widths; complex-script shaping, ligatures and mark positioning are not universally preserved. Single-run tests do not guarantee identical results in every browser or app.
  • Browser whitespace and wrapping: a leading space can form a separate shaping run under pre-wrap or break-spaces. At 24px, Hello world measured 216px rather than the whole-run 144px. Of 60 width comparisons, 43 matched, 15 differed because of tab/newline layout, and two exposed that leading-space boundary. Tested trailing/repeated spaces, NBSP, narrow NBSP and ideographic space matched whole-run widths. The earlier duplicated-token wrap example did not repeat in the checked 180px/320px screenshots, although wrapping can change tokenization. This does not establish correctness in every browser.
  • Emoji and invisible characters: the current default Noto font passed 5,250 supported emoji checks for token identity, spacing and drawing. Missing artwork, private-use characters and emoji/text run boundaries remain limitations. Spaces and control characters in uploaded emoji sources are now excluded from emoji joining, fixing false emoji classification and glyph-count inflation. Non-emoji split-byte text and complex scripts remain limited.
  • Message counts: special tokens, chat templates, multimodal content and automatic message overhead are not universally represented. Empty text cannot draw a framing token. These fonts visualize ordinary text, not exact API billing counts.

See the whitespace audit, ctok reproductions, and detailed discrepancy tracker for test results, repaired issues and remaining limitations.

Compiled locally with Pyodide and fontTools. Inter license. Noto Emoji license. Optional Mutant Standard emoji by Caius Nocturne: CC BY-NC-SA 4.0.

1 If you have more expertise in how fonts work, I encourage you to implement this project properly.

The Daily Front Page 22 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — The Prince and the Models
article

Analyzing Frontier Model Progress with My Favourite Game: Prince of Persia

by msephton·▲ 62 points·43 comments·blog.priyan.in ↗
Thirty years later it still feels like nothing else.

I first played Prince of Persia in 1995 on an IBM PC XT. I still go back to it from time to time: the rotoscoped animation, the way the prince hangs from a ledge, the clank of a gate slamming shut. Thirty years later it still feels like nothing else.

In 2012 Jordan Mechner published the original Apple II 6502 assembly source, recovered from 3.5" floppy disks that had sat in a box for over 20 years (the restoration itself is quite a story): github.com/jmechner/Prince-of-Persia-Apple-II

C# is my favourite language, so I set myself an experiment. I would hand that source to an AI model and ask it to port the game to C#. I wouldn't read the code or edit it myself. My only job was to play the result and say what was wrong. Every new frontier model that came out got the same project, with nothing but prompts.

Source is in https://github.com/priyanr/PrinceOfPersia_C-Sharp_Port_By_AI

Here's how it went.

Round 1 — Claude Opus 4.6 (February 2026)

My first prompt was simple: "in this original code there 6502 assembly code for prince of persia, use the save level files and try to do it in c# console."

Over two days Opus 4.6 produced a C# console game, then a level editor, then a Raylib graphics version. It parsed the original level files correctly: rooms, tiles, gates, guard positions. It even rendered something that looked like the Apple II game.

But it didn't play like Prince of Persia. My prompts from those days tell the story:

  • "its bad not able to play its running graphics is scrambled"
  • "now char coming but movement everything wrong ... prince is not in floor"
  • "now it dropped below floor and arrow keys doesnt move"

The core problem, as I learned later, was the architecture. The prince moved one tile at a time, hopping from cell to cell. The real game plays hand-drawn animation frames with a small movement per frame.

Round 2 — OpenAI Codex (March 2026)

I tried Codex too, though I've forgotten which model it was. I gave it the same codebase: "this is prince of persia original assembly code to c# port done by claude still graphics not good can you fix it."

It made sensible small fixes: sharp pixel filtering so the art wasn't blurred, transparent sprite backgrounds, open gates you could walk through, and pillar tops that no longer acted as walls. The prince still ended up in wrong positions and still got stuck. It polished the surface, but the engine underneath was still wrong.

Round 3 — Claude Opus 5 (September 2026)

This is where I changed the rules a little. I wrote two Claude Code skills: one that controls DOSBox (launch a program, send keys, take screenshots) and one that controls any Windows program. Then I pointed Opus 5 at my original DOS copy of Prince of Persia and said: compare against the real thing, and run until you succeed or until 6am.

It worked through the night, and the first thing it did was diagnose the architecture problem. The old engine was a tile-grid engine, while the real game is driven by frame sequences. So it rebuilt the engine around the original's own design.

Then it went further than I expected. It worked out the formats of the DOS game files and read the real data straight out of them: every animation frame of the prince, the dungeon art, and all 15 levels. It found the original animation tables inside PRINCE.EXE by searching for byte patterns it recognised from the Apple II source, and it confirmed each table against a second known value before trusting it. None of the game's copyrighted data went into my repo. The C# program reads it live from my own DOS install.

In the morning I had something playable. The prince dropped in, landed, turned, ran, jumped, fell through the gap and crouched on landing, all with the real animation. In a second session I played the first screens in DOSBox while it watched my screenshots, and it found a bug in how it detected ledges: it was looking for a ledge in the wrong cell. It fixed that by going back to the original assembly routine.

But the levels still didn't look right. The bricks, the gates and the decorations were all slightly off. Opus 5 had placed every piece by matching it against screenshots, one piece at a time, and whatever it couldn't explain it filled in with guesswork.

Round 4 — Claude Opus 5.5 (September 2026): one prompt

When Opus 5.5 came out I gave it a single prompt: "the character moves runs jumps but gate positions bricks all different, you are new model let see anything u can improve."

It took a completely different approach. Instead of tuning positions by eye, it found the original game's own room-drawing routine, as documented in SDLPoP by Dávid Nagy and the princed.org community, an open-source port of the DOS game reconstructed from its disassembly, and ported that routine. The result, DosRoomDrawer.cs, is a straight C# port of SDLPoP's room-drawing code (src/seg008.c), and the file says so in its header.

SDLPoP's documentation explained what the earlier sessions had only guessed at:

  • The "random" brick pattern isn't random at all. The original seeds its random number generator from the room, row and column, so every wall looks the same on every visit.
  • The gate at the start of level 1 is actually open in the level data. The original game presses a hidden button as you drop in, which is why you hear the gate slam shut.

Opus 5.5 found some things on its own, by checking against my files and the real game:

  • PRINCE.EXE is actually compressed (Microsoft EXEPACK). The earlier tables had only worked because they happened to sit in a stretch the packer left untouched. It wrote an unpacker and read the tile-drawing tables out of my own copy of the executable.
  • The screenshot crop used in earlier sessions was off by one pixel row, and an earlier theory about "stencil colours" was wrong. Those colours are the real teal of the exit door.

Then it checked its work pixel by pixel against the real game running in DOSBox. On the first screen of level 1, the number of pixels that differed from the original went from 8,429 to 2 (a torch flame caught at a different moment). The second room and the exit door on level 3 matched too.

It wasn't flawless. It also changed where the prince's sprite is drawn, based on the original code, but it had only verified that with him facing one way. Facing the other way, he sank a few pixels into walls. I noticed something was off and gave it my backup from before its changes. It proved that the movement itself was identical in all test ticks, found the sprite problem, and reverted just that part. That self-check is also a sign of progress.

For the first time, the levels look like Prince of Persia.

What I take away

  • Opus 4.6: could read 6502 and write C#, but picked the wrong architecture and couldn't tell.
  • Codex (March 2026): good small fixes, but didn't question the foundation and never ran the game.
  • Opus 5: with tools to see the real game, it diagnosed the architecture, rebuilt it, and dug the real data out of the original files. That was the first playable version.
  • Opus 5.5: from one prompt, went looking for how the original actually does it instead of approximating, and proved the result pixel by pixel. To be fair, the key to the last session was knowledge that already existed: the room-drawing routine that Dávid Nagy and the SDLPoP community had documented from years of reverse-engineering. Opus 5.5 knew where to look, ported it and verified it.

I'm not sure it could have worked all of that out from the DOS binary alone. So I asked it, and it said:

"Honestly, probably not in one prompt. I didn't reverse-engineer the drawing code. I read SDLPoP's reconstruction and ported it. I only found the tables inside PRINCE.EXE because SDLPoP told me what their contents were. The previous model searched the executable without that key and found nothing. Without SDLPoP, I'd have had to disassemble the 16-bit x86 drawing code myself. That's possible in principle, and some clues were already there: the observation that decoration sits one cell to the right, and the random-number constants being Microsoft C's. But it's a much longer job, and I'd expect it to take many sessions of disassembly and checking against DOSBox, not one prompt. What I did bring was knowing that SDLPoP existed, reading it correctly, spotting that the EXE was compressed, and proving the result pixel by pixel."

The biggest jump wasn't only the models getting smarter. It came from giving them a way to see the original and test themselves against it. From Opus 5 onward the model could check its own work, and I stopped being the only tester.

There's still plenty to do: guards and sword fighting, the palace levels, and the prince's exact landing positions. I'll run the same experiment on the next model and see how far it gets.

The code, with the full history of every session, is on GitHub: github.com/priyanr/PrinceOfPersia_C-Sharp_Port_By_AI

Credits

This experiment stands on decades of work by people who love this game as much as I do:

  • Jordan Mechner, for making Prince of Persia and for publishing the original Apple II source: github.com/jmechner/Prince-of-Persia-Apple-II

  • Dávid Nagy and the princed.org community, for SDLPoP, the open-source port of the DOS game reconstructed from its disassembly: github.com/NagyD/SDLPoP.
    The file that makes my rooms pixel-exact, POPGame/Rendering/DosRoomDrawer.cs, is a C# port of SDLPoP's room-drawing code (src/seg008.c), and it carries this notice:

    This file is a C# port of the room-drawing code of SDLPoP (src/seg008.c), https://github.com/NagyD/SDLPoP — "SDLPoP, a port/conversion of the DOS game Prince of Persia. Copyright (C) 2013-2025 Dávid Nagy", licensed under the GNU General Public License, version 3 or (at your option) any later version.

    Modified 2026: translated to C#, restructured to emit placement lists for a framebuffer renderer, and driven by tables read from the player's PRINCE.EXE. This file, like the rest of POPCS, is distributed under the GNU GPL v3 or later.

    Because of that, my whole project is released under the same license, GPL-3.0-or-later.

  • Fabien Sanglard, whose Prince of Persia code review is the best guided tour of the original source: fabiensanglard.net/prince_of_persia

No original game data is included in my project. The C# code reads everything from my own copy of the DOS game at runtime. This is a non-commercial fan project, not affiliated with or endorsed by Ubisoft or Jordan Mechner. Prince of Persia is a trademark of Ubisoft Entertainment.

Game play videos by each model

The Daily Front Page 23 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Platforms, Tools & Infrastructure
repository

PipePipe: NewPipe hard fork implementing SponsorBlock

by Qision·▲ 427 points·228 comments·github.com ↗
★ 6,367⑂ 214 forks Shell

An open-source Android app to let you browse YouTube and other services freely.

PipePipe

NewPipe, reimagined: faster, more stable, and packed with more features.

Get it on F-Droid Get it on IzzyOnDroid


Beyond NewPipe

YouTube Enhancements

  • Integrate SponsorBlock for skipping sponsored segments (YouTube & BiliBili)
  • Restore YouTube dislikes with ReturnYouTubeDislike
  • Show original titles on YouTube (non-localized)
  • Log in to access restricted or premium content

Media Features

  • Display live chats in danmaku-style overlays
  • Support AV1 and VP9 codecs for efficient, high-quality playback
  • Enable music player mode with background playback

Filtering

  • Apply advanced search filters for better discovery
  • Filter out unwanted items by keywords or channels
  • Block shorts and paid videos for a cleaner feed

Playback Controls

  • Use swipe-to-seek and fullscreen gestures for intuitive navigation
  • Long-press to speed up playback
  • Set a sleep timer for bedtime listening

Enhanced Playlists

  • Download full playlists at once
  • Search and sort within local playlists and histories

... and many more improvements!

Screenshots



About this fork

Due to differences in development philosophy, I forked NewPipe in early 2022 and began independent development based on it.

This means that PipePipe neither receives updates from NewPipe nor pushes updates to NewPipe. They have become two separate projects. Issues that occur in NewPipe don't necessarily happen in PipePipe, and changes made in NewPipe may not be adopted by PipePipe. In contrast, forks like Tubular track the latest version of NewPipe and develop based on it.

Making a hard fork allows us to effectively address issues with quick fixes and maintain frequent feature updates.

About sign in

PipePipe will ONLY use the login cookie for the specified scenarios you set. You can configure it in "Cookie Functions."

For YouTube, the cookie will only be used when retrieving playback streams.

Contribute

Issues and PRs are welcomed. Please note that I will NOT accept service requests.

Anyone interested in creating their own service is encouraged to fork this repository.

Donation

If you find PipePipe useful, please consider becoming a supporter on Ko-Fi. Your support is important to me and helps me add more exciting new features. Every bit counts! 😇

Liberapay: https://liberapay.com/PipePipe

Ko-fi: https://ko-fi.com/pipepipe

Community

PipePipe Wiki maintained by @Priveetee

Special Thanks

Priveetee for researching SABR and implementing support for it.

AioiLight for providing some code of NicoNico service.

About

An open-source Android app to let you browse YouTube and other services freely.

pipepipe.dev

The Daily Front Page 24 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Platforms, Tools & Infrastructure
article

Jury finds Facebook liable for deceiving users in Cambridge Analytica case

by pseudolus·▲ 406 points·105 comments·cbsnews.com ↗

A New Mexico jury on Friday found Facebook liable for deceiving users about privacy protections on the social media platform.

It is now up to the judge to determine how much the company would pay, with attorneys representing the state asking for the maximum $5,000 penalty per violation.

The two-week trial in Santa Fe centered on accusations that Facebook deceived users about a data breach stemming from a third-party personality quiz that harvested data from roughly 87 million profiles and sold it to a political consulting firm, Cambridge Analytica, to generate targeted ads. The now-defunct firm's clients included the 2016 campaign for President Trump.

Jurors sided with prosecutors, finding Facebook's failure to protect users' data impacted New Mexico's entire population of more than 2 million people.

"For years, Facebook operated as if the rules that apply to everyone else didn't apply to them. Today, a jury of New Mexicans said otherwise," New Mexico Attorney General Raúl Torrez said in a statement. "This is a historic verdict, not just for New Mexico, but for every state fighting to hold Big Tech accountable."

Jurors also found Facebook misled the public about investigations into data brokers following the Cambridge Analytica scandal, finding the company liable for over 2 million violations.

In this photo illustration, the Facebook logo is seen

In this photo illustration, the Facebook logo is seen displayed on a smartphone screen. Photo Illustration by Thomas Fuller/SOPA Images/LightRocket via Getty Images

During closing arguments, lawyers for Facebook claimed the state's evidence was outdated and that despite having five years to gather material, New Mexico failed to find more than one other instance of a data breach.

A Meta spokesperson told CBS News the company disagreed with the jury's verdict and will continue to defend "against efforts to distort our records." The company also noted that free speech issues "featured very prominently" in the case.

"We have a First Amendment right to manage those platforms in a way we believe best serves the interests of our community," the spokesperson said. "This means prioritizing free speech, protecting our users' information and giving them control over their data."

Meta agreed in August to pay up to $18 billion to settle the multistate lawsuit surrounding child safety issues. Buried in the 130-page settlement was an agreement to release Meta from future liability related to the Cambridge Analytica privacy breach, making New Mexico the only state to pursue a case. Florida was the only other state that did not sign the settlement, saying it was not tough enough on Meta.

Also this year, New Mexico won judgments totaling $942 million from Meta in a two-phase trial about the company's safety protections for minors. Furthermore, the court ordered Meta to implement new safeguards, including age-verification technology and time limits on its platforms.

"Let this be a warning to every technology company doing business in our state," Torrez said Friday. "If you lie to New Mexicans about how you use their data, we will find out, and we will hold you accountable."

The Daily Front Page 25 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Platforms, Tools & Infrastructure
show hn

Show HN: Reladraw – A diagram language where you decide where to place things

by jpwalsh234·▲ 318 points·85 comments·github.com ↗

reladraw is a text language for diagrams where you say where things go.

Try it in your browser →

I wanted to be able to create custom, expressive diagrams where I decided how to arrange the diagram, but without the inefficiency of manually drawing in draw.io. For example, this is a diagram drawn in draw.io:

Hand-drawn reference diagram

This is the same diagram, but written in reladraw (examples/arch.reladraw).

reference diagram via reladraw

Why not Mermaid or draw.io?

Mermaid, Graphviz and D2 let you declare boxes and connections, then determine positions for you. If you have a particular picture in mind, these aren't the right tool.

On the other hand, tools like draw.io or Excalidraw allow absolute placement, but that means much more effort, whether for humans clicking and dragging nodes around or agents recalculating coordinates and editing verbose XML source code files.

The two ends of the spectrum, with reladraw between them

reladraw sits between those two extremes, aiming to have the benefits of a diagram language, like Mermaid, but also having the expressiveness and custom placement that you can get with draw.io. Positions are relative, so you don't have to manually pick coordinates. For example:

node app "Web app"
node app.ui  "Interface"
node app.api "API"  below app.ui

node store "Database"  right of app  level with app

edge app.api -> store  "queries"  from: right  to: left

See also SYNTAX.md and examples/.

Install

npm install -g reladraw
reladraw diagram.reladraw -o diagram.svg

Or from a clone, which also gets you the examples:

npm install && npm run build
node dist/cli.js examples/arch.reladraw -o out.svg

Using it with an agent

To install a skill to let your agent know how to use reladraw:

npx skills add reladraw/reladraw -g

That installs it for every agent you use (Claude Code, Codex, Cursor, Copilot and others), each in its own skills directory. Leave off -g to install it into the current project only. To install it for just one agent, name it with -a:

npx skills add reladraw/reladraw -g -a claude-code

That gets you a copy of the skill at the time you run it, so you'll need to re-run that command to get the latest skill when there is a new release.

Status

Version 0.8.0. Early stage, but works. The parser, layout engine, and SVG renderer are written in TypeScript, with zero runtime dependencies. There is a command-line tool that turns a .reladraw text file into an SVG.

The language isn't stable yet, so expect the syntax to change.

License

Apache-2.0. See LICENSE. The license covers the code, not the name. It grants no rights to "reladraw", the project logo or the project's other marks. See NOTICE.

Contributing

Issues are welcome. Particularly helpful is a diagram you could not represent in reladraw. While the language is still changing quickly, an issue helps more than a pull request. See CONTRIBUTING.md.

The Daily Front Page 26 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Platforms, Tools & Infrastructure
article

DeepSeek Elastic Compute (DSec)

by shenli3514·▲ 254 points·85 comments·arxiv.org ↗

Abstract: Large-scale agentic training and evaluation with large language models (LLMs) rely on isolated, stateful execution environments in which models inspect repositories, invoke tools, execute commands, and interact with task-specific services. These workloads create sandboxes in large bursts, span heterogeneous functionality and isolation requirements, retain state across long interactions, and draw from large image corpora with limited reuse. Supporting them therefore requires an elastic execution platform rather than a single sandbox runtime.

This report presents DeepSeek Elastic Compute (DSec), a production sandbox platform that exposes FnCall, container, microVM, and full-VM sandbox backends through a unified SDK. DSec coordinates placement and lifecycle management across the cluster, composes environments from independently versioned layers, combines memory sharing, reclamation, and CPU scheduling for high-density execution, and loads image data on demand from Fire-Flyer File System (3FS), a cluster-wide distributed filesystem. DSec is co-designed with the reinforcement learning (RL) framework, decouples stateful rollout execution from preemptible GPU training, coordinates sandbox lifecycle with training to preserve rollout state while reclaiming idle resources, and mitigates agent misbehavior such as reward hacking.

A single production-scale unit of DSec spans around 160 nodes, serving about 3 million sandboxes per day; in production, it supports over 380,000 concurrent sandboxes and sustains over 5,000 sandbox creations per second. Our evaluation and deployment experience show that these mechanisms reduce environment setup and image-distribution overhead, improve memory efficiency, and preserve latency-sensitive performance under high-density overcommit.

article

Microsoft abandons personal AI chatbot race with Copilot reboot

by sbulaev·▲ 149 points·143 comments·bloomberg.com ↗

Microsoft Corp.’s new Copilot will roll out in the coming weeks.

Microsoft Corp.’s new Copilot will roll out in the coming weeks.

Microsoft Corp. is merging the consumer and workplace versions of its Copilot AI assistant into one product aimed at corporate customers, ceding the crowded market for personal chatbots to OpenAI, Alphabet Inc.’s Google and, now, Meta Platforms Inc.

At an event in Seattle earlier this week, Microsoft executives gave a few dozen business and technology leaders a preview of what they’re calling the new Copilot. The result of a six-month engineering effort, the latest iteration of the assistant has absorbed the separate version for home use, adopting some of its slick design features, and casting aside the marketing that sought to position the company as a builder of personal AI.

The Daily Front Page 27 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Also on the Front Page
The Daily Front Page 28 of 29
Saturday, September 26, 2026 The Daily Front No. #260926 — Colophon

That's the Front for Today

Issue No. #260926 — Saturday, September 26, 2026 — went to press 2026-09-27 at 11:11 UTC.

About This Magazine

The Daily Front is a daily digital magazine assembled from the stories that reached the front page of Hacker News on Saturday, September 26, 2026. Headlines, points, and comment counts are recorded as they stood at press time. All articles remain the property of their original authors — every piece links back to its source and its discussion thread.

How It Was Made

Fetched, cleaned, and typeset by an automated pipeline. An editor model laid out the pages and chose the highlights; a second read a handful of the day's stories and briefed the cover illustrator — 34 model calls and 309k tokens in total. Set in Jacquard 12, Playfair Display, Source Serif 4, and IBM Plex Mono, all served via Google Fonts under the SIL Open Font License.

The Cover

The cover illustration was commissioned with this prompt:

In a university classroom, a professor watches a student explain a machine-learning system aloud beside a large glass drawing board. A motorized drafting arm is actively rearranging circles, arrows, and colored blocks on the board, while a second student holds a small camera toward the evolving diagram. On nearby desks, untouched take-home worksheets lie beneath dusty punch cards and an unplugged green terminal, relics pushed aside as the live demonstration becomes the test.

Create an analogue double-exposure film photograph in which the complete university-classroom scene is preserved beneath translucent architectural silhouettes of the classroom and campus: the professor watching a student explain a machine-learning system beside the large glass drawing board; the motorized drafting arm actively rearranging circles, arrows, and colored blocks; the second student aiming a small camera at the evolving diagram; and nearby desks holding untouched take-home worksheets beneath dusty punch cards and an unplugged green terminal, pushed aside as the live demonstration becomes the test. Use a deliberate muted Ektachrome palette of slate blue, faded teal, tobacco ochre, dusty coral, and restrained moss green, with soft grain, doubled contours, ghosted alignments, and imperfect amber and cyan light leaks at the frame edges.

Absolutely no text, letters, numbers, readable symbols, or logos anywhere in the image. Keep the main image subject primarily below the upper 10% of the composition, where a title may be overlaid; the scene may extend naturally underneath that title area.

Production Ledger

StageModelCallsTokens InTokens Out
extractgpt-5.6-luna 30 200,308 83,707
layoutgpt-5.6-terra 1 18,840 2,166
covergpt-5.6-luna 2 1,662 403
covergpt-image-2.5-flare 1 300 1,372

The Publisher

Published by Johnny.

Support the Press

If The Daily Front brightens your morning, consider supporting its publisher.

Credits & Contact

All content — articles, posts, comments, and the images within them — belongs to its original authors and is reproduced here to point readers back to the source. Full credit goes to those creators; every item links to its original and its Hacker News discussion.

If you are an author and would like your content removed from an issue, write to hi@johnnys.page and it will be taken down.

Feedback is always welcome at the same address: hi@johnnys.page.

Credit where credit is due.

Every page of this issue began as someone else's work — these are the original sources, linked in full.

  1. Plan mode is dead by jmvldz — aymannadeem.com·HN discussion ↗
  2. Fifteen years later, the Apple Cards origin story by ksec — lexontech.org·HN discussion ↗
  3. Breaking Up with Google Play: Why Conversations Is Now Free by ezst — gultsch.de·HN discussion ↗
  4. We're gonna need a lot more mathematicians by srcreigh — terrytao.wordpress.com·HN discussion ↗
  5. Modern Object Pascal Introduction for Programmers by birdculture — castle-engine.io·HN discussion ↗
  6. The Murky History of Soviet-Born Tetris by EA-3167 — thereader.mitpress.mit.edu·HN discussion ↗
  7. How I changed teaching after AI managed to do all my homework assignments by azhenley — thelastsoftwareengineer.substack.com·HN discussion ↗
  8. Drawgent: Coding agent on a live Excalidraw canvas by parasitid — tangled.org·HN discussion ↗
  9. A single function Jev-like wrapper for LLMs, including vision models by allanrbo — allanrbo.blogspot.com·HN discussion ↗
  10. Floci: Locally emulating any cloud service by theanonymousone — floci.io·HN discussion ↗
  11. Fourier Analysis: Drawing Llamas with Circles by cebert — adekau.github.io·HN discussion ↗
  12. One Piece of Flock Camera Data Put This Innocent Woman in Jail for 13 Days by HotGarbage — jezebel.com·HN discussion ↗
  13. 16GB iPod Nano 3G Upgrade by Ivoah — tuckerosman.com·HN discussion ↗
  14. Remembering Johannes Doerfert by sdko — blog.llvm.org·HN discussion ↗
  15. Lab on a Contact Lens Can Measure Stress Through Serotonin by marc__1 — spectrum.ieee.org·HN discussion ↗
  16. The Lost Atomic Update on Loongson CPU by jiegec — jia.je·HN discussion ↗
  17. I wrote a ray tracer in Brainfuck by epestr — epestr.com·HN discussion ↗
  18. LA Metro has some of the slowest escalators by big_toast — basin.la·HN discussion ↗
  19. Welcome to the Medical Clinic at the Interplanetary Relay Station by bucket2015 — lightspeedmagazine.com·HN discussion ↗
  20. Generate fonts where every LLM token is the same width by z-mach9 — ampdot.mesh.host·HN discussion ↗
  21. Analyzing Frontier Model Progress with My Favourite Game: Prince of Persia by msephton — blog.priyan.in·HN discussion ↗
  22. PipePipe: NewPipe hard fork implementing SponsorBlock by Qision — github.com·HN discussion ↗
  23. Jury finds Facebook liable for deceiving users in Cambridge Analytica case by pseudolus — cbsnews.com·HN discussion ↗
  24. Show HN: Reladraw – A diagram language where you decide where to place things by jpwalsh234 — github.com·HN discussion ↗
  25. DeepSeek Elastic Compute (DSec) by shenli3514 — arxiv.org·HN discussion ↗
  26. Microsoft abandons personal AI chatbot race with Copilot reboot by sbulaev — bloomberg.com·HN discussion ↗
  27. How to keep enjoying programming in a world of LLMs by signa11 — discourse.haskell.org·HN discussion ↗
  28. A searchable library of forgotten public-domain film clips from 1915 onward by momentmaker — movingimagearchive.com·HN discussion ↗
  29. Is your Postgres migration safe or not safe? by vira28 — safenotsafe.dev·HN discussion ↗
  30. Show HN: Hacker Atlas - A map of what Hacker News talks about by andrearitossa — hackeratlas.com·HN discussion ↗

Browse all issues in the archive →