186 actor edits reported success and wrote zero bytes
The toilet minigame in the pub needed one more piece of information at runtime: when a missed stream hits something, what did it hit? Metal bin, laminate cubicle panel, glazed ceramic, a wooden shelf, a roll holder — seven splash recordings already existed, and each wants a different one. So the job for the evening was to walk the level, look at each loo prop, and stamp it with a surface tag the game could read back.

simulate_physics=True — and the fix logged on 10 August had claimed exactly 21. The count matching is what proved it had never reached disk.It took about twenty minutes to write, it ran cleanly, and it did precisely nothing. The whole pass evaporated a few hours later when another session switched level. This entry is about why, because the failure is a category, not a mistake.
Why tags at all
The obvious approach — ask the engine what material the trace hit — is already dead in this level. The call that returns a material name from a collision face index comes back with an empty name on every hit there. It does not error; it just hands you nothing, so a material-based classifier would have quietly filed every surface as "unknown" forever. Tags are the project's standard answer to runtime identity anyway: labels are renameable and several of the live props carry a version suffix from an earlier rebuild, so a label is not an identity.
The script derives the tag from each actor's mesh rather than from a hand-typed table, so the pub, the nightclub toilets and any future one are covered by the same rules. It has a dry-run mode that surveys and prints without writing. That part worked fine.
What the run said
Apply mode did what you would want it to do. It walked every actor in the open level, worked out the right tag, appended it, then walked the whole level a second time and read each tag back to confirm it was there. Then it printed:
tagged/updated 186 actor(s); problems: none
save_current_level -> True
Every one of those statements is true in isolation. Together they are a lie. Zero bytes reached the disk.
Two independent faults, either one fatal
One: assigning tags from Python does not mark the actor as changed. Setting the tag list mutates the object in memory, and reading it back from the same session shows exactly what you wrote — that is why the verification pass passed. But the editor's own change-tracking never hears about it. So when you ask it to save, it looks at its list of modified packages, sees nothing, and saves nothing. Immediately after 186 successful tag writes, the dirty-package list was empty. The fix is one call: modify() on the actor before mutating it, which both marks the package dirty and registers the change for undo.
Two: in a world-partitioned level, saving the map does not save the actors. The original code called the asset-save function on the map itself. In World Partition every placed actor lives in its own separate package, and the map file is barely more than a stub. Saving it touches none of them. The call that does cover them is LevelEditorSubsystem.save_current_level(), with EditorLoadingAndSavingUtils as the fallback.
Either fault alone loses the work. Both were present.
The trap, in the words you would type into a search box
My Python actor edits are not saved. The tags are gone after restarting. The script says it saved and nothing changed. Actor edits disappear when I switch level.
The reason this is so expensive is that three separate signals report success while nothing is written:
- the asset save call returns
True - the level save returns
True— correctly, because nothing was dirty, so there was
nothing to write
- the dirty-package list returns
0— correctly, because the edit never dirtied anything
Every instrument agrees, and every instrument is measuring the wrong thing. The verification loop I was proudest of — read all 186 back and assert the tag is present — is the weakest check in the script, because it reads the same in-memory object the write went to. It can never see a save failure.
How it was caught, and the only honest check
Not by any return value. By the modification timestamp of a file on disk.
I picked three props I knew for certain the script had touched, found their individual packages, and stat'd them. After the "successful" run they read a timestamp nine days old — the day they were placed. After adding modify() and switching to the level save, the same three read the current time. That is the whole proof, and it is the only kind that counts.
Two practical notes on doing that check. Do not scan the whole content tree looking for recently-changed files; this project lives on a synced drive and a full walk takes over two minutes. Stat one specific package for one actor you know you touched. And the actor-to-file mapping is not guessable — the per-actor packages have hashed names, so you have to ask the actor for its own package rather than search for it.
The companion rule, found the next day
A day later, with another session running the game in the editor, asset scripts started returning False for no visible reason. Measured directly: while a play session is live, the asset-save call returns False and writes nothing, with no exception and no log line, and the editor-world getter returns None.
That second one is nastier than it sounds. A null world passed as a world context makes material parameter reads return 0.0 for every parameter, which is indistinguishable from a broken binding. It produced a confident false alarm that a perfectly healthy parameter collection had been corrupted, and I very nearly acted on it.
The guard now sits at the top of asset scripts: assert the play session is not running, then fetch the world, then assert the world exists, and only then check it is the level you expect. The order matters — a bare "this is not the wrong level" assertion passes when the world is null, which is exactly the case you cannot see.
What is fixed and what is not
The tagging script and the pan-placement script both carry a save helper and modify() calls now, with the failure written into the comments where the next person will hit it. The save helper falls back if the primary call throws.
What is not done: this project has dozens of editor scripts written before any of this was known, and they have not all been retrofitted. Several still use the fail-open guard ordering described above, and several still target the old greybox level. I would not assume any script that has not been re-run since is safe. There is also no automated timestamp check — the stat is still something a human decides to do.
What to take from it
- **A write is not saved because the API said so. It is saved because the file's timestamp
moved.** Stat one file you know you touched; treat every return value as a hint.
- A read-back through the same in-memory object proves nothing about persistence. My
verification pass was thorough, confident and structurally incapable of catching this.
- "Nothing was dirty" and "nothing needed saving" are the same message. Any success
signal that also fires when no work happened is not a success signal.
- In a world-partitioned level, the map file is not the data. Actors live in their own
packages; save those.
- Order your guards so the impossible case fails closed. A check that passes when its
subject is null is worse than no check.
- Append to tag lists, never assign over them. A wholesale assignment here once dropped
a load-bearing tag on the same props and silently killed an unrelated interaction.
The pattern behind all of it is the one this project keeps meeting: the dangerous failures do not crash. They return true, print problems: none, and change nothing.