ruiciro.devfield notesworkgithub

September 10, 2026

A checksum you don't understand

Ruiciro RiveraSenior AI engineer
& game developer

Putting one cube into an Unreal level took two hours, and almost none of that was the cube.

I’m building a fighting-game prototype by describing the work to an agent instead of clicking through the editor myself. Before any of that is worth attempting, the asset pipeline has to exist. So the goal for this session was written down before I started, so I couldn’t quietly move it once things got hard:

I say “put an object in my game,” and a chain runs: script → Blender → asset file → Unreal → an object standing in a level.

If that works, I have a pipeline and everything after it is content. If it doesn’t, everything after it is a plan.

It works. The interesting part is what I almost got wrong while checking that it works.

A two-metre orange cube standing at the origin of an Unreal level, next to the PlayerStart.

Headless Blender instead of a Blender agent

There are two ways to let an agent drive Blender. One is a third-party MCP addon: the agent gets a live session, sees the viewport, moves things around. The other is blender --background --python script.py, which is a script runner and nothing more.

I took the script runner, and not mainly for install risk — though Blender 5.2.1 had shipped two weeks earlier and I didn’t want to spend the session debugging an addon against a fresh release.

The real argument is that a pipeline step has to produce the same thing twice. With --factory-startup, Blender ignores my preferences, my startup file, my everything. Two consecutive runs of the same command gave me the same 15,660-byte FBX. That property is worth more to me than a viewport, because the moment the output depends on the state of my machine, nobody else can run this, and neither can I in three months.

The script takes --name, --size and --out, and prints exactly one line I care about:

ZMG_RESULT object=ZMG_HelloCube verts=8 tris=12 dim_m=(2.0, 2.0, 2.0) file=...fbx bytes=15660

That line exists because of the lesson from the last post: an agent that verifies its work by looking at a viewport isn’t verifying anything. Give it a number.

Hold on to verts=8.

Where the import actually lives

Epic’s MCP plugin exposes its surface through 19 toolsets. The one called AssetTools describes itself as “tools for interacting with assets in the Unreal project and files on disk,” which is where I went looking for an importer.

It isn’t there. What AssetTools has is read_file and write_file, both restricted to paths under /Game/, an enabled plugin’s Content/, or the project’s Saved/ directory, and both text-only. Sensible jail. Not an importer.

The importer lives in StaticMeshTools:

import_file(folder_path, asset_name, source_file,
            import_materials=False, import_textures=False, combine_meshes=True)

Worth knowing if you go looking: the tool that reads a mesh off your disk is filed under the asset type it produces, not under the toolset whose description contains the words “files on disk.” One call, and /Game/Exp000/SM_ZMG_HelloCube existed.

Then I checked it, and that’s the actual subject of this post.

Three numbers crossed the boundary

Anything that leaves one program and enters another arrives changed. The question is which changes mean the tool worked and which mean the tool ate your asset. I pulled three numbers back out of Unreal:

Blender Unreal Rule
Triangles 12 12 must match
Vertices 8 24 must not match
Height 2.0 m 200.00001525878906 must scale, with tolerance

Triangles held at 12. That’s the straightforward check, and it’s the one people write.

Vertices went from 8 to 24, and that is correct. A cube has eight corners in Blender because Blender stores position once and attaches per-face data separately. FBX and Unreal want a vertex to be one bundle of position plus normal plus UV, so every corner where three hard-edged faces meet becomes three vertices. Six faces, four corners each, twenty-four. Nothing was duplicated by accident and nothing was lost.

Now imagine I’d written the obvious check — the mesh that comes out must have the same vertex count as the mesh that went in. It fails. Not on a broken import: it fails on a correct one, every time, for every hard-edged mesh I will ever make.

Height came out as 200.00001525878906 Unreal units against 2 metres in Blender. Unreal works in centimetres, so 200 is right and the unit conversion survived. But it isn’t 200. It’s 200 plus a rounding tail from a 32-bit float round trip, so an equality assertion on that number fails as well — on a correct import, again.

Three numbers, three different definitions of “unchanged”: one exact match, one guaranteed mismatch, one match within tolerance. And the scale is the one that matters most, because it’s the only failure here that hides. A mesh that arrives 100× too small is still a mesh. It imports clean, appears in the content browser, spawns without an error, and you find out weeks later when your character is standing beside a chair the size of a coin.

The lesson I took from the last post was count things. That was incomplete, and here’s the correction: a check that fires when nothing is wrong is worse than no check at all. It goes off on day one, you decide it’s noisy, you switch it off, and now nothing is checked — including on the day it would have been right. Deciding what to count is the easy half. The half that costs you a session is knowing, per number, what a correct crossing looks like.

The optional parameters that aren’t, again

Last time I found a schema bug in find_actors: three filter arguments marked required that are plainly optional, so listing every actor in a level means explicitly passing "", "", [].

This session I wanted the agent to screenshot the viewport, and got this:

Function "CaptureViewport", input param "captureTransform" needs a default value.
Function input params Json - {}

The documentation string for that same parameter reads: “Optional pose to capture from. If unset, uses the viewport’s current camera.” So I supplied a camera transform. Then:

Function "CaptureViewport", input param "annotations" needs a default value.

Also documented as optional. Also not.

One occurrence in find_actors was a typo in somebody’s tool definition. Five occurrences across two toolsets is the schema generator dropping optionality on the way out, and it gives you a working rule for this plugin: the descriptions are written by people, the required list is generated, and when they disagree, believe the generator.

I’ll repeat what I said last time, because it held up twice: these errors are excellent. Each one names the parameter, says what’s wrong with it, and hands back the exact arguments I sent. That is the highest-leverage text in the system, because it arrives at the moment the caller is wrong and can still recover. Most tools answer 400 Bad Request and let you guess.

The health check that isn’t one

The log line I’ve been using to tell a working setup from a broken one reads N toolsets discoverable, and I’ve quoted 19 healthy, 1 dead. Watching startup properly this time, the 19 turns out not to be a verdict:

Tool search enabled: registered 3 meta-tools (14 toolsets discoverable via list_toolsets)
Tool search enabled: registered 3 meta-tools (15 toolsets discoverable via list_toolsets)
...
Tool search enabled: registered 3 meta-tools (19 toolsets discoverable via list_toolsets)

The line prints once per toolset as each registers, all within the same millisecond. It’s a running counter, not a verdict. And the last such line in my log from the previous session reads 18, because a toolset unregistered itself during shutdown and printed on the way out.

The number moves with what’s loaded and with when you look. The real signal is many versus one, and the line to read is the last one printed during startup.

Read as a fixed number, it’s one more check that fires when nothing is wrong: an 18 means a toolset unregistered on the way out, and you’d go hunting for a problem that isn’t there.

The agent can see now, and it still isn’t enough

CaptureViewport returns a PNG. There’s also an annotation mode that overlays a projected ground grid with coordinates in metres and leader-line labels on nearby actors, so a vision-capable model can say “the cube is at roughly (0,0)” rather than “there is a cube.”

Last time, the object the agent built was invisible: a StaticMeshActor with no mesh assigned renders nothing, and one new row in an outliner holding 145 of them announces nothing. This time I had the count and the picture, and they answered different questions.

The count told me the object exists — 145 actors before, 146 after, located by name. It could not have told me the cube was orange, sitting on the ground plane instead of buried halfway into it, or the right size next to the PlayerStart. The picture told me all three at a glance and couldn’t have told me the triangle count.

Neither one replaces the other. What the screenshot changes is that the agent can check its own spatial work without me at the keyboard. Everything above is why I’m still not going to let it grade its own homework with a picture.

The boring thing that had to go first

git-lfs was installed on this machine and the repo had no .gitattributes. Every .uasset I committed would have gone into git proper as a blob, and git history can’t be slimmed down afterwards without rewriting it.

So that landed before a single generated asset existed. The baseline commit moved 145 files and 32 MB into LFS — and a default level is 145 files because Unreal now stores one .uasset per actor under __ExternalActors__. One level with a landscape in it is a folder full of small binaries.

There’s no insight in this section. It’s just the step that has to be first and can’t be taken later.

Where this goes next

The level has a file on disk now. There’s a cube standing in it, the cube carries a material slot with the name I gave it in a Python script, and all of it is committed behind LFS.

Next is the part I’ve been circling: a capsule that moves, one attack, a hitbox and a hurtbox. The first task in this project that can’t be done in one tool call and can’t be verified by counting actors.

I expect that’s where the agent falls over. That’s the post I want to write.


Ruiciro Rivera — Senior AI engineer, AI enthusiast, and builder of worlds. By day I build production LLM systems; by night I ship my own products with Claude — and, occasionally, a video game. Find me on LinkedIn and GitHub.

← back